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