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>
This commit is contained in:
Kelly
2025-12-09 00:05:34 -07:00
parent 9711d594db
commit 2f483b3084
83 changed files with 16700 additions and 1277 deletions

View File

@@ -313,6 +313,8 @@ function getScopedDispensaryId(req: PublicApiRequest): { dispensaryId: number |
* - dispensary_id: (internal keys only) Filter by specific dispensary
* - sort_by: Sort field (name, price, thc, updated) (default: name)
* - sort_dir: Sort direction (asc, desc) (default: asc)
* - pricing_type: Price type to return (rec, med, all) (default: rec)
* - include_variants: Include per-variant pricing/inventory (true/false) (default: false)
*/
router.get('/products', async (req: PublicApiRequest, res: Response) => {
try {
@@ -341,7 +343,9 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
limit = '100',
offset = '0',
sort_by = 'name',
sort_dir = 'asc'
sort_dir = 'asc',
pricing_type = 'rec',
include_variants = 'false'
} = req.query;
// Build query
@@ -367,9 +371,9 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
whereClause += ` AND p.stock_status = 'in_stock'`;
}
// Filter by category (maps to 'type' in dutchie_az)
// Filter by category
if (category) {
whereClause += ` AND LOWER(p.type) = LOWER($${paramIndex})`;
whereClause += ` AND LOWER(p.category) = LOWER($${paramIndex})`;
params.push(category);
paramIndex++;
}
@@ -390,19 +394,19 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
// Filter by THC range
if (min_thc) {
whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) >= $${paramIndex}`;
whereClause += ` AND p.thc_percent >= $${paramIndex}`;
params.push(parseFloat(min_thc as string));
paramIndex++;
}
if (max_thc) {
whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) <= $${paramIndex}`;
whereClause += ` AND p.thc_percent <= $${paramIndex}`;
params.push(parseFloat(max_thc as string));
paramIndex++;
}
// Filter by on special
if (on_special === 'true' || on_special === '1') {
whereClause += ` AND s.special = TRUE`;
whereClause += ` AND s.is_on_special = TRUE`;
}
// Search by name or brand
@@ -416,15 +420,16 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500);
const offsetNum = parseInt(offset as string, 10) || 0;
// Build ORDER BY clause
// Build ORDER BY clause (use pricing_type for price sorting)
const sortDirection = sort_dir === 'desc' ? 'DESC' : 'ASC';
let orderBy = 'p.name ASC';
switch (sort_by) {
case 'price':
orderBy = `s.rec_min_price_cents ${sortDirection} NULLS LAST`;
const sortPriceCol = pricing_type === 'med' ? 's.price_med' : 's.price_rec';
orderBy = `${sortPriceCol} ${sortDirection} NULLS LAST`;
break;
case 'thc':
orderBy = `CAST(NULLIF(p.thc, '') AS NUMERIC) ${sortDirection} NULLS LAST`;
orderBy = `p.thc_percent ${sortDirection} NULLS LAST`;
break;
case 'updated':
orderBy = `p.updated_at ${sortDirection}`;
@@ -436,80 +441,91 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
params.push(limitNum, offsetNum);
// Determine which price column to use for filtering based on pricing_type
const priceColumn = pricing_type === 'med' ? 's.price_med' : 's.price_rec';
// Query products with latest snapshot data
// Note: Price filters use HAVING clause since they reference the snapshot subquery
// Uses store_products + v_product_snapshots (canonical tables with raw_data)
const { rows: products } = await pool.query(`
SELECT
p.id,
p.dispensary_id,
p.external_product_id as dutchie_id,
p.provider_product_id as dutchie_id,
p.name,
p.brand_name as brand,
p.type as category,
p.category,
p.subcategory,
p.strain_type,
p.stock_status,
p.thc,
p.cbd,
p.primary_image_url as image_url,
p.images,
p.effects,
p.thc_percent as thc,
p.cbd_percent as cbd,
p.image_url,
p.created_at,
p.updated_at,
s.rec_min_price_cents,
s.rec_max_price_cents,
s.rec_min_special_price_cents,
s.med_min_price_cents,
s.med_max_price_cents,
s.med_min_special_price_cents,
s.total_quantity_available,
s.options,
s.special,
s.crawled_at as snapshot_at
FROM dutchie_products p
s.price_rec,
s.price_med,
s.price_rec_special,
s.price_med_special,
s.stock_quantity as total_quantity_available,
s.is_on_special as special,
s.captured_at as snapshot_at,
${include_variants === 'true' || include_variants === '1' ? "s.raw_data->'POSMetaData'->'children' as variants_raw" : 'NULL as variants_raw'}
FROM store_products p
LEFT JOIN LATERAL (
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
ORDER BY crawled_at DESC
SELECT * FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY captured_at DESC
LIMIT 1
) s ON true
${whereClause}
${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''}
${min_price ? `AND ${priceColumn} >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND ${priceColumn} <= ${parseFloat(max_price as string)}` : ''}
ORDER BY ${orderBy}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Get total count for pagination (include price filters if specified)
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total FROM dutchie_products p
SELECT COUNT(*) as total FROM store_products p
LEFT JOIN LATERAL (
SELECT rec_min_price_cents, special FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
ORDER BY crawled_at DESC
SELECT price_rec, price_med, is_on_special FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY captured_at DESC
LIMIT 1
) s ON true
${whereClause}
${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''}
${min_price ? `AND ${priceColumn} >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND ${priceColumn} <= ${parseFloat(max_price as string)}` : ''}
`, params.slice(0, -2));
// Transform products to backward-compatible format
// Helper to format variants from raw Dutchie data
const formatVariants = (variantsRaw: any[]) => {
if (!variantsRaw || !Array.isArray(variantsRaw)) return [];
return variantsRaw.map((v: any) => ({
option: v.option || v.key || '',
price_rec: v.recPrice || v.price || null,
price_med: v.medPrice || null,
price_rec_special: v.recSpecialPrice || null,
price_med_special: v.medSpecialPrice || null,
quantity: v.quantityAvailable ?? v.quantity ?? null,
in_stock: (v.quantityAvailable ?? v.quantity ?? 0) > 0,
sku: v.canonicalSKU || null,
canonical_id: v.canonicalID || null,
}));
};
// Transform products with pricing_type support
const transformedProducts = products.map((p) => {
let imageUrl = p.image_url;
if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) {
const firstImage = p.images[0];
imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url;
}
// Select price based on pricing_type
const useRecPricing = pricing_type !== 'med';
const regularPrice = useRecPricing
? (p.price_rec ? parseFloat(p.price_rec).toFixed(2) : null)
: (p.price_med ? parseFloat(p.price_med).toFixed(2) : null);
const salePrice = useRecPricing
? (p.price_rec_special ? parseFloat(p.price_rec_special).toFixed(2) : null)
: (p.price_med_special ? parseFloat(p.price_med_special).toFixed(2) : null);
const regularPrice = p.rec_min_price_cents
? (p.rec_min_price_cents / 100).toFixed(2)
: null;
const salePrice = p.rec_min_special_price_cents
? (p.rec_min_special_price_cents / 100).toFixed(2)
: null;
return {
const result: any = {
id: p.id,
dispensary_id: p.dispensary_id,
dutchie_id: p.dutchie_id,
@@ -523,16 +539,36 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
sale_price: salePrice,
thc_percentage: p.thc ? parseFloat(p.thc) : null,
cbd_percentage: p.cbd ? parseFloat(p.cbd) : null,
image_url: imageUrl || null,
image_url: p.image_url || null,
in_stock: p.stock_status === 'in_stock',
on_special: p.special || false,
effects: p.effects || [],
options: p.options || [],
quantity_available: p.total_quantity_available || 0,
created_at: p.created_at,
updated_at: p.updated_at,
snapshot_at: p.snapshot_at
snapshot_at: p.snapshot_at,
pricing_type: pricing_type,
};
// Include both pricing if pricing_type is 'all'
if (pricing_type === 'all') {
result.pricing = {
rec: {
price: p.price_rec ? parseFloat(p.price_rec).toFixed(2) : null,
special_price: p.price_rec_special ? parseFloat(p.price_rec_special).toFixed(2) : null,
},
med: {
price: p.price_med ? parseFloat(p.price_med).toFixed(2) : null,
special_price: p.price_med_special ? parseFloat(p.price_med_special).toFixed(2) : null,
}
};
}
// Include variants if requested
if (include_variants === 'true' || include_variants === '1') {
result.variants = formatVariants(p.variants_raw);
}
return result;
});
res.json({
@@ -578,10 +614,10 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => {
s.options,
s.special,
s.crawled_at as snapshot_at
FROM dutchie_products p
FROM v_products p
LEFT JOIN LATERAL (
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
SELECT * FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
@@ -682,7 +718,7 @@ router.get('/categories', async (req: PublicApiRequest, res: Response) => {
subcategory,
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count
FROM dutchie_products
FROM v_products
${whereClause}
GROUP BY type, subcategory
ORDER BY type, subcategory
@@ -737,7 +773,7 @@ router.get('/brands', async (req: PublicApiRequest, res: Response) => {
brand_name as brand,
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count
FROM dutchie_products
FROM v_products
${whereClause}
GROUP BY brand_name
ORDER BY product_count DESC
@@ -813,10 +849,10 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
s.options,
p.updated_at,
s.crawled_at as snapshot_at
FROM dutchie_products p
FROM v_products p
INNER JOIN LATERAL (
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
SELECT * FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
@@ -829,10 +865,10 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
const countParams = params.slice(0, -2);
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total
FROM dutchie_products p
FROM v_products p
INNER JOIN LATERAL (
SELECT special FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
SELECT special FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
@@ -934,7 +970,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count,
MAX(updated_at) as last_updated
FROM dutchie_products
FROM v_products
WHERE dispensary_id = d.id
) pc ON true
WHERE d.id = $1
@@ -1041,7 +1077,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count,
MAX(updated_at) as last_updated
FROM dutchie_products
FROM v_products
WHERE dispensary_id = d.id
) pc ON true
${whereClause}
@@ -1055,7 +1091,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
FROM dispensaries d
LEFT JOIN LATERAL (
SELECT COUNT(*) as product_count
FROM dutchie_products
FROM v_products
WHERE dispensary_id = d.id
) pc ON true
${whereClause}
@@ -1206,10 +1242,10 @@ router.get('/search', async (req: PublicApiRequest, res: Response) => {
WHEN LOWER(p.brand_name) LIKE '%' || LOWER($${relevanceParamIndex}) || '%' THEN 60
ELSE 50
END as relevance
FROM dutchie_products p
FROM v_products p
LEFT JOIN LATERAL (
SELECT * FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
SELECT * FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
@@ -1222,7 +1258,7 @@ router.get('/search', async (req: PublicApiRequest, res: Response) => {
const countParams = params.slice(0, paramIndex - 3); // Remove relevance, limit, offset
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total
FROM dutchie_products p
FROM v_products p
${whereClause}
`, countParams);
@@ -1306,7 +1342,7 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
type as category,
COUNT(*) as total,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock
FROM dutchie_products
FROM v_products
${whereClause} AND type IS NOT NULL
GROUP BY type
ORDER BY total DESC
@@ -1320,17 +1356,17 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
COUNT(DISTINCT brand_name) as brand_count,
COUNT(DISTINCT type) as category_count,
MAX(updated_at) as last_updated
FROM dutchie_products
FROM v_products
${whereClause}
`, params);
// Get specials count
const { rows: specialsCount } = await pool.query(`
SELECT COUNT(*) as count
FROM dutchie_products p
FROM v_products p
INNER JOIN LATERAL (
SELECT special FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
SELECT special FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true