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>
438 lines
14 KiB
JavaScript
438 lines
14 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const express_1 = require("express");
|
|
const middleware_1 = require("../auth/middleware");
|
|
const migrate_1 = require("../db/migrate");
|
|
const router = (0, express_1.Router)();
|
|
router.use(middleware_1.authMiddleware);
|
|
// Valid menu_type values
|
|
const VALID_MENU_TYPES = ['dutchie', 'treez', 'jane', 'weedmaps', 'leafly', 'meadow', 'blaze', 'flowhub', 'dispense', 'cova', 'other', 'unknown'];
|
|
// Get all dispensaries
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const { menu_type } = req.query;
|
|
let query = `
|
|
SELECT
|
|
id,
|
|
azdhs_id,
|
|
name,
|
|
company_name,
|
|
slug,
|
|
address,
|
|
city,
|
|
state,
|
|
zip,
|
|
phone,
|
|
email,
|
|
website,
|
|
dba_name,
|
|
google_rating,
|
|
google_review_count,
|
|
status_line,
|
|
azdhs_url,
|
|
latitude,
|
|
longitude,
|
|
menu_url,
|
|
menu_type,
|
|
menu_provider,
|
|
menu_provider_confidence,
|
|
scraper_template,
|
|
last_menu_scrape,
|
|
menu_scrape_status,
|
|
platform_dispensary_id,
|
|
created_at,
|
|
updated_at
|
|
FROM dispensaries
|
|
`;
|
|
const params = [];
|
|
// Filter by menu_type if provided
|
|
if (menu_type) {
|
|
query += ` WHERE menu_type = $1`;
|
|
params.push(menu_type);
|
|
}
|
|
query += ` ORDER BY name`;
|
|
const result = await migrate_1.pool.query(query, params);
|
|
res.json({ dispensaries: result.rows });
|
|
}
|
|
catch (error) {
|
|
console.error('Error fetching dispensaries:', error);
|
|
res.status(500).json({ error: 'Failed to fetch dispensaries' });
|
|
}
|
|
});
|
|
// Get menu type stats
|
|
router.get('/stats/menu-types', async (req, res) => {
|
|
try {
|
|
const result = await migrate_1.pool.query(`
|
|
SELECT menu_type, COUNT(*) as count
|
|
FROM dispensaries
|
|
GROUP BY menu_type
|
|
ORDER BY count DESC
|
|
`);
|
|
res.json({ menu_types: result.rows, valid_types: VALID_MENU_TYPES });
|
|
}
|
|
catch (error) {
|
|
console.error('Error fetching menu type stats:', error);
|
|
res.status(500).json({ error: 'Failed to fetch menu type stats' });
|
|
}
|
|
});
|
|
// Get single dispensary by slug
|
|
router.get('/:slug', async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const result = await migrate_1.pool.query(`
|
|
SELECT
|
|
id,
|
|
azdhs_id,
|
|
name,
|
|
company_name,
|
|
slug,
|
|
address,
|
|
city,
|
|
state,
|
|
zip,
|
|
phone,
|
|
email,
|
|
website,
|
|
dba_name,
|
|
google_rating,
|
|
google_review_count,
|
|
status_line,
|
|
azdhs_url,
|
|
latitude,
|
|
longitude,
|
|
menu_url,
|
|
menu_type,
|
|
menu_provider,
|
|
menu_provider_confidence,
|
|
scraper_template,
|
|
scraper_config,
|
|
last_menu_scrape,
|
|
menu_scrape_status,
|
|
platform_dispensary_id,
|
|
created_at,
|
|
updated_at
|
|
FROM dispensaries
|
|
WHERE slug = $1
|
|
`, [slug]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
}
|
|
catch (error) {
|
|
console.error('Error fetching dispensary:', error);
|
|
res.status(500).json({ error: 'Failed to fetch dispensary' });
|
|
}
|
|
});
|
|
// Update dispensary
|
|
router.put('/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { dba_name, website, phone, email, google_rating, google_review_count, menu_url, menu_type, scraper_template, scraper_config, menu_scrape_status } = req.body;
|
|
// Validate menu_type if provided
|
|
if (menu_type !== undefined && menu_type !== null && menu_type !== '' && !VALID_MENU_TYPES.includes(menu_type)) {
|
|
return res.status(400).json({
|
|
error: `Invalid menu_type. Must be one of: ${VALID_MENU_TYPES.join(', ')}`
|
|
});
|
|
}
|
|
const result = await migrate_1.pool.query(`
|
|
UPDATE dispensaries
|
|
SET
|
|
dba_name = COALESCE($1, dba_name),
|
|
website = COALESCE($2, website),
|
|
phone = COALESCE($3, phone),
|
|
email = COALESCE($4, email),
|
|
google_rating = COALESCE($5, google_rating),
|
|
google_review_count = COALESCE($6, google_review_count),
|
|
menu_url = COALESCE($7, menu_url),
|
|
menu_type = COALESCE($8, menu_type),
|
|
scraper_template = COALESCE($9, scraper_template),
|
|
scraper_config = COALESCE($10, scraper_config),
|
|
menu_scrape_status = COALESCE($11, menu_scrape_status),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $12
|
|
RETURNING *
|
|
`, [
|
|
dba_name,
|
|
website,
|
|
phone,
|
|
email,
|
|
google_rating,
|
|
google_review_count,
|
|
menu_url,
|
|
menu_type,
|
|
scraper_template,
|
|
scraper_config,
|
|
menu_scrape_status,
|
|
id
|
|
]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
}
|
|
catch (error) {
|
|
console.error('Error updating dispensary:', error);
|
|
res.status(500).json({ error: 'Failed to update dispensary' });
|
|
}
|
|
});
|
|
// Get products for a dispensary by slug
|
|
router.get('/:slug/products', async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const { category } = req.query;
|
|
// First get the dispensary ID from slug
|
|
const dispensaryResult = await migrate_1.pool.query(`
|
|
SELECT id FROM dispensaries WHERE slug = $1
|
|
`, [slug]);
|
|
if (dispensaryResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
const dispensaryId = dispensaryResult.rows[0].id;
|
|
// Build query for products
|
|
let query = `
|
|
SELECT
|
|
p.id,
|
|
p.name,
|
|
p.brand,
|
|
p.variant,
|
|
p.slug,
|
|
p.description,
|
|
p.regular_price,
|
|
p.sale_price,
|
|
p.thc_percentage,
|
|
p.cbd_percentage,
|
|
p.strain_type,
|
|
p.terpenes,
|
|
p.effects,
|
|
p.flavors,
|
|
p.image_url,
|
|
p.dutchie_url,
|
|
p.in_stock,
|
|
p.created_at,
|
|
p.updated_at
|
|
FROM products p
|
|
WHERE p.dispensary_id = $1
|
|
`;
|
|
const params = [dispensaryId];
|
|
if (category) {
|
|
query += ` AND p.category = $2`;
|
|
params.push(category);
|
|
}
|
|
query += ` ORDER BY p.created_at DESC`;
|
|
const result = await migrate_1.pool.query(query, params);
|
|
res.json({ products: result.rows });
|
|
}
|
|
catch (error) {
|
|
console.error('Error fetching dispensary products:', error);
|
|
res.status(500).json({ error: 'Failed to fetch products' });
|
|
}
|
|
});
|
|
// Get unique brands for a dispensary by slug
|
|
router.get('/:slug/brands', async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const { search } = req.query;
|
|
// First get the dispensary ID from slug
|
|
const dispensaryResult = await migrate_1.pool.query(`
|
|
SELECT id FROM dispensaries WHERE slug = $1
|
|
`, [slug]);
|
|
if (dispensaryResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
const dispensaryId = dispensaryResult.rows[0].id;
|
|
// Build query with optional search filter
|
|
let query = `
|
|
SELECT DISTINCT
|
|
brand,
|
|
COUNT(*) as product_count
|
|
FROM products
|
|
WHERE dispensary_id = $1 AND brand IS NOT NULL
|
|
`;
|
|
const params = [dispensaryId];
|
|
// Add search filter if provided
|
|
if (search) {
|
|
query += ` AND brand ILIKE $2`;
|
|
params.push(`%${search}%`);
|
|
}
|
|
query += ` GROUP BY brand ORDER BY product_count DESC, brand ASC`;
|
|
const result = await migrate_1.pool.query(query, params);
|
|
res.json({ brands: result.rows });
|
|
}
|
|
catch (error) {
|
|
console.error('Error fetching dispensary brands:', error);
|
|
res.status(500).json({ error: 'Failed to fetch brands' });
|
|
}
|
|
});
|
|
// Get products with discounts/specials for a dispensary by slug
|
|
router.get('/:slug/specials', async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const { search } = req.query;
|
|
// First get the dispensary ID from slug
|
|
const dispensaryResult = await migrate_1.pool.query(`
|
|
SELECT id FROM dispensaries WHERE slug = $1
|
|
`, [slug]);
|
|
if (dispensaryResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
const dispensaryId = dispensaryResult.rows[0].id;
|
|
// Build query to get products with discounts
|
|
let query = `
|
|
SELECT
|
|
p.id,
|
|
p.name,
|
|
p.brand,
|
|
p.variant,
|
|
p.slug,
|
|
p.description,
|
|
p.regular_price,
|
|
p.sale_price,
|
|
p.discount_type,
|
|
p.discount_value,
|
|
p.thc_percentage,
|
|
p.cbd_percentage,
|
|
p.strain_type,
|
|
p.terpenes,
|
|
p.effects,
|
|
p.flavors,
|
|
p.image_url,
|
|
p.dutchie_url,
|
|
p.in_stock,
|
|
p.created_at,
|
|
p.updated_at
|
|
FROM products p
|
|
WHERE p.dispensary_id = $1
|
|
AND p.discount_type IS NOT NULL
|
|
AND p.discount_value IS NOT NULL
|
|
`;
|
|
const params = [dispensaryId];
|
|
// Add search filter if provided
|
|
if (search) {
|
|
query += ` AND (p.name ILIKE $2 OR p.brand ILIKE $2 OR p.description ILIKE $2)`;
|
|
params.push(`%${search}%`);
|
|
}
|
|
query += ` ORDER BY p.created_at DESC`;
|
|
const result = await migrate_1.pool.query(query, params);
|
|
res.json({ specials: result.rows });
|
|
}
|
|
catch (error) {
|
|
console.error('Error fetching dispensary specials:', error);
|
|
res.status(500).json({ error: 'Failed to fetch specials' });
|
|
}
|
|
});
|
|
// Trigger scraping for a dispensary
|
|
router.post('/:slug/scrape', async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const { type } = req.body; // 'products' | 'brands' | 'specials' | 'all'
|
|
if (!['products', 'brands', 'specials', 'all'].includes(type)) {
|
|
return res.status(400).json({ error: 'Invalid type. Must be: products, brands, specials, or all' });
|
|
}
|
|
// Get the dispensary
|
|
const dispensaryResult = await migrate_1.pool.query(`
|
|
SELECT id, name, slug, website, menu_url, scraper_template, scraper_config
|
|
FROM dispensaries
|
|
WHERE slug = $1
|
|
`, [slug]);
|
|
if (dispensaryResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
const dispensary = dispensaryResult.rows[0];
|
|
if (!dispensary.menu_url && !dispensary.website) {
|
|
return res.status(400).json({ error: 'Dispensary has no menu URL or website configured' });
|
|
}
|
|
// Update last_menu_scrape time and status
|
|
await migrate_1.pool.query(`
|
|
UPDATE dispensaries
|
|
SET
|
|
last_menu_scrape = CURRENT_TIMESTAMP,
|
|
menu_scrape_status = 'pending',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
`, [dispensary.id]);
|
|
// Log the scrape request
|
|
console.log(`[SCRAPE REQUEST] Dispensary: ${dispensary.name} (${slug}), Type: ${type}`);
|
|
console.log(` Menu URL: ${dispensary.menu_url || dispensary.website}`);
|
|
console.log(` Template: ${dispensary.scraper_template || 'N/A'}`);
|
|
// TODO: Actually trigger the scraper here
|
|
// For now, this is a placeholder that updates the status
|
|
// You can integrate with your existing scraper infrastructure
|
|
res.json({
|
|
success: true,
|
|
message: `Scraping queued for ${dispensary.name}`,
|
|
type,
|
|
dispensary: {
|
|
id: dispensary.id,
|
|
name: dispensary.name,
|
|
slug: dispensary.slug
|
|
}
|
|
});
|
|
}
|
|
catch (error) {
|
|
console.error('Error triggering scrape:', error);
|
|
res.status(500).json({ error: 'Failed to trigger scraping' });
|
|
}
|
|
});
|
|
// Update menu_type for a dispensary (dedicated endpoint)
|
|
router.patch('/:id/menu-type', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { menu_type } = req.body;
|
|
// Validate menu_type
|
|
if (menu_type !== null && menu_type !== '' && !VALID_MENU_TYPES.includes(menu_type)) {
|
|
return res.status(400).json({
|
|
error: `Invalid menu_type. Must be one of: ${VALID_MENU_TYPES.join(', ')} (or null to clear)`
|
|
});
|
|
}
|
|
const result = await migrate_1.pool.query(`
|
|
UPDATE dispensaries
|
|
SET menu_type = $1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2
|
|
RETURNING id, name, slug, menu_type, menu_provider, menu_url
|
|
`, [menu_type || null, id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Dispensary not found' });
|
|
}
|
|
res.json({
|
|
success: true,
|
|
dispensary: result.rows[0]
|
|
});
|
|
}
|
|
catch (error) {
|
|
console.error('Error updating menu_type:', error);
|
|
res.status(500).json({ error: 'Failed to update menu_type' });
|
|
}
|
|
});
|
|
// Bulk update menu_type for multiple dispensaries
|
|
router.post('/bulk/menu-type', async (req, res) => {
|
|
try {
|
|
const { dispensary_ids, menu_type } = 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' });
|
|
}
|
|
// Validate menu_type
|
|
if (menu_type !== null && menu_type !== '' && !VALID_MENU_TYPES.includes(menu_type)) {
|
|
return res.status(400).json({
|
|
error: `Invalid menu_type. Must be one of: ${VALID_MENU_TYPES.join(', ')} (or null to clear)`
|
|
});
|
|
}
|
|
const result = await migrate_1.pool.query(`
|
|
UPDATE dispensaries
|
|
SET menu_type = $1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ANY($2::int[])
|
|
RETURNING id, name, slug, menu_type
|
|
`, [menu_type || null, dispensary_ids]);
|
|
res.json({
|
|
success: true,
|
|
updated_count: result.rowCount,
|
|
dispensaries: result.rows
|
|
});
|
|
}
|
|
catch (error) {
|
|
console.error('Error bulk updating menu_type:', error);
|
|
res.status(500).json({ error: 'Failed to bulk update menu_type' });
|
|
}
|
|
});
|
|
exports.default = router;
|