diff --git a/backend/src/dutchie-az/routes/index.ts b/backend/src/dutchie-az/routes/index.ts index 58c37dd6..388dcc15 100644 --- a/backend/src/dutchie-az/routes/index.ts +++ b/backend/src/dutchie-az/routes/index.ts @@ -20,6 +20,13 @@ import { getDispensaryById, } from '../services/discovery'; import { crawlDispensaryProducts } from '../services/product-crawler'; + +// Explicit column list for dispensaries table (avoids SELECT * issues with schema differences) +const DISPENSARY_COLUMNS = ` + id, name, slug, city, state, zip, address, latitude, longitude, + menu_type, menu_url, platform_dispensary_id, website, + provider_detection_data, created_at, updated_at +`; import { startScheduler, stopScheduler, @@ -106,7 +113,7 @@ router.get('/stores', async (req: Request, res: Response) => { const { rows, rowCount } = await query( ` - SELECT * FROM dispensaries + SELECT ${DISPENSARY_COLUMNS} FROM dispensaries ${whereClause} ORDER BY name LIMIT $${paramIndex} OFFSET $${paramIndex + 1} @@ -142,7 +149,7 @@ router.get('/stores/slug/:slug', async (req: Request, res: Response) => { const { rows } = await query( ` - SELECT * + SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE lower(slug) = $1 OR lower(platform_dispensary_id) = $1 @@ -191,7 +198,7 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => { // Get dispensary info const { rows: dispensaryRows } = await query( - `SELECT * FROM dispensaries WHERE id = $1`, + `SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`, [parseInt(id, 10)] ); @@ -209,7 +216,7 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => { COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count, COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock_count, COUNT(*) FILTER (WHERE stock_status = 'unknown') as unknown_count, - COUNT(*) FILTER (WHERE missing_from_feed = true) as missing_count + COUNT(*) FILTER (WHERE stock_status = 'missing_from_feed') as missing_count FROM dutchie_products WHERE dispensary_id = $1 `, @@ -254,10 +261,10 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => { started_at, completed_at, products_found, - products_inserted, + products_new, products_updated, error_message - FROM crawl_jobs + FROM dispensary_crawl_jobs WHERE dispensary_id = $1 ORDER BY created_at DESC LIMIT 1 @@ -343,29 +350,28 @@ router.get('/stores/:id/products', async (req: Request, res: Response) => { ` SELECT p.id, - p.platform_product_id, + p.external_product_id, p.name, - p.slug, p.brand_name, p.type, p.subcategory, p.strain_type, p.stock_status, - p.missing_from_feed, - p.first_seen_at, - p.last_seen_at, + p.created_at, p.updated_at, - -- Latest snapshot data - s.price_rec, - s.price_med, - s.special_price_rec, - s.special_price_med, - s.thc_potency_range, - s.cbd_potency_range, - s.total_quantity, - s.images, + p.primary_image_url, + p.thc_content, + p.cbd_content, + -- Latest snapshot data (prices in cents) + s.rec_min_price_cents, + s.rec_max_price_cents, + s.med_min_price_cents, + s.med_max_price_cents, + s.rec_min_special_price_cents, + s.med_min_special_price_cents, + s.total_quantity_available, s.options, - s.description, + s.stock_status as snapshot_stock_status, s.crawled_at as snapshot_at FROM dutchie_products p LEFT JOIN LATERAL ( @@ -390,35 +396,31 @@ router.get('/stores/:id/products', async (req: Request, res: Response) => { // Transform products for frontend compatibility const transformedProducts = products.map((p) => ({ id: p.id, - external_id: p.platform_product_id, + external_id: p.external_product_id, name: p.name, - slug: p.slug, brand: p.brand_name, type: p.type, subcategory: p.subcategory, strain_type: p.strain_type, - stock_status: p.stock_status, - in_stock: p.stock_status === 'in_stock', - missing_from_feed: p.missing_from_feed, - // Prices from latest snapshot - regular_price: p.price_rec, - sale_price: p.special_price_rec, - med_price: p.price_med, - med_sale_price: p.special_price_med, - // Potency - thc_percentage: p.thc_potency_range?.max || p.thc_potency_range?.min || null, - cbd_percentage: p.cbd_potency_range?.max || p.cbd_potency_range?.min || null, - // Images - extract first image URL - image_url: Array.isArray(p.images) && p.images.length > 0 - ? (typeof p.images[0] === 'string' ? p.images[0] : p.images[0]?.url) - : null, + stock_status: p.snapshot_stock_status || p.stock_status, + in_stock: (p.snapshot_stock_status || p.stock_status) === 'in_stock', + // Prices from latest snapshot (convert cents to dollars) + regular_price: p.rec_min_price_cents ? p.rec_min_price_cents / 100 : null, + regular_price_max: p.rec_max_price_cents ? p.rec_max_price_cents / 100 : null, + sale_price: p.rec_min_special_price_cents ? p.rec_min_special_price_cents / 100 : null, + med_price: p.med_min_price_cents ? p.med_min_price_cents / 100 : null, + med_price_max: p.med_max_price_cents ? p.med_max_price_cents / 100 : null, + med_sale_price: p.med_min_special_price_cents ? p.med_min_special_price_cents / 100 : null, + // Potency from products table + thc_percentage: p.thc_content, + cbd_percentage: p.cbd_content, + // Images from products table + image_url: p.primary_image_url, // Other - description: p.description, options: p.options, - total_quantity: p.total_quantity, + total_quantity: p.total_quantity_available, // Timestamps - first_seen_at: p.first_seen_at, - last_seen_at: p.last_seen_at, + created_at: p.created_at, updated_at: p.updated_at, snapshot_at: p.snapshot_at, })); @@ -781,7 +783,7 @@ router.get('/admin/stats', async (_req: Request, res: Response) => { // Get recent crawl jobs const { rows: recentJobs } = await query(` - SELECT * FROM crawl_jobs + SELECT * FROM dispensary_crawl_jobs ORDER BY created_at DESC LIMIT 10 `); @@ -906,7 +908,7 @@ router.get('/admin/jobs', async (req: Request, res: Response) => { cj.*, d.name as dispensary_name, d.slug as dispensary_slug - FROM crawl_jobs cj + FROM dispensary_crawl_jobs cj LEFT JOIN dispensaries d ON cj.dispensary_id = d.id ${whereClause} ORDER BY cj.created_at DESC @@ -916,7 +918,7 @@ router.get('/admin/jobs', async (req: Request, res: Response) => { ); const { rows: countRows } = await query( - `SELECT COUNT(*) as total FROM crawl_jobs ${whereClause}`, + `SELECT COUNT(*) as total FROM dispensary_crawl_jobs ${whereClause}`, params.slice(0, -2) ); @@ -1147,9 +1149,9 @@ router.get('/debug/summary', async (_req: Request, res: Response) => { (SELECT COUNT(*) FROM dispensaries WHERE platform_dispensary_id IS NOT NULL) as dispensaries_with_platform_id, (SELECT COUNT(*) FROM dutchie_products) as product_count, (SELECT COUNT(*) FROM dutchie_product_snapshots) as snapshot_count, - (SELECT COUNT(*) FROM crawl_jobs) as job_count, - (SELECT COUNT(*) FROM crawl_jobs WHERE status = 'completed') as completed_jobs, - (SELECT COUNT(*) FROM crawl_jobs WHERE status = 'failed') as failed_jobs + (SELECT COUNT(*) FROM dispensary_crawl_jobs) as job_count, + (SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'completed') as completed_jobs, + (SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'failed') as failed_jobs `); // Get stock status distribution @@ -1215,7 +1217,7 @@ router.get('/debug/store/:id', async (req: Request, res: Response) => { // Get dispensary info const { rows: dispensaryRows } = await query( - `SELECT * FROM dispensaries WHERE id = $1`, + `SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`, [parseInt(id, 10)] ); @@ -1233,7 +1235,7 @@ router.get('/debug/store/:id', async (req: Request, res: Response) => { COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock, COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock, COUNT(*) FILTER (WHERE stock_status = 'unknown') as unknown, - COUNT(*) FILTER (WHERE missing_from_feed = true) as missing_from_feed, + COUNT(*) FILTER (WHERE stock_status = 'missing_from_feed') as missing_from_feed, MIN(first_seen_at) as earliest_product, MAX(last_seen_at) as latest_product, MAX(updated_at) as last_update @@ -1267,11 +1269,11 @@ router.get('/debug/store/:id', async (req: Request, res: Response) => { started_at, completed_at, products_found, - products_inserted, + products_new, products_updated, error_message, created_at - FROM crawl_jobs + FROM dispensary_crawl_jobs WHERE dispensary_id = $1 ORDER BY created_at DESC LIMIT 10 @@ -1345,4 +1347,519 @@ router.get('/debug/store/:id', async (req: Request, res: Response) => { } }); +// ============================================================ +// LIVE CRAWLER STATUS ROUTES +// ============================================================ + +import { + getQueueStats, + getActiveWorkers, + getRunningJobs, + recoverStaleJobs, +} from '../services/job-queue'; + +/** + * GET /api/dutchie-az/monitor/active-jobs + * Get all currently running jobs with real-time status including worker info + */ +router.get('/monitor/active-jobs', async (_req: Request, res: Response) => { + try { + // Get running jobs from job_run_logs (scheduled jobs like "enqueue all") + const { rows: runningScheduledJobs } = await query(` + SELECT + jrl.id, + jrl.schedule_id, + jrl.job_name, + jrl.status, + jrl.started_at, + jrl.items_processed, + jrl.items_succeeded, + jrl.items_failed, + jrl.metadata, + jrl.worker_id, + jrl.worker_hostname, + js.description as job_description, + EXTRACT(EPOCH FROM (NOW() - jrl.started_at)) as duration_seconds + FROM job_run_logs jrl + LEFT JOIN job_schedules js ON jrl.schedule_id = js.id + WHERE jrl.status = 'running' + ORDER BY jrl.started_at DESC + `); + + // Get running crawl jobs (individual store crawls with worker info) + const { rows: runningCrawlJobs } = await query(` + SELECT + cj.id, + cj.job_type, + cj.dispensary_id, + d.name as dispensary_name, + d.city, + d.platform_dispensary_id, + cj.status, + cj.started_at, + cj.claimed_by as worker_id, + cj.worker_hostname, + cj.claimed_at, + cj.products_found, + cj.products_upserted, + cj.snapshots_created, + cj.current_page, + cj.total_pages, + cj.last_heartbeat_at, + cj.retry_count, + cj.metadata, + EXTRACT(EPOCH FROM (NOW() - cj.started_at)) as duration_seconds + FROM dispensary_crawl_jobs cj + LEFT JOIN dispensaries d ON cj.dispensary_id = d.id + WHERE cj.status = 'running' + ORDER BY cj.started_at DESC + `); + + // Get queue stats + const queueStats = await getQueueStats(); + + // Get active workers + const activeWorkers = await getActiveWorkers(); + + // Also get in-memory scrapers if any (from the legacy system) + let inMemoryScrapers: any[] = []; + try { + const { activeScrapers } = await import('../../routes/scraper-monitor'); + inMemoryScrapers = Array.from(activeScrapers.values()).map(scraper => ({ + ...scraper, + source: 'in_memory', + duration_seconds: (Date.now() - scraper.startTime.getTime()) / 1000, + })); + } catch { + // Legacy scraper monitor not available + } + + res.json({ + scheduledJobs: runningScheduledJobs, + crawlJobs: runningCrawlJobs, + inMemoryScrapers, + activeWorkers, + queueStats, + totalActive: runningScheduledJobs.length + runningCrawlJobs.length + inMemoryScrapers.length, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/dutchie-az/monitor/recent-jobs + * Get recent completed jobs + */ +router.get('/monitor/recent-jobs', async (req: Request, res: Response) => { + try { + const { limit = '50' } = req.query; + const limitNum = Math.min(parseInt(limit as string, 10), 200); + + // Recent job run logs + const { rows: recentJobLogs } = await query(` + SELECT + jrl.id, + jrl.schedule_id, + jrl.job_name, + jrl.status, + jrl.started_at, + jrl.completed_at, + jrl.duration_ms, + jrl.error_message, + jrl.items_processed, + jrl.items_succeeded, + jrl.items_failed, + jrl.metadata, + js.description as job_description + FROM job_run_logs jrl + LEFT JOIN job_schedules js ON jrl.schedule_id = js.id + ORDER BY jrl.created_at DESC + LIMIT $1 + `, [limitNum]); + + // Recent crawl jobs + const { rows: recentCrawlJobs } = await query(` + SELECT + cj.id, + cj.job_type, + cj.dispensary_id, + d.name as dispensary_name, + d.city, + cj.status, + cj.started_at, + cj.completed_at, + cj.error_message, + cj.products_found, + cj.snapshots_created, + cj.metadata, + EXTRACT(EPOCH FROM (COALESCE(cj.completed_at, NOW()) - cj.started_at)) * 1000 as duration_ms + FROM dispensary_crawl_jobs cj + LEFT JOIN dispensaries d ON cj.dispensary_id = d.id + ORDER BY cj.created_at DESC + LIMIT $1 + `, [limitNum]); + + res.json({ + jobLogs: recentJobLogs, + crawlJobs: recentCrawlJobs, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/dutchie-az/monitor/errors + * Get recent job errors + */ +router.get('/monitor/errors', async (req: Request, res: Response) => { + try { + const { limit = '20', hours = '24' } = req.query; + const limitNum = Math.min(parseInt(limit as string, 10), 100); + const hoursNum = Math.min(parseInt(hours as string, 10), 168); + + // Errors from job_run_logs + const { rows: jobErrors } = await query(` + SELECT + 'job_run_log' as source, + jrl.id, + jrl.job_name, + jrl.status, + jrl.started_at, + jrl.completed_at, + jrl.error_message, + jrl.items_processed, + jrl.items_failed, + jrl.metadata + FROM job_run_logs jrl + WHERE jrl.status IN ('error', 'partial') + AND jrl.created_at > NOW() - INTERVAL '${hoursNum} hours' + ORDER BY jrl.created_at DESC + LIMIT $1 + `, [limitNum]); + + // Errors from dispensary_crawl_jobs + const { rows: crawlErrors } = await query(` + SELECT + 'crawl_job' as source, + cj.id, + cj.job_type as job_name, + d.name as dispensary_name, + cj.status, + cj.started_at, + cj.completed_at, + cj.error_message, + cj.products_found as items_processed, + cj.metadata + FROM dispensary_crawl_jobs cj + LEFT JOIN dispensaries d ON cj.dispensary_id = d.id + WHERE cj.status = 'failed' + AND cj.created_at > NOW() - INTERVAL '${hoursNum} hours' + ORDER BY cj.created_at DESC + LIMIT $1 + `, [limitNum]); + + res.json({ + errors: [...jobErrors, ...crawlErrors].sort((a, b) => + new Date(b.started_at || b.created_at).getTime() - + new Date(a.started_at || a.created_at).getTime() + ).slice(0, limitNum), + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/dutchie-az/monitor/summary + * Get overall monitoring summary + */ +router.get('/monitor/summary', async (_req: Request, res: Response) => { + try { + const { rows: stats } = await query(` + SELECT + (SELECT COUNT(*) FROM job_run_logs WHERE status = 'running') as running_scheduled_jobs, + (SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'running') as running_dispensary_crawl_jobs, + (SELECT COUNT(*) FROM job_run_logs WHERE status = 'success' AND created_at > NOW() - INTERVAL '24 hours') as successful_jobs_24h, + (SELECT COUNT(*) FROM job_run_logs WHERE status IN ('error', 'partial') AND created_at > NOW() - INTERVAL '24 hours') as failed_jobs_24h, + (SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'completed' AND created_at > NOW() - INTERVAL '24 hours') as successful_crawls_24h, + (SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'failed' AND created_at > NOW() - INTERVAL '24 hours') as failed_crawls_24h, + (SELECT SUM(products_found) FROM dispensary_crawl_jobs WHERE status = 'completed' AND created_at > NOW() - INTERVAL '24 hours') as products_found_24h, + (SELECT SUM(snapshots_created) FROM dispensary_crawl_jobs WHERE status = 'completed' AND created_at > NOW() - INTERVAL '24 hours') as snapshots_created_24h, + (SELECT MAX(started_at) FROM job_run_logs) as last_job_started, + (SELECT MAX(completed_at) FROM job_run_logs WHERE status = 'success') as last_job_completed + `); + + // Get next scheduled runs + const { rows: nextRuns } = await query(` + SELECT + id, + job_name, + description, + enabled, + next_run_at, + last_status, + last_run_at + FROM job_schedules + WHERE enabled = true AND next_run_at IS NOT NULL + ORDER BY next_run_at ASC + LIMIT 5 + `); + + res.json({ + ...(stats[0] || {}), + nextRuns, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +// ============================================================ +// MENU DETECTION ROUTES +// ============================================================ + +import { + detectAndResolveDispensary, + runBulkDetection, + getDetectionStats, + getDispensariesNeedingDetection, +} from '../services/menu-detection'; + +/** + * GET /api/dutchie-az/admin/detection/stats + * Get menu detection statistics + */ +router.get('/admin/detection/stats', async (_req: Request, res: Response) => { + try { + const stats = await getDetectionStats(); + res.json(stats); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/dutchie-az/admin/detection/pending + * Get dispensaries that need menu detection + */ +router.get('/admin/detection/pending', async (req: Request, res: Response) => { + try { + const { state = 'AZ', limit = '100' } = req.query; + const dispensaries = await getDispensariesNeedingDetection({ + state: state as string, + limit: parseInt(limit as string, 10), + }); + res.json({ dispensaries, total: dispensaries.length }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /api/dutchie-az/admin/detection/detect/:id + * Detect menu provider and resolve platform ID for a single dispensary + */ +router.post('/admin/detection/detect/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const result = await detectAndResolveDispensary(parseInt(id, 10)); + res.json(result); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /api/dutchie-az/admin/detection/detect-all + * Run bulk menu detection on all dispensaries needing it + */ +router.post('/admin/detection/detect-all', async (req: Request, res: Response) => { + try { + const { state = 'AZ', onlyUnknown = true, onlyMissingPlatformId = false, limit } = req.body; + + const result = await runBulkDetection({ + state, + onlyUnknown, + onlyMissingPlatformId, + limit, + }); + + res.json(result); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /api/dutchie-az/admin/detection/trigger + * Trigger the menu detection scheduled job immediately + */ +router.post('/admin/detection/trigger', async (_req: Request, res: Response) => { + try { + // Find the menu detection schedule and trigger it + const schedules = await getAllSchedules(); + const menuDetection = schedules.find(s => s.jobName === 'dutchie_az_menu_detection'); + + if (!menuDetection) { + return res.status(404).json({ error: 'Menu detection schedule not found. Run /admin/schedules/init first.' }); + } + + const result = await triggerScheduleNow(menuDetection.id); + res.json(result); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +// ============================================================ +// FAILED DISPENSARIES ROUTES +// ============================================================ + +/** + * GET /api/dutchie-az/admin/dispensaries/failed + * Get all dispensaries flagged as failed (for admin review) + */ +router.get('/admin/dispensaries/failed', async (_req: Request, res: Response) => { + try { + const { rows } = await query(` + SELECT + id, + name, + city, + state, + menu_url, + menu_type, + platform_dispensary_id, + consecutive_failures, + last_failure_at, + last_failure_reason, + failed_at, + failure_notes, + last_crawl_at, + updated_at + FROM dispensaries + WHERE failed_at IS NOT NULL + ORDER BY failed_at DESC + `); + + res.json({ + failed: rows, + total: rows.length, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/dutchie-az/admin/dispensaries/at-risk + * Get dispensaries with high failure counts (but not yet flagged as failed) + */ +router.get('/admin/dispensaries/at-risk', async (_req: Request, res: Response) => { + try { + const { rows } = await query(` + SELECT + id, + name, + city, + state, + menu_url, + menu_type, + consecutive_failures, + last_failure_at, + last_failure_reason, + last_crawl_at + FROM dispensaries + WHERE consecutive_failures >= 1 + AND failed_at IS NULL + ORDER BY consecutive_failures DESC, last_failure_at DESC + `); + + res.json({ + atRisk: rows, + total: rows.length, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /api/dutchie-az/admin/dispensaries/:id/unfail + * Restore a failed dispensary - clears failed status and resets for re-detection + */ +router.post('/admin/dispensaries/:id/unfail', async (req: Request, res: Response) => { + try { + const { id } = req.params; + + await query(` + UPDATE dispensaries + SET failed_at = NULL, + consecutive_failures = 0, + last_failure_at = NULL, + last_failure_reason = NULL, + failure_notes = NULL, + menu_type = NULL, + platform_dispensary_id = NULL, + updated_at = NOW() + WHERE id = $1 + `, [id]); + + res.json({ success: true, message: `Dispensary ${id} restored for re-detection` }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /api/dutchie-az/admin/dispensaries/:id/reset-failures + * Reset failure counter for a dispensary (without unflagging) + */ +router.post('/admin/dispensaries/:id/reset-failures', async (req: Request, res: Response) => { + try { + const { id } = req.params; + + await query(` + UPDATE dispensaries + SET consecutive_failures = 0, + last_failure_at = NULL, + last_failure_reason = NULL, + updated_at = NOW() + WHERE id = $1 + `, [id]); + + res.json({ success: true, message: `Failure counter reset for dispensary ${id}` }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/dutchie-az/admin/dispensaries/health-summary + * Get a summary of dispensary health status + */ +router.get('/admin/dispensaries/health-summary', async (_req: Request, res: Response) => { + try { + const { rows } = await query(` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE state = 'AZ') as arizona_total, + COUNT(*) FILTER (WHERE failed_at IS NOT NULL) as failed, + COUNT(*) FILTER (WHERE consecutive_failures >= 1 AND failed_at IS NULL) as at_risk, + COUNT(*) FILTER (WHERE menu_type = 'dutchie' AND platform_dispensary_id IS NOT NULL AND failed_at IS NULL) as ready_to_crawl, + COUNT(*) FILTER (WHERE menu_type = 'dutchie' AND failed_at IS NULL) as dutchie_detected, + COUNT(*) FILTER (WHERE (menu_type IS NULL OR menu_type = 'unknown') AND failed_at IS NULL) as needs_detection, + COUNT(*) FILTER (WHERE menu_type NOT IN ('dutchie', 'unknown') AND menu_type IS NOT NULL AND failed_at IS NULL) as non_dutchie + FROM dispensaries + WHERE state = 'AZ' + `); + + res.json(rows[0] || {}); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + export default router;