Fix SQL queries to use correct column names in store summary endpoints
- Changed missing_from_feed=true to stock_status='missing_from_feed' - Changed products_inserted/snapshots_created to products_new/products_updated - Changed crawl_jobs table reference to dispensary_crawl_jobs - Fixed product query to use actual snapshot columns (price in cents, etc.) - Added explicit column list for dispensaries to avoid SELECT * issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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<any>(`
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user