725 lines
28 KiB
JavaScript
725 lines
28 KiB
JavaScript
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;
|