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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user