## SEO Template Library - Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration) - Add Template Library tab in SEO Orchestrator with accordion-based editors - Add template preview, validation, and variable injection engine - Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate ## Discovery Pipeline - Add promotion.ts for discovery location validation and promotion - Add discover-all-states.ts script for multi-state discovery - Add promotion log migration (067) - Enhance discovery routes and types ## Orchestrator & Admin - Add crawl_enabled filter to stores page - Add API permissions page - Add job queue management - Add price analytics routes - Add markets and intelligence routes - Enhance dashboard and worker monitoring ## Infrastructure - Add migrations for worker definitions, SEO settings, field alignment - Add canonical pipeline for scraper v2 - Update hydration and sync orchestrator - Enhance multi-state query service 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
555 lines
16 KiB
TypeScript
Executable File
555 lines
16 KiB
TypeScript
Executable File
/**
|
|
* 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;
|