Files
cannaiq/backend/dist/dutchie-az/routes/index.js
Kelly bd65674f3a fix(menu-detection): remove non-existent platform_dispensary_id_resolved_at column
The UPDATE query was trying to set a column that doesn't exist in the database
schema, causing platform ID resolution to fail silently. Now stores the
resolved_at timestamp in provider_detection_data JSONB instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 19:40:22 -07:00

1609 lines
53 KiB
JavaScript

"use strict";
/**
* Dutchie AZ API Routes
*
* Express routes for the Dutchie AZ data pipeline.
* Provides API endpoints for stores, products, categories, and dashboard.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const connection_1 = require("../db/connection");
const schema_1 = require("../db/schema");
const azdhs_import_1 = require("../services/azdhs-import");
const discovery_1 = require("../services/discovery");
const product_crawler_1 = require("../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
`;
const scheduler_1 = require("../services/scheduler");
const router = (0, express_1.Router)();
// ============================================================
// DASHBOARD
// ============================================================
/**
* GET /api/dutchie-az/dashboard
* Dashboard stats overview
*/
router.get('/dashboard', async (_req, res) => {
try {
const { rows } = await (0, connection_1.query)(`SELECT * FROM v_dashboard_stats`);
const stats = rows[0] || {};
res.json({
dispensaryCount: parseInt(stats.dispensary_count || '0', 10),
productCount: parseInt(stats.product_count || '0', 10),
snapshotCount24h: parseInt(stats.snapshots_24h || '0', 10),
lastCrawlTime: stats.last_crawl_time,
failedJobCount: parseInt(stats.failed_jobs_24h || '0', 10),
brandCount: parseInt(stats.brand_count || '0', 10),
categoryCount: parseInt(stats.category_count || '0', 10),
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// DISPENSARIES (STORES)
// ============================================================
/**
* GET /api/dutchie-az/stores
* List all stores with optional filters
*/
router.get('/stores', async (req, res) => {
try {
const { city, hasPlatformId, limit = '100', offset = '0' } = req.query;
let whereClause = 'WHERE state = \'AZ\'';
const params = [];
let paramIndex = 1;
if (city) {
whereClause += ` AND city = $${paramIndex}`;
params.push(city);
paramIndex++;
}
if (hasPlatformId === 'true') {
whereClause += ' AND platform_dispensary_id IS NOT NULL';
}
else if (hasPlatformId === 'false') {
whereClause += ' AND platform_dispensary_id IS NULL';
}
params.push(parseInt(limit, 10), parseInt(offset, 10));
const { rows, rowCount } = await (0, connection_1.query)(`
SELECT ${DISPENSARY_COLUMNS} FROM dispensaries
${whereClause}
ORDER BY name
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Get total count
const { rows: countRows } = await (0, connection_1.query)(`SELECT COUNT(*) as total FROM dispensaries ${whereClause}`, params.slice(0, -2));
res.json({
stores: rows,
total: parseInt(countRows[0]?.total || '0', 10),
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/stores/slug/:slug
* Resolve a store by slug (case-insensitive) or platform_dispensary_id
*/
router.get('/stores/slug/:slug', async (req, res) => {
try {
const { slug } = req.params;
const normalized = slug.toLowerCase();
const { rows } = await (0, connection_1.query)(`
SELECT ${DISPENSARY_COLUMNS}
FROM dispensaries
WHERE lower(slug) = $1
OR lower(platform_dispensary_id) = $1
LIMIT 1
`, [normalized]);
if (!rows || rows.length === 0) {
return res.status(404).json({ error: 'Store not found' });
}
res.json(rows[0]);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/stores/:id
* Get a single store by ID
*/
router.get('/stores/:id', async (req, res) => {
try {
const { id } = req.params;
const store = await (0, discovery_1.getDispensaryById)(parseInt(id, 10));
if (!store) {
return res.status(404).json({ error: 'Store not found' });
}
res.json(store);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/stores/:id/summary
* Get store summary with product count, categories, and brands
* This is the main endpoint for the DispensaryDetail panel
*/
router.get('/stores/:id/summary', async (req, res) => {
try {
const { id } = req.params;
// Get dispensary info
const { rows: dispensaryRows } = await (0, connection_1.query)(`SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`, [parseInt(id, 10)]);
if (dispensaryRows.length === 0) {
return res.status(404).json({ error: 'Store not found' });
}
const dispensary = dispensaryRows[0];
// Get product counts by stock status
const { rows: countRows } = await (0, connection_1.query)(`
SELECT
COUNT(*) as total_products,
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 stock_status = 'missing_from_feed') as missing_count
FROM dutchie_products
WHERE dispensary_id = $1
`, [id]);
// Get categories with counts for this store
const { rows: categories } = await (0, connection_1.query)(`
SELECT
type,
subcategory,
COUNT(*) as product_count
FROM dutchie_products
WHERE dispensary_id = $1 AND type IS NOT NULL
GROUP BY type, subcategory
ORDER BY type, subcategory
`, [id]);
// Get brands with counts for this store
const { rows: brands } = await (0, connection_1.query)(`
SELECT
brand_name,
COUNT(*) as product_count
FROM dutchie_products
WHERE dispensary_id = $1 AND brand_name IS NOT NULL
GROUP BY brand_name
ORDER BY product_count DESC
`, [id]);
// Get last crawl info
const { rows: lastCrawl } = await (0, connection_1.query)(`
SELECT
id,
status,
started_at,
completed_at,
products_found,
products_new,
products_updated,
error_message
FROM dispensary_crawl_jobs
WHERE dispensary_id = $1
ORDER BY created_at DESC
LIMIT 1
`, [id]);
const counts = countRows[0] || {};
res.json({
dispensary,
totalProducts: parseInt(counts.total_products || '0', 10),
inStockCount: parseInt(counts.in_stock_count || '0', 10),
outOfStockCount: parseInt(counts.out_of_stock_count || '0', 10),
unknownStockCount: parseInt(counts.unknown_count || '0', 10),
missingFromFeedCount: parseInt(counts.missing_count || '0', 10),
categories,
brands,
brandCount: brands.length,
categoryCount: categories.length,
lastCrawl: lastCrawl[0] || null,
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/stores/:id/products
* Get paginated products for a store with latest snapshot data
*/
router.get('/stores/:id/products', async (req, res) => {
try {
const { id } = req.params;
const { stockStatus, type, subcategory, brandName, search, limit = '50', offset = '0', } = req.query;
let whereClause = 'WHERE p.dispensary_id = $1';
const params = [parseInt(id, 10)];
let paramIndex = 2;
if (stockStatus) {
whereClause += ` AND p.stock_status = $${paramIndex}`;
params.push(stockStatus);
paramIndex++;
}
if (type) {
whereClause += ` AND p.type = $${paramIndex}`;
params.push(type);
paramIndex++;
}
if (subcategory) {
whereClause += ` AND p.subcategory = $${paramIndex}`;
params.push(subcategory);
paramIndex++;
}
if (brandName) {
whereClause += ` AND p.brand_name ILIKE $${paramIndex}`;
params.push(`%${brandName}%`);
paramIndex++;
}
if (search) {
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.brand_name ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
params.push(parseInt(limit, 10), parseInt(offset, 10));
// Get products with their latest snapshot data
const { rows: products } = await (0, connection_1.query)(`
SELECT
p.id,
p.external_product_id,
p.name,
p.brand_name,
p.type,
p.subcategory,
p.strain_type,
p.stock_status,
p.created_at,
p.updated_at,
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.stock_status as snapshot_stock_status,
s.crawled_at as snapshot_at
FROM dutchie_products p
LEFT JOIN LATERAL (
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
${whereClause}
ORDER BY p.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Get total count
const { rows: countRows } = await (0, connection_1.query)(`SELECT COUNT(*) as total FROM dutchie_products p ${whereClause}`, params.slice(0, -2));
// Transform products for frontend compatibility
const transformedProducts = products.map((p) => ({
id: p.id,
external_id: p.external_product_id,
name: p.name,
brand: p.brand_name,
type: p.type,
subcategory: p.subcategory,
strain_type: p.strain_type,
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
options: p.options,
total_quantity: p.total_quantity_available,
// Timestamps
created_at: p.created_at,
updated_at: p.updated_at,
snapshot_at: p.snapshot_at,
}));
res.json({
products: transformedProducts,
total: parseInt(countRows[0]?.total || '0', 10),
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/stores/:id/brands
* Get brands for a specific store
*/
router.get('/stores/:id/brands', async (req, res) => {
try {
const { id } = req.params;
const { rows: brands } = await (0, connection_1.query)(`
SELECT
brand_name as brand,
COUNT(*) as product_count
FROM dutchie_products
WHERE dispensary_id = $1 AND brand_name IS NOT NULL
GROUP BY brand_name
ORDER BY product_count DESC
`, [parseInt(id, 10)]);
res.json({ brands });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/stores/:id/categories
* Get categories for a specific store
*/
router.get('/stores/:id/categories', async (req, res) => {
try {
const { id } = req.params;
const { rows: categories } = await (0, connection_1.query)(`
SELECT
type,
subcategory,
COUNT(*) as product_count
FROM dutchie_products
WHERE dispensary_id = $1 AND type IS NOT NULL
GROUP BY type, subcategory
ORDER BY type, subcategory
`, [parseInt(id, 10)]);
res.json({ categories });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// PRODUCTS
// ============================================================
/**
* GET /api/dutchie-az/products
* List products with filtering on our own DB
*/
router.get('/products', async (req, res) => {
try {
const { storeId, stockStatus, type, subcategory, brandName, search, limit = '50', offset = '0', } = req.query;
let whereClause = 'WHERE 1=1';
const params = [];
let paramIndex = 1;
if (storeId) {
whereClause += ` AND dispensary_id = $${paramIndex}`;
params.push(parseInt(storeId, 10));
paramIndex++;
}
if (stockStatus) {
whereClause += ` AND stock_status = $${paramIndex}`;
params.push(stockStatus);
paramIndex++;
}
if (type) {
whereClause += ` AND type = $${paramIndex}`;
params.push(type);
paramIndex++;
}
if (subcategory) {
whereClause += ` AND subcategory = $${paramIndex}`;
params.push(subcategory);
paramIndex++;
}
if (brandName) {
whereClause += ` AND brand_name ILIKE $${paramIndex}`;
params.push(`%${brandName}%`);
paramIndex++;
}
if (search) {
whereClause += ` AND (name ILIKE $${paramIndex} OR brand_name ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
params.push(parseInt(limit, 10), parseInt(offset, 10));
const { rows } = await (0, connection_1.query)(`
SELECT
p.*,
d.name as store_name,
d.city as store_city
FROM dutchie_products p
JOIN dispensaries d ON p.dispensary_id = d.id
${whereClause}
ORDER BY p.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Get total count
const { rows: countRows } = await (0, connection_1.query)(`SELECT COUNT(*) as total FROM dutchie_products ${whereClause}`, params.slice(0, -2));
res.json({
products: rows,
total: parseInt(countRows[0]?.total || '0', 10),
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/products/:id
* Get a single product with its latest snapshot
*/
router.get('/products/:id', async (req, res) => {
try {
const { id } = req.params;
const { rows: productRows } = await (0, connection_1.query)(`
SELECT
p.*,
d.name as store_name,
d.city as store_city,
d.slug as store_slug
FROM dutchie_products p
JOIN dispensaries d ON p.dispensary_id = d.id
WHERE p.id = $1
`, [id]);
if (productRows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
// Get latest snapshot
const { rows: snapshotRows } = await (0, connection_1.query)(`
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = $1
ORDER BY crawled_at DESC
LIMIT 1
`, [id]);
res.json({
product: productRows[0],
latestSnapshot: snapshotRows[0] || null,
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/products/:id/snapshots
* Get snapshot history for a product
*/
router.get('/products/:id/snapshots', async (req, res) => {
try {
const { id } = req.params;
const { limit = '50', offset = '0' } = req.query;
const { rows } = await (0, connection_1.query)(`
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = $1
ORDER BY crawled_at DESC
LIMIT $2 OFFSET $3
`, [id, parseInt(limit, 10), parseInt(offset, 10)]);
res.json({ snapshots: rows });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// CATEGORIES
// ============================================================
/**
* GET /api/dutchie-az/categories
* Get all categories with counts
*/
router.get('/categories', async (_req, res) => {
try {
const { rows } = await (0, connection_1.query)(`SELECT * FROM v_categories`);
res.json({ categories: rows });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// BRANDS
// ============================================================
/**
* GET /api/dutchie-az/brands
* Get all brands with counts
*/
router.get('/brands', async (req, res) => {
try {
const { limit = '100', offset = '0' } = req.query;
const { rows } = await (0, connection_1.query)(`
SELECT * FROM v_brands
LIMIT $1 OFFSET $2
`, [parseInt(limit, 10), parseInt(offset, 10)]);
res.json({ brands: rows });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// ADMIN ACTIONS
// ============================================================
/**
* POST /api/dutchie-az/admin/init-schema
* Initialize the database schema
*/
router.post('/admin/init-schema', async (_req, res) => {
try {
await (0, schema_1.ensureSchema)();
res.json({ success: true, message: 'Schema initialized' });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/import-azdhs
* Import dispensaries from AZDHS (main database)
*/
router.post('/admin/import-azdhs', async (_req, res) => {
try {
const result = await (0, azdhs_import_1.importAZDHSDispensaries)();
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/resolve-platform-ids
* Resolve Dutchie platform IDs for all dispensaries
*/
router.post('/admin/resolve-platform-ids', async (_req, res) => {
try {
const result = await (0, discovery_1.resolvePlatformDispensaryIds)();
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/crawl-store/:id
* Crawl a single store
*/
router.post('/admin/crawl-store/:id', async (req, res) => {
try {
const { id } = req.params;
const { pricingType = 'rec', useBothModes = true } = req.body;
const dispensary = await (0, discovery_1.getDispensaryById)(parseInt(id, 10));
if (!dispensary) {
return res.status(404).json({ error: 'Store not found' });
}
const result = await (0, product_crawler_1.crawlDispensaryProducts)(dispensary, pricingType, { useBothModes });
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/admin/stats
* Get import and crawl statistics
*/
router.get('/admin/stats', async (_req, res) => {
try {
const importStats = await (0, azdhs_import_1.getImportStats)();
// Get stock status distribution
const { rows: stockStats } = await (0, connection_1.query)(`
SELECT
stock_status,
COUNT(*) as count
FROM dutchie_products
GROUP BY stock_status
`);
// Get recent crawl jobs
const { rows: recentJobs } = await (0, connection_1.query)(`
SELECT * FROM dispensary_crawl_jobs
ORDER BY created_at DESC
LIMIT 10
`);
res.json({
import: importStats,
stockDistribution: stockStats,
recentJobs,
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// SCHEDULER ADMIN
// ============================================================
/**
* GET /api/dutchie-az/admin/scheduler/status
* Get scheduler status
*/
router.get('/admin/scheduler/status', async (_req, res) => {
try {
const status = (0, scheduler_1.getSchedulerStatus)();
res.json(status);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/scheduler/start
* Start the scheduler
*/
router.post('/admin/scheduler/start', async (_req, res) => {
try {
(0, scheduler_1.startScheduler)();
res.json({ success: true, message: 'Scheduler started' });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/scheduler/stop
* Stop the scheduler
*/
router.post('/admin/scheduler/stop', async (_req, res) => {
try {
(0, scheduler_1.stopScheduler)();
res.json({ success: true, message: 'Scheduler stopped' });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/scheduler/trigger
* Trigger an immediate crawl cycle
*/
router.post('/admin/scheduler/trigger', async (_req, res) => {
try {
const result = await (0, scheduler_1.triggerImmediateCrawl)();
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/crawl/:id
* Crawl a single dispensary with job tracking
*/
router.post('/admin/crawl/:id', async (req, res) => {
try {
const { id } = req.params;
const { pricingType = 'rec', useBothModes = true } = req.body;
// Fetch the dispensary first
const dispensary = await (0, discovery_1.getDispensaryById)(parseInt(id, 10));
if (!dispensary) {
return res.status(404).json({ error: 'Dispensary not found' });
}
const result = await (0, scheduler_1.crawlSingleDispensary)(dispensary, pricingType, { useBothModes });
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/admin/jobs
* Get crawl job history
*/
router.get('/admin/jobs', async (req, res) => {
try {
const { status, dispensaryId, limit = '50', offset = '0' } = req.query;
let whereClause = 'WHERE 1=1';
const params = [];
let paramIndex = 1;
if (status) {
whereClause += ` AND status = $${paramIndex}`;
params.push(status);
paramIndex++;
}
if (dispensaryId) {
whereClause += ` AND dispensary_id = $${paramIndex}`;
params.push(parseInt(dispensaryId, 10));
paramIndex++;
}
params.push(parseInt(limit, 10), parseInt(offset, 10));
const { rows } = await (0, connection_1.query)(`
SELECT
cj.*,
d.name as dispensary_name,
d.slug as dispensary_slug
FROM dispensary_crawl_jobs cj
LEFT JOIN dispensaries d ON cj.dispensary_id = d.id
${whereClause}
ORDER BY cj.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
const { rows: countRows } = await (0, connection_1.query)(`SELECT COUNT(*) as total FROM dispensary_crawl_jobs ${whereClause}`, params.slice(0, -2));
res.json({
jobs: rows,
total: parseInt(countRows[0]?.total || '0', 10),
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// SCHEDULES (CONFIG CRUD)
// ============================================================
/**
* GET /api/dutchie-az/admin/schedules
* Get all schedule configurations
*/
router.get('/admin/schedules', async (_req, res) => {
try {
const schedules = await (0, scheduler_1.getAllSchedules)();
res.json({ schedules });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/admin/schedules/:id
* Get a single schedule by ID
*/
router.get('/admin/schedules/:id', async (req, res) => {
try {
const { id } = req.params;
const schedule = await (0, scheduler_1.getScheduleById)(parseInt(id, 10));
if (!schedule) {
return res.status(404).json({ error: 'Schedule not found' });
}
res.json(schedule);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/schedules
* Create a new schedule
*/
router.post('/admin/schedules', async (req, res) => {
try {
const { jobName, description, enabled = true, baseIntervalMinutes, jitterMinutes, jobConfig, startImmediately = false, } = req.body;
if (!jobName || typeof baseIntervalMinutes !== 'number' || typeof jitterMinutes !== 'number') {
return res.status(400).json({
error: 'jobName, baseIntervalMinutes, and jitterMinutes are required',
});
}
const schedule = await (0, scheduler_1.createSchedule)({
jobName,
description,
enabled,
baseIntervalMinutes,
jitterMinutes,
jobConfig,
startImmediately,
});
res.status(201).json(schedule);
}
catch (error) {
// Handle unique constraint violation
if (error.code === '23505') {
return res.status(409).json({ error: `Schedule "${req.body.jobName}" already exists` });
}
res.status(500).json({ error: error.message });
}
});
/**
* PUT /api/dutchie-az/admin/schedules/:id
* Update a schedule
*/
router.put('/admin/schedules/:id', async (req, res) => {
try {
const { id } = req.params;
const { description, enabled, baseIntervalMinutes, jitterMinutes, jobConfig } = req.body;
const schedule = await (0, scheduler_1.updateSchedule)(parseInt(id, 10), {
description,
enabled,
baseIntervalMinutes,
jitterMinutes,
jobConfig,
});
if (!schedule) {
return res.status(404).json({ error: 'Schedule not found' });
}
res.json(schedule);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* DELETE /api/dutchie-az/admin/schedules/:id
* Delete a schedule
*/
router.delete('/admin/schedules/:id', async (req, res) => {
try {
const { id } = req.params;
const deleted = await (0, scheduler_1.deleteSchedule)(parseInt(id, 10));
if (!deleted) {
return res.status(404).json({ error: 'Schedule not found' });
}
res.json({ success: true, message: 'Schedule deleted' });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/schedules/:id/trigger
* Trigger immediate execution of a schedule
*/
router.post('/admin/schedules/:id/trigger', async (req, res) => {
try {
const { id } = req.params;
const result = await (0, scheduler_1.triggerScheduleNow)(parseInt(id, 10));
if (!result.success) {
return res.status(400).json({ error: result.message });
}
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/dutchie-az/admin/schedules/init
* Initialize default schedules if they don't exist
*/
router.post('/admin/schedules/init', async (_req, res) => {
try {
await (0, scheduler_1.initializeDefaultSchedules)();
const schedules = await (0, scheduler_1.getAllSchedules)();
res.json({ success: true, schedules });
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/admin/schedules/:id/logs
* Get run logs for a specific schedule
*/
router.get('/admin/schedules/:id/logs', async (req, res) => {
try {
const { id } = req.params;
const { limit = '50', offset = '0' } = req.query;
const result = await (0, scheduler_1.getRunLogs)({
scheduleId: parseInt(id, 10),
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
});
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/admin/run-logs
* Get all run logs with filtering
*/
router.get('/admin/run-logs', async (req, res) => {
try {
const { scheduleId, jobName, limit = '50', offset = '0' } = req.query;
const result = await (0, scheduler_1.getRunLogs)({
scheduleId: scheduleId ? parseInt(scheduleId, 10) : undefined,
jobName: jobName,
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
});
res.json(result);
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// DEBUG ROUTES
// ============================================================
/**
* GET /api/dutchie-az/debug/summary
* Get overall system summary for debugging
*/
router.get('/debug/summary', async (_req, res) => {
try {
// Get table counts
const { rows: tableCounts } = await (0, connection_1.query)(`
SELECT
(SELECT COUNT(*) FROM dispensaries) as dispensary_count,
(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 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
const { rows: stockDistribution } = await (0, connection_1.query)(`
SELECT
stock_status,
COUNT(*) as count
FROM dutchie_products
GROUP BY stock_status
ORDER BY count DESC
`);
// Get products by dispensary
const { rows: productsByDispensary } = await (0, connection_1.query)(`
SELECT
d.id,
d.name,
d.slug,
d.platform_dispensary_id,
COUNT(p.id) as product_count,
MAX(p.updated_at) as last_product_update
FROM dispensaries d
LEFT JOIN dutchie_products p ON d.id = p.dispensary_id
WHERE d.state = 'AZ'
GROUP BY d.id, d.name, d.slug, d.platform_dispensary_id
ORDER BY product_count DESC
LIMIT 20
`);
// Get recent snapshots
const { rows: recentSnapshots } = await (0, connection_1.query)(`
SELECT
s.id,
s.dutchie_product_id,
p.name as product_name,
d.name as dispensary_name,
s.crawled_at
FROM dutchie_product_snapshots s
JOIN dutchie_products p ON s.dutchie_product_id = p.id
JOIN dispensaries d ON p.dispensary_id = d.id
ORDER BY s.crawled_at DESC
LIMIT 10
`);
res.json({
tableCounts: tableCounts[0],
stockDistribution,
productsByDispensary,
recentSnapshots,
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/debug/store/:id
* Get detailed debug info for a specific store
*/
router.get('/debug/store/:id', async (req, res) => {
try {
const { id } = req.params;
// Get dispensary info
const { rows: dispensaryRows } = await (0, connection_1.query)(`SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`, [parseInt(id, 10)]);
if (dispensaryRows.length === 0) {
return res.status(404).json({ error: 'Store not found' });
}
const dispensary = dispensaryRows[0];
// Get product stats
const { rows: productStats } = await (0, connection_1.query)(`
SELECT
COUNT(*) as total_products,
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 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
FROM dutchie_products
WHERE dispensary_id = $1
`, [id]);
// Get snapshot stats
const { rows: snapshotStats } = await (0, connection_1.query)(`
SELECT
COUNT(*) as total_snapshots,
MIN(crawled_at) as earliest_snapshot,
MAX(crawled_at) as latest_snapshot,
COUNT(DISTINCT dutchie_product_id) as products_with_snapshots
FROM dutchie_product_snapshots s
JOIN dutchie_products p ON s.dutchie_product_id = p.id
WHERE p.dispensary_id = $1
`, [id]);
// Get crawl job history
const { rows: recentJobs } = await (0, connection_1.query)(`
SELECT
id,
status,
started_at,
completed_at,
products_found,
products_new,
products_updated,
error_message,
created_at
FROM dispensary_crawl_jobs
WHERE dispensary_id = $1
ORDER BY created_at DESC
LIMIT 10
`, [id]);
// Get sample products (5 in-stock, 5 out-of-stock)
const { rows: sampleInStock } = await (0, connection_1.query)(`
SELECT
p.id,
p.name,
p.brand_name,
p.type,
p.stock_status,
p.updated_at
FROM dutchie_products p
WHERE p.dispensary_id = $1 AND p.stock_status = 'in_stock'
ORDER BY p.updated_at DESC
LIMIT 5
`, [id]);
const { rows: sampleOutOfStock } = await (0, connection_1.query)(`
SELECT
p.id,
p.name,
p.brand_name,
p.type,
p.stock_status,
p.updated_at
FROM dutchie_products p
WHERE p.dispensary_id = $1 AND p.stock_status = 'out_of_stock'
ORDER BY p.updated_at DESC
LIMIT 5
`, [id]);
// Get categories breakdown
const { rows: categories } = await (0, connection_1.query)(`
SELECT
type,
subcategory,
COUNT(*) as count
FROM dutchie_products
WHERE dispensary_id = $1
GROUP BY type, subcategory
ORDER BY count DESC
`, [id]);
res.json({
dispensary,
productStats: productStats[0],
snapshotStats: snapshotStats[0],
recentJobs,
sampleProducts: {
inStock: sampleInStock,
outOfStock: sampleOutOfStock,
},
categories,
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// LIVE CRAWLER STATUS ROUTES
// ============================================================
const job_queue_1 = require("../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, res) => {
try {
// Get running jobs from job_run_logs (scheduled jobs like "enqueue all")
const { rows: runningScheduledJobs } = await (0, connection_1.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,
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 (0, connection_1.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 (0, job_queue_1.getQueueStats)();
// Get active workers
const activeWorkers = await (0, job_queue_1.getActiveWorkers)();
// Also get in-memory scrapers if any (from the legacy system)
let inMemoryScrapers = [];
try {
const { activeScrapers } = await Promise.resolve().then(() => __importStar(require('../../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) {
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, res) => {
try {
const { limit = '50' } = req.query;
const limitNum = Math.min(parseInt(limit, 10), 200);
// Recent job run logs
const { rows: recentJobLogs } = await (0, connection_1.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 (0, connection_1.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) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/monitor/errors
* Get recent job errors
*/
router.get('/monitor/errors', async (req, res) => {
try {
const { limit = '20', hours = '24' } = req.query;
const limitNum = Math.min(parseInt(limit, 10), 100);
const hoursNum = Math.min(parseInt(hours, 10), 168);
// Errors from job_run_logs
const { rows: jobErrors } = await (0, connection_1.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 (0, connection_1.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) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/monitor/summary
* Get overall monitoring summary
*/
router.get('/monitor/summary', async (_req, res) => {
try {
const { rows: stats } = await (0, connection_1.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 (0, connection_1.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) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// MENU DETECTION ROUTES
// ============================================================
const menu_detection_1 = require("../services/menu-detection");
/**
* GET /api/dutchie-az/admin/detection/stats
* Get menu detection statistics
*/
router.get('/admin/detection/stats', async (_req, res) => {
try {
const stats = await (0, menu_detection_1.getDetectionStats)();
res.json(stats);
}
catch (error) {
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, res) => {
try {
const { state = 'AZ', limit = '100' } = req.query;
const dispensaries = await (0, menu_detection_1.getDispensariesNeedingDetection)({
state: state,
limit: parseInt(limit, 10),
});
res.json({ dispensaries, total: dispensaries.length });
}
catch (error) {
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, res) => {
try {
const { id } = req.params;
const result = await (0, menu_detection_1.detectAndResolveDispensary)(parseInt(id, 10));
res.json(result);
}
catch (error) {
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, res) => {
try {
const { state = 'AZ', onlyUnknown = true, onlyMissingPlatformId = false, limit } = req.body;
const result = await (0, menu_detection_1.runBulkDetection)({
state,
onlyUnknown,
onlyMissingPlatformId,
limit,
});
res.json(result);
}
catch (error) {
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, res) => {
try {
// Find the menu detection schedule and trigger it
const schedules = await (0, scheduler_1.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 (0, scheduler_1.triggerScheduleNow)(menuDetection.id);
res.json(result);
}
catch (error) {
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, res) => {
try {
const { rows } = await (0, connection_1.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) {
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, res) => {
try {
const { rows } = await (0, connection_1.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) {
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, res) => {
try {
const { id } = req.params;
await (0, connection_1.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) {
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, res) => {
try {
const { id } = req.params;
await (0, connection_1.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) {
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, res) => {
try {
const { rows } = await (0, connection_1.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) {
res.status(500).json({ error: error.message });
}
});
exports.default = router;