From a8fec97bcb6e7239c1976c63fd5d44e449598b1a Mon Sep 17 00:00:00 2001 From: Kelly Date: Sat, 13 Dec 2025 12:03:08 -0700 Subject: [PATCH] feat: Support per-dispensary schedules (not just per-state) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dispensary_id column to task_schedules table - Update scheduler to handle single-dispensary schedules - Update run-now endpoint to handle single-dispensary schedules - Update frontend modal to pass dispensary_id when 1 store selected - Fix existing "Deeply Rooted Hourly" schedule with dispensary_id=112 Now when you select ONE store and check "Make recurring", it creates a schedule that runs for that specific store every interval. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../migrations/103_schedule_dispensary_id.sql | 12 +++ .../public/downloads/cannaiq-menus-1.7.0.zip | Bin 0 -> 28791 bytes .../public/downloads/cannaiq-menus-latest.zip | 2 +- backend/src/routes/tasks.ts | 55 ++++++++-- backend/src/services/task-scheduler.ts | 102 ++++++++++++------ cannaiq/dist/index.html | 2 +- cannaiq/src/lib/api.ts | 1 + cannaiq/src/pages/TasksDashboard.tsx | 4 +- 8 files changed, 131 insertions(+), 47 deletions(-) create mode 100644 backend/migrations/103_schedule_dispensary_id.sql create mode 100644 backend/public/downloads/cannaiq-menus-1.7.0.zip 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 0000000000000000000000000000000000000000..e589858b787a79c45444bba1d317a1d3c3f4fe24 GIT binary patch literal 28791 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

Yu*zcvP$qKN9Mw46gNv40nP@b)ov@l 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

P*i@(}6yUEW8FN4b0qk2Za84u#J`X#dGKx3u4I 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 literal 0 HcmV?d00001 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', });