Files
cannaiq/backend/dist/routes/schedule.js
Kelly 66e07b2009 fix(monitor): remove non-existent worker columns from job_run_logs query
The job_run_logs table tracks scheduled job orchestration, not individual
worker jobs. Worker info (worker_id, worker_hostname) belongs on
dispensary_crawl_jobs, not job_run_logs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 18:45:05 -07:00

888 lines
34 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const middleware_1 = require("../auth/middleware");
const crawl_scheduler_1 = require("../services/crawl-scheduler");
const store_crawl_orchestrator_1 = require("../services/store-crawl-orchestrator");
const dispensary_orchestrator_1 = require("../services/dispensary-orchestrator");
const migrate_1 = require("../db/migrate");
const graphql_client_1 = require("../dutchie-az/services/graphql-client");
const router = (0, express_1.Router)();
router.use(middleware_1.authMiddleware);
// ============================================
// Global Schedule Endpoints
// ============================================
/**
* GET /api/schedule/global
* Get global schedule settings
*/
router.get('/global', async (req, res) => {
try {
const schedules = await (0, crawl_scheduler_1.getGlobalSchedule)();
res.json({ schedules });
}
catch (error) {
console.error('Error fetching global schedule:', error);
res.status(500).json({ error: 'Failed to fetch global schedule' });
}
});
/**
* PUT /api/schedule/global/:type
* Update global schedule setting
*/
router.put('/global/:type', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const { type } = req.params;
const { enabled, interval_hours, run_time } = req.body;
if (type !== 'global_interval' && type !== 'daily_special') {
return res.status(400).json({ error: 'Invalid schedule type' });
}
const schedule = await (0, crawl_scheduler_1.updateGlobalSchedule)(type, {
enabled,
interval_hours,
run_time
});
// Restart scheduler to apply changes
await (0, crawl_scheduler_1.restartCrawlScheduler)();
res.json({ schedule, message: 'Schedule updated and scheduler restarted' });
}
catch (error) {
console.error('Error updating global schedule:', error);
res.status(500).json({ error: 'Failed to update global schedule' });
}
});
// ============================================
// Store Schedule Endpoints
// ============================================
/**
* GET /api/schedule/stores
* Get all store schedule statuses
*/
router.get('/stores', async (req, res) => {
try {
const stores = await (0, crawl_scheduler_1.getStoreScheduleStatuses)();
res.json({ stores });
}
catch (error) {
console.error('Error fetching store schedules:', error);
res.status(500).json({ error: 'Failed to fetch store schedules' });
}
});
/**
* GET /api/schedule/stores/:storeId
* Get schedule for a specific store
*/
router.get('/stores/:storeId', async (req, res) => {
try {
const storeId = parseInt(req.params.storeId);
if (isNaN(storeId)) {
return res.status(400).json({ error: 'Invalid store ID' });
}
const schedule = await (0, crawl_scheduler_1.getStoreSchedule)(storeId);
res.json({ schedule });
}
catch (error) {
console.error('Error fetching store schedule:', error);
res.status(500).json({ error: 'Failed to fetch store schedule' });
}
});
/**
* PUT /api/schedule/stores/:storeId
* Update schedule for a specific store
*/
router.put('/stores/:storeId', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const storeId = parseInt(req.params.storeId);
if (isNaN(storeId)) {
return res.status(400).json({ error: 'Invalid store ID' });
}
const { enabled, interval_hours, daily_special_enabled, daily_special_time, priority } = req.body;
const schedule = await (0, crawl_scheduler_1.updateStoreSchedule)(storeId, {
enabled,
interval_hours,
daily_special_enabled,
daily_special_time,
priority
});
res.json({ schedule });
}
catch (error) {
console.error('Error updating store schedule:', error);
res.status(500).json({ error: 'Failed to update store schedule' });
}
});
// ============================================
// Job Queue Endpoints
// ============================================
/**
* GET /api/schedule/jobs
* Get recent jobs
*/
router.get('/jobs', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50;
const jobs = await (0, crawl_scheduler_1.getAllRecentJobs)(Math.min(limit, 200));
res.json({ jobs });
}
catch (error) {
console.error('Error fetching jobs:', error);
res.status(500).json({ error: 'Failed to fetch jobs' });
}
});
/**
* GET /api/schedule/jobs/store/:storeId
* Get recent jobs for a specific store
*/
router.get('/jobs/store/:storeId', async (req, res) => {
try {
const storeId = parseInt(req.params.storeId);
if (isNaN(storeId)) {
return res.status(400).json({ error: 'Invalid store ID' });
}
const limit = parseInt(req.query.limit) || 10;
const jobs = await (0, crawl_scheduler_1.getRecentJobs)(storeId, Math.min(limit, 100));
res.json({ jobs });
}
catch (error) {
console.error('Error fetching store jobs:', error);
res.status(500).json({ error: 'Failed to fetch store jobs' });
}
});
/**
* POST /api/schedule/jobs/:jobId/cancel
* Cancel a pending job
*/
router.post('/jobs/:jobId/cancel', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const jobId = parseInt(req.params.jobId);
if (isNaN(jobId)) {
return res.status(400).json({ error: 'Invalid job ID' });
}
const cancelled = await (0, crawl_scheduler_1.cancelJob)(jobId);
if (cancelled) {
res.json({ success: true, message: 'Job cancelled' });
}
else {
res.status(400).json({ error: 'Job could not be cancelled (may not be pending)' });
}
}
catch (error) {
console.error('Error cancelling job:', error);
res.status(500).json({ error: 'Failed to cancel job' });
}
});
// ============================================
// Manual Trigger Endpoints
// ============================================
/**
* POST /api/schedule/trigger/store/:storeId
* Manually trigger orchestrated crawl for a specific store
* Uses the intelligent orchestrator which:
* - Checks provider detection status
* - Runs detection if needed
* - Queues appropriate crawl type (production/sandbox)
*/
router.post('/trigger/store/:storeId', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const storeId = parseInt(req.params.storeId);
if (isNaN(storeId)) {
return res.status(400).json({ error: 'Invalid store ID' });
}
// Use the orchestrator instead of simple triggerManualCrawl
const result = await (0, store_crawl_orchestrator_1.runStoreCrawlOrchestrator)(storeId);
res.json({
result,
message: result.summary,
success: result.status === 'success' || result.status === 'sandbox_only',
});
}
catch (error) {
console.error('Error triggering orchestrated crawl:', error);
res.status(500).json({ error: 'Failed to trigger crawl' });
}
});
/**
* POST /api/schedule/trigger/store/:storeId/legacy
* Legacy: Simple job queue trigger (no orchestration)
*/
router.post('/trigger/store/:storeId/legacy', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const storeId = parseInt(req.params.storeId);
if (isNaN(storeId)) {
return res.status(400).json({ error: 'Invalid store ID' });
}
const job = await (0, crawl_scheduler_1.triggerManualCrawl)(storeId);
res.json({ job, message: 'Crawl job created' });
}
catch (error) {
console.error('Error triggering manual crawl:', error);
res.status(500).json({ error: 'Failed to trigger crawl' });
}
});
/**
* POST /api/schedule/trigger/all
* Manually trigger crawls for all stores
*/
router.post('/trigger/all', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const jobsCreated = await (0, crawl_scheduler_1.triggerAllStoresCrawl)();
res.json({ jobs_created: jobsCreated, message: `Created ${jobsCreated} crawl jobs` });
}
catch (error) {
console.error('Error triggering all crawls:', error);
res.status(500).json({ error: 'Failed to trigger crawls' });
}
});
/**
* POST /api/schedule/restart
* Restart the scheduler
*/
router.post('/restart', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
await (0, crawl_scheduler_1.restartCrawlScheduler)();
res.json({ message: 'Scheduler restarted', mode: (0, crawl_scheduler_1.getSchedulerMode)() });
}
catch (error) {
console.error('Error restarting scheduler:', error);
res.status(500).json({ error: 'Failed to restart scheduler' });
}
});
// ============================================
// Scheduler Mode Endpoints
// ============================================
/**
* GET /api/schedule/mode
* Get current scheduler mode
*/
router.get('/mode', async (req, res) => {
try {
const mode = (0, crawl_scheduler_1.getSchedulerMode)();
res.json({ mode });
}
catch (error) {
console.error('Error getting scheduler mode:', error);
res.status(500).json({ error: 'Failed to get scheduler mode' });
}
});
/**
* PUT /api/schedule/mode
* Set scheduler mode (legacy or orchestrator)
*/
router.put('/mode', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const { mode } = req.body;
if (mode !== 'legacy' && mode !== 'orchestrator') {
return res.status(400).json({ error: 'Invalid mode. Must be "legacy" or "orchestrator"' });
}
(0, crawl_scheduler_1.setSchedulerMode)(mode);
// Restart scheduler with new mode
await (0, crawl_scheduler_1.restartCrawlScheduler)();
res.json({ mode, message: `Scheduler mode set to ${mode} and restarted` });
}
catch (error) {
console.error('Error setting scheduler mode:', error);
res.status(500).json({ error: 'Failed to set scheduler mode' });
}
});
/**
* GET /api/schedule/due
* Get stores that are due for orchestration
*/
router.get('/due', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const storeIds = await (0, store_crawl_orchestrator_1.getStoresDueForOrchestration)(Math.min(limit, 50));
res.json({ stores_due: storeIds, count: storeIds.length });
}
catch (error) {
console.error('Error getting stores due for orchestration:', error);
res.status(500).json({ error: 'Failed to get stores due' });
}
});
// ============================================
// Dispensary Schedule Endpoints (NEW - dispensary-centric)
// ============================================
/**
* GET /api/schedule/dispensaries
* Get all dispensary schedule statuses with optional filters
* Query params:
* - state: filter by state (e.g., 'AZ')
* - search: search by name or slug
*/
router.get('/dispensaries', async (req, res) => {
try {
const { state, search } = req.query;
// Build dynamic query with optional filters
const conditions = [];
const params = [];
let paramIndex = 1;
if (state) {
conditions.push(`d.state = $${paramIndex}`);
params.push(state);
paramIndex++;
}
if (search) {
conditions.push(`(d.name ILIKE $${paramIndex} OR d.slug ILIKE $${paramIndex} OR d.dba_name ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `
SELECT
d.id AS dispensary_id,
COALESCE(d.dba_name, d.name) AS dispensary_name,
d.slug AS dispensary_slug,
d.city,
d.state,
d.menu_url,
d.menu_type,
d.platform_dispensary_id,
d.scrape_enabled,
d.last_crawl_at,
d.crawl_status,
d.product_crawler_mode,
d.product_provider,
cs.interval_minutes,
cs.is_active,
cs.priority,
cs.last_run_at,
cs.next_run_at,
cs.last_status AS schedule_last_status,
cs.last_error AS schedule_last_error,
cs.consecutive_failures,
j.id AS latest_job_id,
j.status AS latest_job_status,
j.job_type AS latest_job_type,
j.started_at AS latest_job_started,
j.completed_at AS latest_job_completed,
j.products_found AS latest_products_found,
j.products_new AS latest_products_created,
j.products_updated AS latest_products_updated,
j.error_message AS latest_job_error,
CASE
WHEN d.menu_type = 'dutchie' AND d.platform_dispensary_id IS NOT NULL THEN true
ELSE false
END AS can_crawl,
CASE
WHEN d.menu_type IS NULL OR d.menu_type = 'unknown' THEN 'menu_type not detected'
WHEN d.menu_type != 'dutchie' THEN 'not dutchie platform'
WHEN d.platform_dispensary_id IS NULL THEN 'platform ID not resolved'
WHEN d.scrape_enabled = false THEN 'scraping disabled'
ELSE 'ready'
END AS schedule_status_reason
FROM public.dispensaries d
LEFT JOIN public.dispensary_crawl_schedule cs ON cs.dispensary_id = d.id
LEFT JOIN LATERAL (
SELECT *
FROM public.dispensary_crawl_jobs dj
WHERE dj.dispensary_id = d.id
ORDER BY dj.created_at DESC
LIMIT 1
) j ON true
${whereClause}
ORDER BY cs.priority DESC NULLS LAST, COALESCE(d.dba_name, d.name)
`;
const result = await migrate_1.pool.query(query, params);
res.json({ dispensaries: result.rows });
}
catch (error) {
console.error('Error fetching dispensary schedules:', error);
res.status(500).json({ error: 'Failed to fetch dispensary schedules' });
}
});
/**
* GET /api/schedule/dispensaries/:id
* Get schedule for a specific dispensary
*/
router.get('/dispensaries/:id', async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const result = await migrate_1.pool.query(`
SELECT * FROM dispensary_crawl_status
WHERE dispensary_id = $1
`, [dispensaryId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Dispensary not found' });
}
res.json({ schedule: result.rows[0] });
}
catch (error) {
console.error('Error fetching dispensary schedule:', error);
res.status(500).json({ error: 'Failed to fetch dispensary schedule' });
}
});
/**
* PUT /api/schedule/dispensaries/:id
* Update schedule for a specific dispensary
*/
router.put('/dispensaries/:id', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const { is_active, interval_minutes, priority } = req.body;
// Upsert schedule
const result = await migrate_1.pool.query(`
INSERT INTO dispensary_crawl_schedule (dispensary_id, is_active, interval_minutes, priority)
VALUES ($1, COALESCE($2, TRUE), COALESCE($3, 240), COALESCE($4, 0))
ON CONFLICT (dispensary_id) DO UPDATE SET
is_active = COALESCE($2, dispensary_crawl_schedule.is_active),
interval_minutes = COALESCE($3, dispensary_crawl_schedule.interval_minutes),
priority = COALESCE($4, dispensary_crawl_schedule.priority),
updated_at = NOW()
RETURNING *
`, [dispensaryId, is_active, interval_minutes, priority]);
res.json({ schedule: result.rows[0] });
}
catch (error) {
console.error('Error updating dispensary schedule:', error);
res.status(500).json({ error: 'Failed to update dispensary schedule' });
}
});
/**
* GET /api/schedule/dispensary-jobs
* Get recent dispensary crawl jobs
*/
router.get('/dispensary-jobs', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50;
const result = await migrate_1.pool.query(`
SELECT dcj.*, d.name as dispensary_name
FROM dispensary_crawl_jobs dcj
JOIN dispensaries d ON d.id = dcj.dispensary_id
ORDER BY dcj.created_at DESC
LIMIT $1
`, [Math.min(limit, 200)]);
res.json({ jobs: result.rows });
}
catch (error) {
console.error('Error fetching dispensary jobs:', error);
res.status(500).json({ error: 'Failed to fetch dispensary jobs' });
}
});
/**
* GET /api/schedule/dispensary-jobs/:dispensaryId
* Get recent jobs for a specific dispensary
*/
router.get('/dispensary-jobs/:dispensaryId', async (req, res) => {
try {
const dispensaryId = parseInt(req.params.dispensaryId);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const limit = parseInt(req.query.limit) || 10;
const result = await migrate_1.pool.query(`
SELECT dcj.*, d.name as dispensary_name
FROM dispensary_crawl_jobs dcj
JOIN dispensaries d ON d.id = dcj.dispensary_id
WHERE dcj.dispensary_id = $1
ORDER BY dcj.created_at DESC
LIMIT $2
`, [dispensaryId, Math.min(limit, 100)]);
res.json({ jobs: result.rows });
}
catch (error) {
console.error('Error fetching dispensary jobs:', error);
res.status(500).json({ error: 'Failed to fetch dispensary jobs' });
}
});
/**
* POST /api/schedule/trigger/dispensary/:id
* Trigger orchestrator for a specific dispensary (Run Now button)
*/
router.post('/trigger/dispensary/:id', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
// Run the dispensary orchestrator
const result = await (0, dispensary_orchestrator_1.runDispensaryOrchestrator)(dispensaryId);
res.json({
result,
message: result.summary,
success: result.status === 'success' || result.status === 'sandbox_only' || result.status === 'detection_only',
});
}
catch (error) {
console.error('Error triggering dispensary orchestrator:', error);
res.status(500).json({ error: 'Failed to trigger orchestrator' });
}
});
/**
* POST /api/schedule/trigger/dispensaries/batch
* Trigger orchestrator for multiple dispensaries
*/
router.post('/trigger/dispensaries/batch', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const { dispensary_ids, concurrency } = req.body;
if (!Array.isArray(dispensary_ids) || dispensary_ids.length === 0) {
return res.status(400).json({ error: 'dispensary_ids must be a non-empty array' });
}
const results = await (0, dispensary_orchestrator_1.runBatchDispensaryOrchestrator)(dispensary_ids, concurrency || 3);
const summary = {
total: results.length,
success: results.filter(r => r.status === 'success').length,
sandbox_only: results.filter(r => r.status === 'sandbox_only').length,
detection_only: results.filter(r => r.status === 'detection_only').length,
error: results.filter(r => r.status === 'error').length,
};
res.json({ results, summary });
}
catch (error) {
console.error('Error triggering batch orchestrator:', error);
res.status(500).json({ error: 'Failed to trigger batch orchestrator' });
}
});
/**
* GET /api/schedule/dispensary-due
* Get dispensaries that are due for orchestration
*/
router.get('/dispensary-due', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const dispensaryIds = await (0, dispensary_orchestrator_1.getDispensariesDueForOrchestration)(Math.min(limit, 50));
// Get details for the due dispensaries
if (dispensaryIds.length > 0) {
const details = await migrate_1.pool.query(`
SELECT d.id, d.name, d.product_provider, d.product_crawler_mode,
dcs.next_run_at, dcs.last_status, dcs.priority
FROM dispensaries d
LEFT JOIN dispensary_crawl_schedule dcs ON dcs.dispensary_id = d.id
WHERE d.id = ANY($1)
ORDER BY COALESCE(dcs.priority, 0) DESC, dcs.last_run_at ASC NULLS FIRST
`, [dispensaryIds]);
res.json({ dispensaries_due: details.rows, count: dispensaryIds.length });
}
else {
res.json({ dispensaries_due: [], count: 0 });
}
}
catch (error) {
console.error('Error getting dispensaries due for orchestration:', error);
res.status(500).json({ error: 'Failed to get dispensaries due' });
}
});
/**
* POST /api/schedule/dispensaries/bootstrap
* Ensure all dispensaries have schedule entries
*/
router.post('/dispensaries/bootstrap', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const { interval_minutes } = req.body;
const result = await (0, dispensary_orchestrator_1.ensureAllDispensariesHaveSchedules)(interval_minutes || 240);
res.json({
message: `Created ${result.created} new schedules, ${result.existing} already existed`,
created: result.created,
existing: result.existing,
});
}
catch (error) {
console.error('Error bootstrapping dispensary schedules:', error);
res.status(500).json({ error: 'Failed to bootstrap schedules' });
}
});
// ============================================
// Platform ID & Menu Type Detection Endpoints
// ============================================
/**
* POST /api/schedule/dispensaries/:id/resolve-platform-id
* Resolve the Dutchie platform_dispensary_id from menu_url slug
*/
router.post('/dispensaries/:id/resolve-platform-id', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
// Get dispensary info
const dispensaryResult = await migrate_1.pool.query(`
SELECT id, name, slug, menu_url, menu_type, platform_dispensary_id
FROM dispensaries WHERE id = $1
`, [dispensaryId]);
if (dispensaryResult.rows.length === 0) {
return res.status(404).json({ error: 'Dispensary not found' });
}
const dispensary = dispensaryResult.rows[0];
// Check if already resolved
if (dispensary.platform_dispensary_id) {
return res.json({
success: true,
message: 'Platform ID already resolved',
platform_dispensary_id: dispensary.platform_dispensary_id,
already_resolved: true
});
}
// Extract slug from menu_url for Dutchie URLs
let slugToResolve = dispensary.slug;
if (dispensary.menu_url) {
// Match embedded-menu or dispensary URLs
const match = dispensary.menu_url.match(/(?:embedded-menu|dispensar(?:y|ies))\/([^\/\?#]+)/i);
if (match) {
slugToResolve = match[1];
}
}
if (!slugToResolve) {
return res.status(400).json({
error: 'No slug available to resolve platform ID',
menu_url: dispensary.menu_url
});
}
console.log(`[Schedule] Resolving platform ID for ${dispensary.name} using slug: ${slugToResolve}`);
// Resolve platform ID using GraphQL client
const platformId = await (0, graphql_client_1.resolveDispensaryId)(slugToResolve);
if (!platformId) {
return res.status(404).json({
error: 'Could not resolve platform ID',
slug_tried: slugToResolve,
message: 'The dispensary might not be on Dutchie or the slug is incorrect'
});
}
// Update the dispensary with resolved platform ID
await migrate_1.pool.query(`
UPDATE dispensaries
SET platform_dispensary_id = $1,
menu_type = COALESCE(menu_type, 'dutchie'),
updated_at = NOW()
WHERE id = $2
`, [platformId, dispensaryId]);
res.json({
success: true,
platform_dispensary_id: platformId,
slug_resolved: slugToResolve,
message: `Platform ID resolved: ${platformId}`
});
}
catch (error) {
console.error('Error resolving platform ID:', error);
res.status(500).json({ error: 'Failed to resolve platform ID', details: error.message });
}
});
/**
* POST /api/schedule/dispensaries/:id/detect-menu-type
* Detect menu type from menu_url
*/
router.post('/dispensaries/:id/detect-menu-type', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
// Get dispensary info
const dispensaryResult = await migrate_1.pool.query(`
SELECT id, name, menu_url, website FROM dispensaries WHERE id = $1
`, [dispensaryId]);
if (dispensaryResult.rows.length === 0) {
return res.status(404).json({ error: 'Dispensary not found' });
}
const dispensary = dispensaryResult.rows[0];
const urlToCheck = dispensary.menu_url || dispensary.website;
if (!urlToCheck) {
return res.status(400).json({ error: 'No menu_url or website to detect from' });
}
// Detect menu type from URL patterns
let detectedType = 'unknown';
if (urlToCheck.includes('dutchie.com') || urlToCheck.includes('embedded-menu')) {
detectedType = 'dutchie';
}
else if (urlToCheck.includes('iheartjane.com') || urlToCheck.includes('jane.co')) {
detectedType = 'jane';
}
else if (urlToCheck.includes('weedmaps.com')) {
detectedType = 'weedmaps';
}
else if (urlToCheck.includes('leafly.com')) {
detectedType = 'leafly';
}
else if (urlToCheck.includes('treez.io') || urlToCheck.includes('treez.co')) {
detectedType = 'treez';
}
else if (urlToCheck.includes('meadow.com')) {
detectedType = 'meadow';
}
else if (urlToCheck.includes('blaze.me') || urlToCheck.includes('blazepay')) {
detectedType = 'blaze';
}
else if (urlToCheck.includes('flowhub.com')) {
detectedType = 'flowhub';
}
else if (urlToCheck.includes('dispense.app')) {
detectedType = 'dispense';
}
else if (urlToCheck.includes('covasoft.com')) {
detectedType = 'cova';
}
// Update menu_type
await migrate_1.pool.query(`
UPDATE dispensaries
SET menu_type = $1, updated_at = NOW()
WHERE id = $2
`, [detectedType, dispensaryId]);
res.json({
success: true,
menu_type: detectedType,
url_checked: urlToCheck,
message: `Menu type detected: ${detectedType}`
});
}
catch (error) {
console.error('Error detecting menu type:', error);
res.status(500).json({ error: 'Failed to detect menu type' });
}
});
/**
* POST /api/schedule/dispensaries/:id/refresh-detection
* Combined: detect menu_type AND resolve platform_dispensary_id if dutchie
*/
router.post('/dispensaries/:id/refresh-detection', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
// Get dispensary info
const dispensaryResult = await migrate_1.pool.query(`
SELECT id, name, slug, menu_url, website FROM dispensaries WHERE id = $1
`, [dispensaryId]);
if (dispensaryResult.rows.length === 0) {
return res.status(404).json({ error: 'Dispensary not found' });
}
const dispensary = dispensaryResult.rows[0];
const urlToCheck = dispensary.menu_url || dispensary.website;
if (!urlToCheck) {
return res.status(400).json({ error: 'No menu_url or website to detect from' });
}
// Detect menu type from URL patterns
let detectedType = 'unknown';
if (urlToCheck.includes('dutchie.com') || urlToCheck.includes('embedded-menu')) {
detectedType = 'dutchie';
}
else if (urlToCheck.includes('iheartjane.com') || urlToCheck.includes('jane.co')) {
detectedType = 'jane';
}
else if (urlToCheck.includes('weedmaps.com')) {
detectedType = 'weedmaps';
}
else if (urlToCheck.includes('leafly.com')) {
detectedType = 'leafly';
}
else if (urlToCheck.includes('treez.io') || urlToCheck.includes('treez.co')) {
detectedType = 'treez';
}
else if (urlToCheck.includes('meadow.com')) {
detectedType = 'meadow';
}
else if (urlToCheck.includes('blaze.me') || urlToCheck.includes('blazepay')) {
detectedType = 'blaze';
}
else if (urlToCheck.includes('flowhub.com')) {
detectedType = 'flowhub';
}
else if (urlToCheck.includes('dispense.app')) {
detectedType = 'dispense';
}
else if (urlToCheck.includes('covasoft.com')) {
detectedType = 'cova';
}
// Update menu_type first
await migrate_1.pool.query(`
UPDATE dispensaries SET menu_type = $1, updated_at = NOW() WHERE id = $2
`, [detectedType, dispensaryId]);
let platformId = null;
// If dutchie, also try to resolve platform ID
if (detectedType === 'dutchie') {
let slugToResolve = dispensary.slug;
const match = urlToCheck.match(/(?:embedded-menu|dispensar(?:y|ies))\/([^\/\?#]+)/i);
if (match) {
slugToResolve = match[1];
}
if (slugToResolve) {
try {
console.log(`[Schedule] Resolving platform ID for ${dispensary.name} using slug: ${slugToResolve}`);
platformId = await (0, graphql_client_1.resolveDispensaryId)(slugToResolve);
if (platformId) {
await migrate_1.pool.query(`
UPDATE dispensaries SET platform_dispensary_id = $1, updated_at = NOW() WHERE id = $2
`, [platformId, dispensaryId]);
}
}
catch (err) {
console.warn(`[Schedule] Failed to resolve platform ID: ${err.message}`);
}
}
}
res.json({
success: true,
menu_type: detectedType,
platform_dispensary_id: platformId,
url_checked: urlToCheck,
can_crawl: detectedType === 'dutchie' && !!platformId
});
}
catch (error) {
console.error('Error refreshing detection:', error);
res.status(500).json({ error: 'Failed to refresh detection' });
}
});
/**
* PUT /api/schedule/dispensaries/:id/toggle-active
* Enable or disable schedule for a dispensary
*/
router.put('/dispensaries/:id/toggle-active', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const { is_active } = req.body;
// Upsert schedule with new is_active value
const result = await migrate_1.pool.query(`
INSERT INTO dispensary_crawl_schedule (dispensary_id, is_active, interval_minutes, priority)
VALUES ($1, $2, 240, 0)
ON CONFLICT (dispensary_id) DO UPDATE SET
is_active = $2,
updated_at = NOW()
RETURNING *
`, [dispensaryId, is_active]);
res.json({
success: true,
schedule: result.rows[0],
message: is_active ? 'Schedule enabled' : 'Schedule disabled'
});
}
catch (error) {
console.error('Error toggling schedule active status:', error);
res.status(500).json({ error: 'Failed to toggle schedule' });
}
});
/**
* DELETE /api/schedule/dispensaries/:id/schedule
* Delete schedule for a dispensary
*/
router.delete('/dispensaries/:id/schedule', (0, middleware_1.requireRole)('superadmin', 'admin'), async (req, res) => {
try {
const dispensaryId = parseInt(req.params.id);
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const result = await migrate_1.pool.query(`
DELETE FROM dispensary_crawl_schedule WHERE dispensary_id = $1 RETURNING id
`, [dispensaryId]);
const deleted = (result.rowCount ?? 0) > 0;
res.json({
success: true,
deleted,
message: deleted ? 'Schedule deleted' : 'No schedule to delete'
});
}
catch (error) {
console.error('Error deleting schedule:', error);
res.status(500).json({ error: 'Failed to delete schedule' });
}
});
exports.default = router;