diff --git a/backend/migrations/103_schedule_dispensary_id.sql b/backend/migrations/103_schedule_dispensary_id.sql new file mode 100644 index 00000000..42218ffa --- /dev/null +++ b/backend/migrations/103_schedule_dispensary_id.sql @@ -0,0 +1,12 @@ +-- Migration: 103_schedule_dispensary_id.sql +-- Description: Add dispensary_id to task_schedules for per-store schedules +-- Created: 2025-12-13 + +-- Add dispensary_id column for single-store schedules +ALTER TABLE task_schedules +ADD COLUMN IF NOT EXISTS dispensary_id INTEGER REFERENCES dispensaries(id); + +-- Index for quick lookups +CREATE INDEX IF NOT EXISTS idx_task_schedules_dispensary_id ON task_schedules(dispensary_id); + +COMMENT ON COLUMN task_schedules.dispensary_id IS 'For single-store schedules. If set, only this store is refreshed. If NULL, uses state_code for all stores in state.'; diff --git a/backend/public/downloads/cannaiq-menus-1.7.0.zip b/backend/public/downloads/cannaiq-menus-1.7.0.zip new file mode 100644 index 00000000..e589858b Binary files /dev/null and b/backend/public/downloads/cannaiq-menus-1.7.0.zip differ diff --git a/backend/public/downloads/cannaiq-menus-latest.zip b/backend/public/downloads/cannaiq-menus-latest.zip index 99833311..026c7acd 120000 --- a/backend/public/downloads/cannaiq-menus-latest.zip +++ b/backend/public/downloads/cannaiq-menus-latest.zip @@ -1 +1 @@ -cannaiq-menus-1.6.0.zip \ No newline at end of file +cannaiq-menus-1.7.0.zip \ No newline at end of file diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index ffb4008a..9b768a46 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -286,6 +286,7 @@ router.post('/schedules', async (req: Request, res: Response) => { interval_hours, priority = 0, state_code, + dispensary_id, platform, } = req.body; @@ -300,12 +301,12 @@ router.post('/schedules', async (req: Request, res: Response) => { const result = await pool.query(` INSERT INTO task_schedules - (name, role, description, enabled, interval_hours, priority, state_code, platform, next_run_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (name, role, description, enabled, interval_hours, priority, state_code, dispensary_id, platform, next_run_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, name, role, description, enabled, interval_hours, - priority, state_code, platform, last_run_at, next_run_at, + priority, state_code, dispensary_id, platform, last_run_at, next_run_at, last_task_count, last_error, created_at, updated_at - `, [name, role, description, enabled, interval_hours, priority, state_code, platform, nextRunAt]); + `, [name, role, description, enabled, interval_hours, priority, state_code, dispensary_id, platform, nextRunAt]); res.status(201).json(result.rows[0]); } catch (error: any) { @@ -536,7 +537,7 @@ router.post('/schedules/:id/run-now', async (req: Request, res: Response) => { // Get the full schedule const scheduleResult = await pool.query(` - SELECT id, name, role, state_code, platform, priority, interval_hours, method + SELECT id, name, role, state_code, dispensary_id, platform, priority, interval_hours, method FROM task_schedules WHERE id = $1 `, [scheduleId]); @@ -547,9 +548,45 @@ router.post('/schedules/:id/run-now', async (req: Request, res: Response) => { const schedule = scheduleResult.rows[0]; let tasksCreated = 0; - // For product crawl roles with state_code, fan out to individual stores const isCrawlRole = ['product_discovery', 'product_refresh', 'payload_fetch'].includes(schedule.role); - if (isCrawlRole && schedule.state_code) { + + // Single-dispensary schedule (e.g., "Deeply Rooted Hourly") + if (isCrawlRole && schedule.dispensary_id) { + // Check if this specific store can be refreshed (no pending task) + const storeResult = await pool.query(` + SELECT d.id, d.name + FROM dispensaries d + WHERE d.id = $1 + AND d.crawl_enabled = true + AND d.platform_dispensary_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM worker_tasks t + WHERE t.dispensary_id = d.id + AND t.role IN ('product_discovery', 'product_refresh', 'payload_fetch') + AND t.status IN ('pending', 'claimed', 'running') + ) + `, [schedule.dispensary_id]); + + if (storeResult.rows.length > 0) { + await taskService.createTask({ + role: 'product_discovery', + dispensary_id: schedule.dispensary_id, + platform: schedule.platform || 'dutchie', + priority: schedule.priority + 10, + method: schedule.method || 'http', + }); + tasksCreated = 1; + } else { + return res.json({ + success: true, + message: `Store ${schedule.dispensary_id} has a pending task or is disabled`, + tasksCreated: 0, + dispensaryId: schedule.dispensary_id, + }); + } + } + // Per-state schedule (e.g., "AZ Product Refresh") + else if (isCrawlRole && schedule.state_code) { // Find stores in this state needing refresh const storeResult = await pool.query(` SELECT d.id @@ -599,9 +636,9 @@ router.post('/schedules/:id/run-now', async (req: Request, res: Response) => { }); tasksCreated = 1; } else { - // Crawl role without state_code - shouldn't happen, reject + // Crawl role without dispensary_id or state_code - reject return res.status(400).json({ - error: `${schedule.role} schedules require a state_code`, + error: `${schedule.role} schedules require a dispensary_id or state_code`, }); } diff --git a/backend/src/services/task-scheduler.ts b/backend/src/services/task-scheduler.ts index a3573361..ebcdd8c4 100644 --- a/backend/src/services/task-scheduler.ts +++ b/backend/src/services/task-scheduler.ts @@ -25,6 +25,7 @@ interface TaskSchedule { last_run_at: Date | null; next_run_at: Date | null; state_code: string | null; + dispensary_id: number | null; // For single-store schedules priority: number; method: 'curl' | 'http' | null; is_immutable: boolean; @@ -245,44 +246,75 @@ class TaskScheduler { * - Easier debugging and monitoring per state */ private async generateProductDiscoveryTasks(schedule: TaskSchedule): Promise { - // state_code is required for per-state schedules - if (!schedule.state_code) { - console.warn(`[TaskScheduler] Schedule ${schedule.name} has no state_code, skipping`); + let dispensaryIds: number[] = []; + + // Single-dispensary schedule (e.g., "Deeply Rooted Hourly") + if (schedule.dispensary_id) { + // Check if this specific store needs refresh + const result = await pool.query(` + SELECT d.id + FROM dispensaries d + WHERE d.id = $1 + AND d.crawl_enabled = true + AND d.platform_dispensary_id IS NOT NULL + -- No pending/running crawl task already + AND NOT EXISTS ( + SELECT 1 FROM worker_tasks t + WHERE t.dispensary_id = d.id + AND t.role IN ('product_discovery', 'product_refresh', 'payload_fetch') + AND t.status IN ('pending', 'claimed', 'running') + ) + `, [schedule.dispensary_id]); + + dispensaryIds = result.rows.map((r: { id: number }) => r.id); + + if (dispensaryIds.length === 0) { + console.log(`[TaskScheduler] Store ${schedule.dispensary_id} has pending task or is disabled`); + return 0; + } + + console.log(`[TaskScheduler] Creating task for single store ${schedule.dispensary_id} (${schedule.name})`); + } + // Per-state schedule (e.g., "AZ Product Refresh") + else if (schedule.state_code) { + // Find stores in this state needing refresh + const result = await pool.query(` + SELECT d.id + FROM dispensaries d + JOIN states s ON d.state_id = s.id + WHERE d.crawl_enabled = true + AND d.platform_dispensary_id IS NOT NULL + AND s.code = $1 + -- No pending/running product_discovery task already + AND NOT EXISTS ( + SELECT 1 FROM worker_tasks t + WHERE t.dispensary_id = d.id + AND t.role = 'product_discovery' + AND t.status IN ('pending', 'claimed', 'running') + ) + -- Never fetched OR last fetch > interval ago + AND ( + d.last_fetch_at IS NULL + OR d.last_fetch_at < NOW() - ($2 || ' hours')::interval + ) + ORDER BY d.last_fetch_at NULLS FIRST, d.id + `, [schedule.state_code, schedule.interval_hours]); + + dispensaryIds = result.rows.map((r: { id: number }) => r.id); + + if (dispensaryIds.length === 0) { + console.log(`[TaskScheduler] No stores in ${schedule.state_code} need refresh`); + return 0; + } + + console.log(`[TaskScheduler] Creating ${dispensaryIds.length} product_discovery tasks for ${schedule.state_code}`); + } + // No dispensary_id or state_code - invalid schedule + else { + console.warn(`[TaskScheduler] Schedule ${schedule.name} has no dispensary_id or state_code, skipping`); return 0; } - // Find stores in this state needing refresh - const result = await pool.query(` - SELECT d.id - FROM dispensaries d - JOIN states s ON d.state_id = s.id - WHERE d.crawl_enabled = true - AND d.platform_dispensary_id IS NOT NULL - AND s.code = $1 - -- No pending/running product_discovery task already - AND NOT EXISTS ( - SELECT 1 FROM worker_tasks t - WHERE t.dispensary_id = d.id - AND t.role = 'product_discovery' - AND t.status IN ('pending', 'claimed', 'running') - ) - -- Never fetched OR last fetch > interval ago - AND ( - d.last_fetch_at IS NULL - OR d.last_fetch_at < NOW() - ($2 || ' hours')::interval - ) - ORDER BY d.last_fetch_at NULLS FIRST, d.id - `, [schedule.state_code, schedule.interval_hours]); - - const dispensaryIds = result.rows.map((r: { id: number }) => r.id); - - if (dispensaryIds.length === 0) { - console.log(`[TaskScheduler] No stores in ${schedule.state_code} need refresh`); - return 0; - } - - console.log(`[TaskScheduler] Creating ${dispensaryIds.length} product_discovery tasks for ${schedule.state_code}`); - // Create product_discovery tasks with HTTP transport // Stagger by 15 seconds to prevent overwhelming proxies const { created } = await taskService.createStaggeredTasks( diff --git a/cannaiq/dist/index.html b/cannaiq/dist/index.html index fc585c57..000f5df2 100644 --- a/cannaiq/dist/index.html +++ b/cannaiq/dist/index.html @@ -7,7 +7,7 @@ CannaIQ - Cannabis Menu Intelligence Platform - + diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index d6c99df7..d990f084 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -3020,6 +3020,7 @@ class ApiClient { interval_hours: number; priority?: number; state_code?: string; + dispensary_id?: number; platform?: string; }) { return this.request('/api/tasks/schedules', { diff --git a/cannaiq/src/pages/TasksDashboard.tsx b/cannaiq/src/pages/TasksDashboard.tsx index 1cefe414..f8413a96 100644 --- a/cannaiq/src/pages/TasksDashboard.tsx +++ b/cannaiq/src/pages/TasksDashboard.tsx @@ -174,7 +174,9 @@ function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProp enabled: true, interval_hours: intervalHours, priority, - state_code: scheduleStateCode || undefined, + // Single store selected = per-dispensary schedule, otherwise use state filter + dispensary_id: selectedStores.length === 1 ? selectedStores[0].id : undefined, + state_code: selectedStores.length !== 1 ? (scheduleStateCode || undefined) : undefined, platform: 'dutchie', });