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>
888 lines
34 KiB
JavaScript
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;
|