const express = require('express'); const router = express.Router(); // Shared CTE fragment for the reference date. // Uses MAX(last_calculated) from product_metrics so time-relative logic works // correctly even when the local data snapshot is behind real-time. const REF_DATE_CTE = ` ref AS (SELECT COALESCE(MAX(last_calculated), NOW()) as d FROM product_metrics) `; // Category definitions matching production website logic: // // NEW: date_online within 31 days (matches prod's date_ol), NOT preorder // PRE-ORDER: preorder_count > 0, NOT new // CLEARANCE: (regular_price - price) / regular_price >= 0.35 (matches prod's 35% clearance threshold) // DAILY DEALS: product_daily_deals table // BACK IN STOCK: date_last_received > date_first_received, received within 14d, // first received > 30d ago, excludes new products (prod excludes datein < 30d) // BESTSELLERS: shop_score > 20 + in stock + recent sales (matches prod's /shop/hot page) // // Mutual exclusivity: // - New and Pre-order are exclusive: if preorder_count > 0, it's preorder not new // - Back in stock excludes new products and preorder products // - Clearance is independent (a bestseller can also be clearance) const CATEGORY_FILTERS = { new: "AND is_new = true", preorder: "AND is_preorder = true", clearance: "AND is_clearance = true", daily_deals: "AND is_daily_deal = true", back_in_stock: "AND is_back_in_stock = true", bestsellers: "AND shop_score > 20 AND COALESCE(current_stock, 0) > 0 AND COALESCE(sales_30d, 0) > 0", never_featured: "AND times_featured IS NULL AND line_last_featured_at IS NULL", no_interest: "AND COALESCE(total_sold, 0) = 0 AND COALESCE(current_stock, 0) > 0 AND COALESCE(date_online, product_created_at) <= CURRENT_DATE - INTERVAL '30 days'", }; function buildScoredCTE({ forCount = false } = {}) { // forCount=true returns minimal columns for COUNT(*) const selectColumns = forCount ? ` p.pid, p.created_at as product_created_at, p.date_online, p.shop_score, p.preorder_count, p.price, p.regular_price, p.total_sold, p.line, pm.current_stock, pm.on_order_qty, pm.sales_30d, pm.sales_7d, pm.date_last_received, pm.date_first_received, nh.times_featured, nh.last_featured_at, lh.line_last_featured_at, dd.deal_id, dd.deal_price ` : ` p.pid, p.title, p.sku, p.brand, p.vendor, p.price, p.regular_price, p.shop_score, p.image_175 as image, p.permalink, p.stock_quantity, p.preorder_count, p.tags, p.categories, p.line, p.created_at as product_created_at, p.date_online, p.first_received, p.date_last_sold, p.total_sold, p.baskets, p.notifies, pm.sales_7d, pm.sales_30d, pm.revenue_30d, pm.current_stock, pm.on_order_qty, pm.abc_class, pm.date_first_received, pm.date_last_received, pm.sales_velocity_daily, pm.sells_out_in_days, pm.sales_growth_30d_vs_prev, pm.margin_30d, -- Direct product feature history nh.times_featured, nh.last_featured_at, EXTRACT(DAY FROM ref.d - nh.last_featured_at)::int as days_since_featured, -- Line-level feature history lh.line_products_featured, lh.line_total_features, lh.line_last_featured_at, lh.line_products_featured_30d, lh.line_products_featured_7d, ls.line_product_count, EXTRACT(DAY FROM ref.d - lh.line_last_featured_at)::int as line_days_since_featured, COALESCE(nh.last_featured_at, lh.line_last_featured_at) as effective_last_featured, EXTRACT(DAY FROM ref.d - COALESCE(nh.last_featured_at, lh.line_last_featured_at))::int as effective_days_since_featured, EXTRACT(DAY FROM ref.d - COALESCE(p.date_online, p.created_at))::int as age_days `; return ` ${REF_DATE_CTE}, newsletter_history AS ( SELECT pid, COUNT(*) as times_featured, MAX(sent_at) as last_featured_at, MIN(sent_at) as first_featured_at FROM klaviyo_campaign_products GROUP BY pid ), line_history AS ( SELECT p2.line, COUNT(DISTINCT kcp.pid) as line_products_featured, COUNT(*) as line_total_features, MAX(kcp.sent_at) as line_last_featured_at, COUNT(DISTINCT kcp.pid) FILTER ( WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '30 days' ) as line_products_featured_30d, COUNT(DISTINCT kcp.pid) FILTER ( WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '7 days' ) as line_products_featured_7d FROM products p2 JOIN klaviyo_campaign_products kcp ON kcp.pid = p2.pid WHERE p2.line IS NOT NULL AND p2.line != '' GROUP BY p2.line ), line_sizes AS ( SELECT line, COUNT(*) as line_product_count FROM products WHERE visible = true AND line IS NOT NULL AND line != '' GROUP BY line ), scored AS ( SELECT ${selectColumns}, -- === CATEGORY FLAGS (production-accurate, mutually exclusive where needed) === -- NEW: date_online within 31 days of reference date, AND not on preorder -- Uses date_online (prod's date_ol) instead of created_at for accuracy CASE WHEN p.preorder_count > 0 THEN false WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN true ELSE false END as is_new, -- PRE-ORDER: has preorder quantity CASE WHEN p.preorder_count > 0 THEN true ELSE false END as is_preorder, -- CLEARANCE: 35%+ discount off regular price (matches prod threshold), price must be > 0 CASE WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35 THEN true ELSE false END as is_clearance, -- DAILY DEALS: product has an active deal for today CASE WHEN dd.deal_id IS NOT NULL THEN true ELSE false END as is_daily_deal, dd.deal_price, -- DISCOUNT % CASE WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price THEN ROUND(((p.regular_price - p.price) / p.regular_price * 100)::numeric, 0) ELSE 0 END as discount_pct, CASE WHEN pm.current_stock > 0 AND pm.current_stock <= 5 THEN true ELSE false END as is_low_stock, -- BACK IN STOCK: restocked product, not new, not preorder -- Matches prod: date_refill within X days, date_refill > datein, -- NOT datein within last 30 days (excludes new products) -- We use date_last_received/date_first_received as our equivalents CASE WHEN p.preorder_count > 0 THEN false WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN false WHEN pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' AND pm.current_stock > 0 THEN true ELSE false END as is_back_in_stock, -- === RECOMMENDATION SCORE === ( -- New product boost (first 31 days by date_online, not preorder) CASE WHEN p.preorder_count > 0 THEN 0 WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '14 days' THEN 50 WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN 35 ELSE 0 END -- Pre-order boost + CASE WHEN p.preorder_count > 0 THEN 30 ELSE 0 END -- Clearance boost (scaled by discount depth) + CASE WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35 THEN LEAST(((p.regular_price - p.price) / p.regular_price * 50)::int, 25) ELSE 0 END -- Sales velocity boost (prod's "hot" logic: recent purchase count) + CASE WHEN COALESCE(pm.sales_7d, 0) >= 5 THEN 15 WHEN COALESCE(pm.sales_7d, 0) >= 2 THEN 10 WHEN COALESCE(pm.sales_7d, 0) >= 1 THEN 5 ELSE 0 END -- Back in stock boost (only for actual restocks, not new arrivals) + CASE WHEN p.preorder_count = 0 AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days' AND pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' AND pm.current_stock > 0 THEN 25 ELSE 0 END -- High interest (baskets + notifies) + LEAST((COALESCE(p.baskets, 0) + COALESCE(p.notifies, 0)) / 2, 15) -- Recency penalty: line-aware effective last featured (tuned for daily sends) + CASE WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) IS NULL THEN 10 WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '2 days' THEN -30 WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '5 days' THEN -15 WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '10 days' THEN -5 ELSE 5 END -- Over-featured penalty (direct product only, tuned for daily sends) + CASE WHEN COALESCE(nh.times_featured, 0) > 15 THEN -10 WHEN COALESCE(nh.times_featured, 0) > 8 THEN -5 ELSE 0 END -- Line saturation penalty (uses 7-day window for daily send cadence) + CASE WHEN lh.line_products_featured_7d IS NOT NULL AND ls.line_product_count IS NOT NULL AND ls.line_product_count > 0 AND (lh.line_products_featured_7d::float / ls.line_product_count) > 0.7 THEN -10 WHEN lh.line_products_featured_7d IS NOT NULL AND lh.line_products_featured_7d >= 4 THEN -5 ELSE 0 END -- Price tier adjustment (deprioritize very low-price items) + CASE WHEN COALESCE(p.price, 0) < 3 THEN -15 WHEN COALESCE(p.price, 0) < 8 THEN -5 WHEN COALESCE(p.price, 0) >= 25 THEN 5 ELSE 0 END -- ABC class boost + CASE WHEN pm.abc_class = 'A' THEN 10 WHEN pm.abc_class = 'B' THEN 5 ELSE 0 END -- Stock penalty + CASE WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 WHEN COALESCE(pm.current_stock, 0) <= 2 AND COALESCE(p.preorder_count, 0) = 0 THEN -20 ELSE 0 END ) as score FROM ref, products p LEFT JOIN product_metrics pm ON pm.pid = p.pid LEFT JOIN newsletter_history nh ON nh.pid = p.pid LEFT JOIN line_history lh ON lh.line = p.line AND p.line IS NOT NULL AND p.line != '' LEFT JOIN line_sizes ls ON ls.line = p.line AND p.line IS NOT NULL AND p.line != '' LEFT JOIN product_daily_deals dd ON dd.pid = p.pid AND dd.deal_date = CURRENT_DATE WHERE p.visible = true ) `; } // GET /api/newsletter/recommendations router.get('/recommendations', async (req, res) => { const pool = req.app.locals.pool; try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 50; const offset = (page - 1) * limit; const category = req.query.category || 'all'; const categoryFilter = CATEGORY_FILTERS[category] || ''; const query = ` WITH ${buildScoredCTE()} SELECT * FROM scored WHERE score > -50 ${categoryFilter} ORDER BY score DESC, COALESCE(sales_7d, 0) DESC LIMIT $1 OFFSET $2 `; const countQuery = ` WITH ${buildScoredCTE({ forCount: true })} SELECT COUNT(*) FROM scored WHERE score > -50 ${categoryFilter} `; const [dataResult, countResult] = await Promise.all([ pool.query(query, [limit, offset]), pool.query(countQuery) ]); res.json({ products: dataResult.rows, pagination: { total: parseInt(countResult.rows[0].count), pages: Math.ceil(parseInt(countResult.rows[0].count) / limit), currentPage: page, limit } }); } catch (error) { console.error('Error fetching newsletter recommendations:', error); res.status(500).json({ error: 'Failed to fetch newsletter recommendations' }); } }); // GET /api/newsletter/history/:pid router.get('/history/:pid', async (req, res) => { const pool = req.app.locals.pool; const { pid } = req.params; try { const { rows } = await pool.query(` SELECT campaign_id, campaign_name, sent_at, product_url FROM klaviyo_campaign_products WHERE pid = $1 ORDER BY sent_at DESC `, [pid]); res.json({ history: rows }); } catch (error) { console.error('Error fetching newsletter history:', error); res.status(500).json({ error: 'Failed to fetch newsletter history' }); } }); // GET /api/newsletter/stats router.get('/stats', async (req, res) => { const pool = req.app.locals.pool; try { const { rows } = await pool.query(` WITH ref AS (SELECT COALESCE(MAX(last_calculated), NOW()) as d FROM product_metrics), featured_pids AS ( SELECT DISTINCT pid FROM klaviyo_campaign_products ), recent_pids AS ( SELECT DISTINCT pid FROM klaviyo_campaign_products WHERE sent_at > (SELECT d FROM ref) - INTERVAL '2 days' ) SELECT -- Unfeatured new products (SELECT COUNT(*) FROM products p, ref WHERE p.visible = true AND p.preorder_count = 0 AND COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' AND p.pid NOT IN (SELECT pid FROM featured_pids) ) as unfeatured_new, -- Back in stock, not yet featured since restock (SELECT COUNT(*) FROM products p JOIN product_metrics pm ON pm.pid = p.pid CROSS JOIN ref WHERE p.visible = true AND p.preorder_count = 0 AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days' AND pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' AND pm.current_stock > 0 AND p.pid NOT IN ( SELECT pid FROM klaviyo_campaign_products WHERE sent_at > pm.date_last_received ) ) as back_in_stock_ready, -- High score products available (score 40+, not featured in last 2 days) (SELECT COUNT(*) FROM ( WITH ${buildScoredCTE({ forCount: true })} SELECT pid FROM scored WHERE score >= 40 AND pid NOT IN (SELECT pid FROM recent_pids) ) hs) as high_score_available, -- Last campaign date (SELECT MAX(sent_at) FROM klaviyo_campaign_products) as last_campaign_date, -- Avg days since last featured (across visible in-stock catalog) (SELECT ROUND(AVG(days)::numeric, 1) FROM ( SELECT EXTRACT(DAY FROM ref.d - MAX(kcp.sent_at))::int as days FROM products p CROSS JOIN ref JOIN klaviyo_campaign_products kcp ON kcp.pid = p.pid JOIN product_metrics pm ON pm.pid = p.pid WHERE p.visible = true AND COALESCE(pm.current_stock, 0) > 0 GROUP BY p.pid, ref.d ) avg_calc) as avg_days_since_featured, -- Never featured (visible, in stock or preorder) (SELECT COUNT(*) FROM products p LEFT JOIN product_metrics pm ON pm.pid = p.pid WHERE p.visible = true AND (COALESCE(pm.current_stock, 0) > 0 OR p.preorder_count > 0) AND p.pid NOT IN (SELECT pid FROM featured_pids) ) as never_featured `); res.json(rows[0]); } catch (error) { console.error('Error fetching newsletter stats:', error); res.status(500).json({ error: 'Failed to fetch newsletter stats' }); } }); // GET /api/newsletter/score-breakdown/:pid // Returns the individual scoring factors for a single product (debug endpoint) router.get('/score-breakdown/:pid', async (req, res) => { const pool = req.app.locals.pool; const { pid } = req.params; try { const { rows } = await pool.query(` WITH ${REF_DATE_CTE}, newsletter_history AS ( SELECT pid, COUNT(*) as times_featured, MAX(sent_at) as last_featured_at FROM klaviyo_campaign_products GROUP BY pid ), line_history AS ( SELECT p2.line, COUNT(DISTINCT kcp.pid) FILTER (WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '7 days') as line_products_featured_7d FROM products p2 JOIN klaviyo_campaign_products kcp ON kcp.pid = p2.pid WHERE p2.line IS NOT NULL AND p2.line != '' GROUP BY p2.line ), line_sizes AS ( SELECT line, COUNT(*) as line_product_count FROM products WHERE visible = true AND line IS NOT NULL AND line != '' GROUP BY line ) SELECT -- New product boost CASE WHEN p.preorder_count > 0 THEN 0 WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '14 days' THEN 50 WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN 35 ELSE 0 END as new_boost, -- Pre-order boost CASE WHEN p.preorder_count > 0 THEN 30 ELSE 0 END as preorder_boost, -- Clearance boost CASE WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35 THEN LEAST(((p.regular_price - p.price) / p.regular_price * 50)::int, 25) ELSE 0 END as clearance_boost, -- Sales velocity CASE WHEN COALESCE(pm.sales_7d, 0) >= 5 THEN 15 WHEN COALESCE(pm.sales_7d, 0) >= 2 THEN 10 WHEN COALESCE(pm.sales_7d, 0) >= 1 THEN 5 ELSE 0 END as velocity_boost, -- Back in stock CASE WHEN p.preorder_count = 0 AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days' AND pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' AND pm.current_stock > 0 THEN 25 ELSE 0 END as back_in_stock_boost, -- Interest LEAST((COALESCE(p.baskets, 0) + COALESCE(p.notifies, 0)) / 2, 15) as interest_boost, -- Recency CASE WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) IS NULL THEN 10 WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '2 days' THEN -30 WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '5 days' THEN -15 WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '10 days' THEN -5 ELSE 5 END as recency_adj, -- Over-featured CASE WHEN COALESCE(nh.times_featured, 0) > 15 THEN -10 WHEN COALESCE(nh.times_featured, 0) > 8 THEN -5 ELSE 0 END as over_featured_adj, -- Line saturation CASE WHEN lh2.line_products_featured_7d IS NOT NULL AND ls.line_product_count IS NOT NULL AND ls.line_product_count > 0 AND (lh2.line_products_featured_7d::float / ls.line_product_count) > 0.7 THEN -10 WHEN lh2.line_products_featured_7d IS NOT NULL AND lh2.line_products_featured_7d >= 4 THEN -5 ELSE 0 END as line_saturation_adj, -- Price tier CASE WHEN COALESCE(p.price, 0) < 3 THEN -15 WHEN COALESCE(p.price, 0) < 8 THEN -5 WHEN COALESCE(p.price, 0) >= 25 THEN 5 ELSE 0 END as price_tier_adj, -- ABC class CASE WHEN pm.abc_class = 'A' THEN 10 WHEN pm.abc_class = 'B' THEN 5 ELSE 0 END as abc_boost, -- Stock penalty CASE WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 WHEN COALESCE(pm.current_stock, 0) <= 2 AND COALESCE(p.preorder_count, 0) = 0 THEN -20 ELSE 0 END as stock_penalty FROM ref, products p LEFT JOIN product_metrics pm ON pm.pid = p.pid LEFT JOIN newsletter_history nh ON nh.pid = p.pid LEFT JOIN LATERAL ( SELECT MAX(kcp.sent_at) as line_last_featured_at FROM products p3 JOIN klaviyo_campaign_products kcp ON kcp.pid = p3.pid WHERE p3.line = p.line AND p.line IS NOT NULL AND p.line != '' ) lh ON true LEFT JOIN line_history lh2 ON lh2.line = p.line AND p.line IS NOT NULL AND p.line != '' LEFT JOIN line_sizes ls ON ls.line = p.line AND p.line IS NOT NULL AND p.line != '' WHERE p.pid = $1 `, [pid]); if (rows.length === 0) { return res.status(404).json({ error: 'Product not found' }); } res.json(rows[0]); } catch (error) { console.error('Error fetching score breakdown:', error); res.status(500).json({ error: 'Failed to fetch score breakdown' }); } }); // GET /api/newsletter/campaigns // Returns all campaigns with product counts and links router.get('/campaigns', async (req, res) => { const pool = req.app.locals.pool; try { const [campaignsResult, linksResult, summaryResult] = await Promise.all([ pool.query(` SELECT kcp.campaign_id, kcp.campaign_name, kcp.sent_at, COUNT(*) as product_count, json_agg(json_build_object( 'pid', kcp.pid, 'title', p.title, 'sku', p.sku, 'brand', p.brand, 'line', p.line, 'image', p.image_175, 'product_url', kcp.product_url ) ORDER BY p.brand, p.line, p.title) as products FROM klaviyo_campaign_products kcp LEFT JOIN products p ON p.pid = kcp.pid GROUP BY kcp.campaign_id, kcp.campaign_name, kcp.sent_at ORDER BY kcp.sent_at DESC `), pool.query(` SELECT campaign_id, campaign_name, sent_at, link_url, link_type FROM klaviyo_campaign_links ORDER BY sent_at DESC `), pool.query(` SELECT COUNT(DISTINCT campaign_id) as total_campaigns, COUNT(DISTINCT pid) as total_unique_products, ROUND(COUNT(*)::numeric / NULLIF(COUNT(DISTINCT campaign_id), 0), 1) as avg_products_per_campaign FROM klaviyo_campaign_products `) ]); // Group links by campaign_id const linksByCampaign = {}; for (const link of linksResult.rows) { if (!linksByCampaign[link.campaign_id]) linksByCampaign[link.campaign_id] = []; linksByCampaign[link.campaign_id].push(link); } const campaigns = campaignsResult.rows.map(c => ({ ...c, links: linksByCampaign[c.campaign_id] || [] })); res.json({ campaigns, summary: summaryResult.rows[0] }); } catch (error) { console.error('Error fetching campaigns:', error); res.status(500).json({ error: 'Failed to fetch campaigns' }); } }); // GET /api/newsletter/campaigns/products // Returns product-level aggregate stats across all campaigns router.get('/campaigns/products', async (req, res) => { const pool = req.app.locals.pool; try { const { rows } = await pool.query(` SELECT kcp.pid, p.title, p.sku, p.brand, p.image_175 as image, p.permalink, COUNT(*) as times_featured, MIN(kcp.sent_at) as first_featured_at, MAX(kcp.sent_at) as last_featured_at, EXTRACT(DAY FROM NOW() - MAX(kcp.sent_at))::int as days_since_featured, EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::int as featured_span_days, CASE WHEN COUNT(*) > 1 THEN ROUND(EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::numeric / (COUNT(*) - 1), 1) ELSE NULL END as avg_days_between_features, json_agg(json_build_object( 'campaign_id', kcp.campaign_id, 'campaign_name', kcp.campaign_name, 'sent_at', kcp.sent_at ) ORDER BY kcp.sent_at DESC) as campaigns FROM klaviyo_campaign_products kcp LEFT JOIN products p ON p.pid = kcp.pid GROUP BY kcp.pid, p.title, p.sku, p.brand, p.image_175, p.permalink ORDER BY COUNT(*) DESC, MAX(kcp.sent_at) DESC `); res.json({ products: rows }); } catch (error) { console.error('Error fetching campaign products:', error); res.status(500).json({ error: 'Failed to fetch campaign products' }); } }); // GET /api/newsletter/campaigns/brands // Returns brand-level aggregate stats across all campaigns router.get('/campaigns/brands', async (req, res) => { const pool = req.app.locals.pool; try { const { rows } = await pool.query(` SELECT COALESCE(p.brand, 'Unknown') as brand, COUNT(DISTINCT kcp.pid) as product_count, COUNT(*) as times_featured, MIN(kcp.sent_at) as first_featured_at, MAX(kcp.sent_at) as last_featured_at, EXTRACT(DAY FROM NOW() - MAX(kcp.sent_at))::int as days_since_featured, CASE WHEN COUNT(DISTINCT kcp.campaign_id) > 1 THEN ROUND(EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::numeric / (COUNT(DISTINCT kcp.campaign_id) - 1), 1) ELSE NULL END as avg_days_between_features, json_agg(DISTINCT jsonb_build_object( 'campaign_id', kcp.campaign_id, 'campaign_name', kcp.campaign_name, 'sent_at', kcp.sent_at )) as campaigns FROM klaviyo_campaign_products kcp LEFT JOIN products p ON p.pid = kcp.pid GROUP BY COALESCE(p.brand, 'Unknown') ORDER BY COUNT(*) DESC, MAX(kcp.sent_at) DESC `); res.json({ brands: rows }); } catch (error) { console.error('Error fetching campaign brands:', error); res.status(500).json({ error: 'Failed to fetch campaign brands' }); } }); // GET /api/newsletter/campaigns/links // Returns link-level aggregate stats across all campaigns router.get('/campaigns/links', async (req, res) => { const pool = req.app.locals.pool; try { const { rows } = await pool.query(` SELECT link_url, link_type, COUNT(*) as times_used, MIN(sent_at) as first_used_at, MAX(sent_at) as last_used_at, EXTRACT(DAY FROM NOW() - MAX(sent_at))::int as days_since_used, json_agg(DISTINCT campaign_name ORDER BY campaign_name) as campaign_names FROM klaviyo_campaign_links GROUP BY link_url, link_type ORDER BY COUNT(*) DESC, MAX(sent_at) DESC `); res.json({ links: rows }); } catch (error) { console.error('Error fetching campaign links:', error); res.status(500).json({ error: 'Failed to fetch campaign links' }); } }); module.exports = router;