/** * Stores API Routes * * NOTE: "Store" and "Dispensary" are synonyms in CannaiQ. * - This file handles `/api/stores` endpoints * - The DB table is `dispensaries` (NOT `stores`) * - Use these terms interchangeably * - `/api/stores` and `/api/dispensaries` both work */ import { Router } from 'express'; import { authMiddleware, requireRole } from '../auth/middleware'; import { pool } from '../db/pool'; const router = Router(); router.use(authMiddleware); // Freshness threshold in hours const STALE_THRESHOLD_HOURS = 4; function calculateFreshness(lastCrawlAt: Date | null): { last_crawl_at: string | null; is_stale: boolean; freshness: string; hours_since_crawl: number | null; } { if (!lastCrawlAt) { return { last_crawl_at: null, is_stale: true, freshness: 'Never crawled', hours_since_crawl: null }; } const now = new Date(); const diffMs = now.getTime() - lastCrawlAt.getTime(); const diffHours = diffMs / (1000 * 60 * 60); const isStale = diffHours > STALE_THRESHOLD_HOURS; let freshnessText: string; if (diffHours < 1) { const mins = Math.round(diffHours * 60); freshnessText = `${mins} minute${mins !== 1 ? 's' : ''} ago`; } else if (diffHours < 24) { const hrs = Math.round(diffHours); freshnessText = `${hrs} hour${hrs !== 1 ? 's' : ''} ago`; } else { const days = Math.round(diffHours / 24); freshnessText = `${days} day${days !== 1 ? 's' : ''} ago`; } return { last_crawl_at: lastCrawlAt.toISOString(), is_stale: isStale, freshness: freshnessText, hours_since_crawl: Math.round(diffHours * 10) / 10 }; } function detectProvider(menuUrl: string | null): string { if (!menuUrl) return 'unknown'; if (menuUrl.includes('dutchie.com')) return 'Dutchie'; if (menuUrl.includes('iheartjane.com') || menuUrl.includes('jane.co')) return 'Jane'; if (menuUrl.includes('treez.io')) return 'Treez'; if (menuUrl.includes('weedmaps.com')) return 'Weedmaps'; if (menuUrl.includes('leafly.com')) return 'Leafly'; return 'Custom'; } // Get all stores (from dispensaries table) router.get('/', async (req, res) => { try { const { city, state, menu_type, crawl_enabled, dutchie_verified } = req.query; let query = ` SELECT id, name, slug, city, state, address1, address2, zipcode, phone, website, email, latitude, longitude, timezone, menu_url, menu_type, platform, platform_dispensary_id, c_name, chain_slug, enterprise_id, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country, product_count, last_crawl_at, crawl_enabled, dutchie_verified, created_at, updated_at FROM dispensaries `; const params: any[] = []; const conditions: string[] = []; // Filter by city (partial match) if (city) { conditions.push(`city ILIKE $${params.length + 1}`); params.push(`%${city}%`); } // Filter by state if (state) { conditions.push(`state = $${params.length + 1}`); params.push(state); } // Filter by menu_type if (menu_type) { conditions.push(`menu_type = $${params.length + 1}`); params.push(menu_type); } // Filter by crawl_enabled - defaults to showing only enabled if (crawl_enabled === 'false' || crawl_enabled === '0') { // Explicitly show disabled only conditions.push(`(crawl_enabled = false OR crawl_enabled IS NULL)`); } else if (crawl_enabled === 'all') { // Show all (no filter) } else { // Default: show only enabled conditions.push(`crawl_enabled = true`); } // Filter by dutchie_verified if (dutchie_verified !== undefined) { const verified = dutchie_verified === 'true' || dutchie_verified === '1'; if (verified) { conditions.push(`dutchie_verified = true`); } else { conditions.push(`(dutchie_verified = false OR dutchie_verified IS NULL)`); } } if (conditions.length > 0) { query += ` WHERE ${conditions.join(' AND ')}`; } query += ` ORDER BY name`; const result = await pool.query(query, params); // Add computed fields const stores = result.rows.map(row => ({ ...row, provider: detectProvider(row.menu_url), ...calculateFreshness(row.last_crawl_at) })); res.json({ stores, total: result.rowCount }); } catch (error) { console.error('Error fetching stores:', error); res.status(500).json({ error: 'Failed to fetch stores' }); } }); // Get single store by ID (from dispensaries table) router.get('/:id', async (req, res) => { try { const { id } = req.params; const result = await pool.query(` SELECT id, name, slug, city, state, address1, address2, zipcode, phone, website, email, dba_name, latitude, longitude, timezone, menu_url, menu_type, platform, platform_dispensary_id, c_name, chain_slug, enterprise_id, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country, product_count, last_crawl_at, raw_metadata, created_at, updated_at FROM dispensaries WHERE id = $1 `, [id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Store not found' }); } const store = result.rows[0]; // Calculate freshness const freshness = calculateFreshness(store.last_crawl_at); // Detect provider from URL const provider = detectProvider(store.menu_url); // Build response const response = { ...store, provider, ...freshness, }; res.json(response); } catch (error) { console.error('Error fetching store:', error); res.status(500).json({ error: 'Failed to fetch store' }); } }); // Create store (into dispensaries table) router.post('/', requireRole('superadmin', 'admin'), async (req, res) => { try { const { name, slug, city, state, address1, address2, zipcode, phone, website, email, menu_url, menu_type, platform, platform_dispensary_id, c_name, chain_slug, enterprise_id, latitude, longitude, timezone, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country } = req.body; if (!name || !slug || !city || !state) { return res.status(400).json({ error: 'name, slug, city, and state are required' }); } const result = await pool.query(` INSERT INTO dispensaries ( name, slug, city, state, address1, address2, zipcode, phone, website, email, menu_url, menu_type, platform, platform_dispensary_id, c_name, chain_slug, enterprise_id, latitude, longitude, timezone, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING * `, [ name, slug, city, state, address1, address2, zipcode, phone, website, email, menu_url, menu_type, platform || 'dutchie', platform_dispensary_id, c_name, chain_slug, enterprise_id, latitude, longitude, timezone, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country || 'United States' ]); res.status(201).json(result.rows[0]); } catch (error: any) { console.error('Error creating store:', error); if (error.code === '23505') { // unique violation res.status(409).json({ error: 'Store with this slug already exists' }); } else { res.status(500).json({ error: 'Failed to create store' }); } } }); // Update store (in dispensaries table) router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => { try { const { id } = req.params; const { name, slug, city, state, address1, address2, zipcode, phone, website, email, menu_url, menu_type, platform, platform_dispensary_id, c_name, chain_slug, enterprise_id, latitude, longitude, timezone, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country } = req.body; const result = await pool.query(` UPDATE dispensaries SET name = COALESCE($1, name), slug = COALESCE($2, slug), city = COALESCE($3, city), state = COALESCE($4, state), address1 = COALESCE($5, address1), address2 = COALESCE($6, address2), zipcode = COALESCE($7, zipcode), phone = COALESCE($8, phone), website = COALESCE($9, website), email = COALESCE($10, email), menu_url = COALESCE($11, menu_url), menu_type = COALESCE($12, menu_type), platform = COALESCE($13, platform), platform_dispensary_id = COALESCE($14, platform_dispensary_id), c_name = COALESCE($15, c_name), chain_slug = COALESCE($16, chain_slug), enterprise_id = COALESCE($17, enterprise_id), latitude = COALESCE($18, latitude), longitude = COALESCE($19, longitude), timezone = COALESCE($20, timezone), description = COALESCE($21, description), logo_image = COALESCE($22, logo_image), banner_image = COALESCE($23, banner_image), offer_pickup = COALESCE($24, offer_pickup), offer_delivery = COALESCE($25, offer_delivery), offer_curbside_pickup = COALESCE($26, offer_curbside_pickup), is_medical = COALESCE($27, is_medical), is_recreational = COALESCE($28, is_recreational), status = COALESCE($29, status), country = COALESCE($30, country), updated_at = CURRENT_TIMESTAMP WHERE id = $31 RETURNING * `, [ name, slug, city, state, address1, address2, zipcode, phone, website, email, menu_url, menu_type, platform, platform_dispensary_id, c_name, chain_slug, enterprise_id, latitude, longitude, timezone, description, logo_image, banner_image, offer_pickup, offer_delivery, offer_curbside_pickup, is_medical, is_recreational, status, country, id ]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Store not found' }); } res.json(result.rows[0]); } catch (error) { console.error('Error updating store:', error); res.status(500).json({ error: 'Failed to update store' }); } }); // Delete store (from dispensaries table) router.delete('/:id', requireRole('superadmin'), async (req, res) => { try { const { id } = req.params; const result = await pool.query('DELETE FROM dispensaries WHERE id = $1 RETURNING *', [id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Store not found' }); } res.json({ message: 'Store deleted successfully' }); } catch (error) { console.error('Error deleting store:', error); res.status(500).json({ error: 'Failed to delete store' }); } }); // Get products for a store (uses store_products via v_products view with snapshot pricing) router.get('/:id/products', async (req, res) => { try { const { id } = req.params; const result = await pool.query(` SELECT p.id, p.name, p.brand_name, p.type, p.subcategory, p.strain_type, p.stock_status, p.thc as thc_content, p.cbd as cbd_content, sp.description, sp.total_quantity_available as quantity, p.primary_image_url, p.external_product_id, p.created_at, p.updated_at, COALESCE(snap.rec_min_price_cents, 0)::numeric / 100.0 as regular_price, CASE WHEN snap.rec_min_special_price_cents > 0 THEN snap.rec_min_special_price_cents::numeric / 100.0 ELSE NULL END as sale_price, COALESCE(snap.med_min_price_cents, 0)::numeric / 100.0 as med_price, CASE WHEN snap.med_min_special_price_cents > 0 THEN snap.med_min_special_price_cents::numeric / 100.0 ELSE NULL END as med_sale_price, snap.special as on_special FROM v_products p JOIN store_products sp ON sp.id = p.id LEFT JOIN LATERAL ( SELECT rec_min_price_cents, rec_min_special_price_cents, med_min_price_cents, med_min_special_price_cents, special FROM v_product_snapshots vps WHERE vps.store_product_id = p.id ORDER BY vps.crawled_at DESC LIMIT 1 ) snap ON true WHERE p.dispensary_id = $1 ORDER BY p.name `, [id]); res.json({ products: result.rows }); } catch (error) { console.error('Error fetching store products:', error); res.status(500).json({ error: 'Failed to fetch products' }); } }); // Get specials for a store (products with sale prices or on_special flag) router.get('/:id/specials', async (req, res) => { try { const { id } = req.params; const result = await pool.query(` SELECT p.id, p.name, p.brand_name, p.type, p.subcategory, p.strain_type, p.stock_status, p.thc as thc_content, p.cbd as cbd_content, sp.description, sp.total_quantity_available as quantity, p.primary_image_url, p.external_product_id, p.created_at, p.updated_at, COALESCE(snap.rec_min_price_cents, 0)::numeric / 100.0 as regular_price, snap.rec_min_special_price_cents::numeric / 100.0 as sale_price, COALESCE(snap.med_min_price_cents, 0)::numeric / 100.0 as med_price, snap.med_min_special_price_cents::numeric / 100.0 as med_sale_price, true as on_special FROM v_products p JOIN store_products sp ON sp.id = p.id INNER JOIN LATERAL ( SELECT rec_min_price_cents, rec_min_special_price_cents, med_min_price_cents, med_min_special_price_cents, special FROM v_product_snapshots vps WHERE vps.store_product_id = p.id AND (vps.special = true OR vps.rec_min_special_price_cents > 0 OR vps.med_min_special_price_cents > 0) ORDER BY vps.crawled_at DESC LIMIT 1 ) snap ON true WHERE p.dispensary_id = $1 ORDER BY p.name `, [id]); res.json({ specials: result.rows }); } catch (error) { console.error('Error fetching store specials:', error); res.status(500).json({ error: 'Failed to fetch specials' }); } }); // Get brands for a store router.get('/:id/brands', async (req, res) => { try { const { id } = req.params; const result = await pool.query(` SELECT DISTINCT brand_name as name, COUNT(*) as product_count FROM v_products WHERE dispensary_id = $1 AND brand_name IS NOT NULL GROUP BY brand_name ORDER BY product_count DESC, brand_name `, [id]); const brands = result.rows.map((row: any) => row.name); res.json({ brands, details: result.rows }); } catch (error) { console.error('Error fetching store brands:', error); res.status(500).json({ error: 'Failed to fetch store brands' }); } }); export default router;