Files
cannaiq/backend/src/routes/stores.ts
Kelly 2f483b3084 feat: SEO template library, discovery pipeline, and orchestrator enhancements
## 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>
2025-12-09 00:05:34 -07:00

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;