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

@@ -429,28 +429,49 @@ router.delete('/:id', requireRole('superadmin'), async (req, res) => {
}
});
// Get products for a store (uses dutchie_products table)
// 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
id,
name,
brand_name,
type,
subcategory,
stock_status,
thc_content,
cbd_content,
primary_image_url,
external_product_id,
created_at,
updated_at
FROM dutchie_products
WHERE dispensary_id = $1
ORDER BY name
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 });
@@ -460,6 +481,55 @@ router.get('/:id/products', async (req, res) => {
}
});
// 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 {
@@ -467,7 +537,7 @@ router.get('/:id/brands', async (req, res) => {
const result = await pool.query(`
SELECT DISTINCT brand_name as name, COUNT(*) as product_count
FROM dutchie_products
FROM v_products
WHERE dispensary_id = $1 AND brand_name IS NOT NULL
GROUP BY brand_name
ORDER BY product_count DESC, brand_name