Newsletter recommendation tweaks, add campaign history dialog

This commit is contained in:
2026-02-01 17:37:08 -05:00
parent 450fd96e19
commit 2744e82264
9 changed files with 1232 additions and 113 deletions

View File

@@ -10,13 +10,13 @@ const REF_DATE_CTE = `
// Category definitions matching production website logic:
//
// NEW: created_at within 31 days, NOT preorder (mutually exclusive on prod)
// 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, price > 0
// 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: ranked by recent order volume (prod's "hot" logic)
// 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
@@ -29,7 +29,7 @@ const CATEGORY_FILTERS = {
clearance: "AND is_clearance = true",
daily_deals: "AND is_daily_deal = true",
back_in_stock: "AND is_back_in_stock = true",
bestsellers: "AND COALESCE(sales_30d, 0) > 0",
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",
};
@@ -38,6 +38,8 @@ function buildScoredCTE({ forCount = false } = {}) {
const selectColumns = forCount ? `
p.pid,
p.created_at,
p.date_online,
p.shop_score,
p.preorder_count,
p.price,
p.regular_price,
@@ -62,6 +64,7 @@ function buildScoredCTE({ forCount = false } = {}) {
p.vendor,
p.price,
p.regular_price,
p.shop_score,
p.image_175 as image,
p.permalink,
p.stock_quantity,
@@ -70,6 +73,7 @@ function buildScoredCTE({ forCount = false } = {}) {
p.categories,
p.line,
p.created_at as product_created_at,
p.date_online,
p.first_received,
p.date_last_sold,
p.total_sold,
@@ -101,7 +105,7 @@ function buildScoredCTE({ forCount = false } = {}) {
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 - p.created_at)::int as age_days
EXTRACT(DAY FROM ref.d - COALESCE(p.date_online, p.created_at))::int as age_days
`;
return `
@@ -144,10 +148,11 @@ function buildScoredCTE({ forCount = false } = {}) {
-- === CATEGORY FLAGS (production-accurate, mutually exclusive where needed) ===
-- NEW: within 31 days of reference date, AND not on preorder
-- 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 p.created_at > ref.d - INTERVAL '31 days' THEN true
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN true
ELSE false
END as is_new,
@@ -157,7 +162,7 @@ function buildScoredCTE({ forCount = false } = {}) {
ELSE false
END as is_preorder,
-- CLEARANCE: 35%+ discount off regular price, price must be > 0
-- 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
@@ -184,7 +189,7 @@ function buildScoredCTE({ forCount = false } = {}) {
-- We use date_last_received/date_first_received as our equivalents
CASE
WHEN p.preorder_count > 0 THEN false
WHEN p.created_at > ref.d - INTERVAL '31 days' 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'
@@ -195,11 +200,11 @@ function buildScoredCTE({ forCount = false } = {}) {
-- === RECOMMENDATION SCORE ===
(
-- New product boost (first 31 days, not preorder)
-- New product boost (first 31 days by date_online, not preorder)
CASE
WHEN p.preorder_count > 0 THEN 0
WHEN p.created_at > ref.d - INTERVAL '14 days' THEN 50
WHEN p.created_at > ref.d - INTERVAL '31 days' THEN 35
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
@@ -219,7 +224,7 @@ function buildScoredCTE({ forCount = false } = {}) {
-- Back in stock boost (only for actual restocks, not new arrivals)
+ CASE
WHEN p.preorder_count = 0
AND p.created_at <= ref.d - INTERVAL '31 days'
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'
@@ -266,8 +271,12 @@ function buildScoredCTE({ forCount = false } = {}) {
+ CASE WHEN pm.abc_class = 'A' THEN 10
WHEN pm.abc_class = 'B' THEN 5
ELSE 0 END
-- In-stock requirement
+ CASE WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 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
@@ -368,7 +377,7 @@ router.get('/stats', async (req, res) => {
-- Unfeatured new products
(SELECT COUNT(*) FROM products p, ref
WHERE p.visible = true AND p.preorder_count = 0
AND p.created_at > ref.d - INTERVAL '31 days'
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
@@ -377,7 +386,7 @@ router.get('/stats', async (req, res) => {
CROSS JOIN ref
WHERE p.visible = true
AND p.preorder_count = 0
AND p.created_at <= ref.d - INTERVAL '31 days'
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'
@@ -422,4 +431,293 @@ router.get('/stats', async (req, res) => {
}
});
// 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;