Add newsletter recommendations

This commit is contained in:
2026-01-31 22:04:49 -05:00
parent 4372dc5e26
commit 450fd96e19
11 changed files with 1169 additions and 29 deletions

View File

@@ -6,8 +6,9 @@
* - Parses out product links (/shop/{id}) and other shop links
* - Inserts into klaviyo_campaign_products and klaviyo_campaign_links tables
*
* Usage: node scripts/poc-campaign-products.js [limit]
* limit: number of recent campaigns to process (default: 10)
* Usage: node scripts/poc-campaign-products.js [limit] [offset]
* limit: number of sent campaigns to process (default: 10)
* offset: number of sent campaigns to skip before processing (default: 0)
*
* Requires DB_* env vars (from inventory-server .env) and KLAVIYO_API_KEY.
*/
@@ -52,33 +53,59 @@ async function klaviyoGet(endpoint, params = {}) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.append(k, v);
}
const res = await fetch(url.toString(), { headers });
return klaviyoFetch(url.toString());
}
async function klaviyoFetch(url) {
const res = await fetch(url, { headers });
if (!res.ok) {
const body = await res.text();
throw new Error(`Klaviyo ${res.status} on ${endpoint}: ${body}`);
throw new Error(`Klaviyo ${res.status} on ${url}: ${body}`);
}
return res.json();
}
async function getRecentCampaigns(limit) {
const data = await klaviyoGet('/campaigns', {
async function getRecentCampaigns(limit, offset = 0) {
const campaigns = [];
const messageMap = {};
let skipped = 0;
let data = await klaviyoGet('/campaigns', {
'filter': 'equals(messages.channel,"email")',
'sort': '-scheduled_at',
'include': 'campaign-messages',
});
const campaigns = (data.data || [])
.filter(c => c.attributes?.status === 'Sent')
.slice(0, limit);
const messageMap = {};
for (const inc of (data.included || [])) {
if (inc.type === 'campaign-message') {
messageMap[inc.id] = inc;
while (true) {
for (const c of (data.data || [])) {
if (c.attributes?.status === 'Sent') {
if (skipped < offset) {
skipped++;
continue;
}
campaigns.push(c);
if (campaigns.length >= limit) break;
}
}
for (const inc of (data.included || [])) {
if (inc.type === 'campaign-message') {
messageMap[inc.id] = inc;
}
}
const nextUrl = data.links?.next;
if (campaigns.length >= limit || !nextUrl) break;
const progress = skipped < offset
? `Skipped ${skipped}/${offset}...`
: `Fetched ${campaigns.length}/${limit} sent campaigns, loading next page...`;
console.log(` ${progress}`);
await new Promise(r => setTimeout(r, 200));
data = await klaviyoFetch(nextUrl);
}
return { campaigns, messageMap };
return { campaigns: campaigns.slice(0, limit), messageMap };
}
async function getTemplateHtml(messageId) {
@@ -190,12 +217,13 @@ async function insertLinks(pool, campaignId, campaignName, sentAt, links) {
async function main() {
const limit = parseInt(process.argv[2]) || 10;
const offset = parseInt(process.argv[3]) || 0;
const pool = createPool();
try {
// Fetch campaigns
console.log(`Fetching up to ${limit} recent campaigns...\n`);
const { campaigns, messageMap } = await getRecentCampaigns(limit);
console.log(`Fetching up to ${limit} recent campaigns (offset: ${offset})...\n`);
const { campaigns, messageMap } = await getRecentCampaigns(limit, offset);
console.log(`Found ${campaigns.length} sent campaigns.\n`);
let totalProducts = 0;

View File

@@ -0,0 +1,17 @@
-- Daily Deals schema for local PostgreSQL
-- Synced from production MySQL product_daily_deals + product_current_prices
CREATE TABLE IF NOT EXISTS product_daily_deals (
deal_id serial PRIMARY KEY,
deal_date date NOT NULL,
pid bigint NOT NULL,
price_id bigint NOT NULL,
-- Denormalized from product_current_prices so we don't need to sync that whole table
deal_price numeric(10,3),
created_at timestamptz DEFAULT NOW(),
CONSTRAINT fk_daily_deals_pid FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_daily_deals_date ON product_daily_deals(deal_date);
CREATE INDEX IF NOT EXISTS idx_daily_deals_pid ON product_daily_deals(pid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_deals_unique ON product_daily_deals(deal_date, pid);

View File

@@ -6,6 +6,7 @@ const importCategories = require('./import/categories');
const { importProducts } = require('./import/products');
const importOrders = require('./import/orders');
const importPurchaseOrders = require('./import/purchase-orders');
const importDailyDeals = require('./import/daily-deals');
dotenv.config({ path: path.join(__dirname, "../.env") });
@@ -14,6 +15,7 @@ const IMPORT_CATEGORIES = true;
const IMPORT_PRODUCTS = true;
const IMPORT_ORDERS = true;
const IMPORT_PURCHASE_ORDERS = true;
const IMPORT_DAILY_DEALS = true;
// Add flag for incremental updates
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
@@ -78,7 +80,8 @@ async function main() {
IMPORT_CATEGORIES,
IMPORT_PRODUCTS,
IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS
IMPORT_PURCHASE_ORDERS,
IMPORT_DAILY_DEALS
].filter(Boolean).length;
try {
@@ -126,10 +129,11 @@ async function main() {
'categories_enabled', $2::boolean,
'products_enabled', $3::boolean,
'orders_enabled', $4::boolean,
'purchase_orders_enabled', $5::boolean
'purchase_orders_enabled', $5::boolean,
'daily_deals_enabled', $6::boolean
)
) RETURNING id
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]);
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_DAILY_DEALS]);
importHistoryId = historyResult.rows[0].id;
} catch (error) {
console.error("Error creating import history record:", error);
@@ -146,7 +150,8 @@ async function main() {
categories: null,
products: null,
orders: null,
purchaseOrders: null
purchaseOrders: null,
dailyDeals: null
};
let totalRecordsAdded = 0;
@@ -224,6 +229,34 @@ async function main() {
}
}
if (IMPORT_DAILY_DEALS) {
try {
const stepStart = Date.now();
results.dailyDeals = await importDailyDeals(prodConnection, localConnection);
stepTimings.dailyDeals = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Daily deals import result:', results.dailyDeals);
if (results.dailyDeals?.status === 'error') {
console.error('Daily deals import had an error:', results.dailyDeals.error);
} else {
totalRecordsAdded += parseInt(results.dailyDeals?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.dailyDeals?.recordsUpdated || 0);
totalRecordsDeleted += parseInt(results.dailyDeals?.recordsDeleted || 0);
}
} catch (error) {
console.error('Error during daily deals import:', error);
results.dailyDeals = {
status: 'error',
error: error.message,
recordsAdded: 0,
recordsUpdated: 0
};
}
}
const endTime = Date.now();
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
@@ -241,15 +274,17 @@ async function main() {
'products_enabled', $5::boolean,
'orders_enabled', $6::boolean,
'purchase_orders_enabled', $7::boolean,
'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
'products_result', COALESCE($9::jsonb, 'null'::jsonb),
'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'total_deleted', $12::integer,
'total_skipped', $13::integer,
'step_timings', $14::jsonb
'daily_deals_enabled', $8::boolean,
'categories_result', COALESCE($9::jsonb, 'null'::jsonb),
'products_result', COALESCE($10::jsonb, 'null'::jsonb),
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
'total_deleted', $14::integer,
'total_skipped', $15::integer,
'step_timings', $16::jsonb
)
WHERE id = $15
WHERE id = $17
`, [
totalElapsedSeconds,
parseInt(totalRecordsAdded),
@@ -258,10 +293,12 @@ async function main() {
IMPORT_PRODUCTS,
IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS,
IMPORT_DAILY_DEALS,
JSON.stringify(results.categories),
JSON.stringify(results.products),
JSON.stringify(results.orders),
JSON.stringify(results.purchaseOrders),
JSON.stringify(results.dailyDeals),
totalRecordsDeleted,
totalRecordsSkipped,
JSON.stringify(stepTimings),

View File

@@ -0,0 +1,167 @@
const { outputProgress, formatElapsedTime } = require('../metrics-new/utils/progress');
/**
* Import daily deals from production MySQL to local PostgreSQL.
*
* Production has two tables:
* - product_daily_deals (deal_id, deal_date, pid, price_id)
* - product_current_prices (price_id, pid, price_each, active, ...)
*
* We join them in the prod query to denormalize the deal price, avoiding
* the need to sync the full product_current_prices table.
*
* On each sync:
* 1. Fetch deals from the last 7 days (plus today) from production
* 2. Upsert into local table
* 3. Hard delete local deals older than 7 days past their deal_date
*/
async function importDailyDeals(prodConnection, localConnection) {
outputProgress({
operation: "Starting daily deals import",
status: "running",
});
const startTime = Date.now();
try {
await localConnection.query('BEGIN');
// Fetch recent daily deals from production (MySQL 5.7, no CTEs)
// Join product_current_prices to get the actual deal price
// Only grab last 7 days + today + tomorrow (for pre-scheduled deals)
const [deals] = await prodConnection.query(`
SELECT
pdd.deal_id,
pdd.deal_date,
pdd.pid,
pdd.price_id,
pcp.price_each as deal_price
FROM product_daily_deals pdd
LEFT JOIN product_current_prices pcp ON pcp.price_id = pdd.price_id
WHERE pdd.deal_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND pdd.deal_date <= DATE_ADD(CURDATE(), INTERVAL 1 DAY)
ORDER BY pdd.deal_date DESC, pdd.pid
`);
outputProgress({
status: "running",
operation: "Daily deals import",
message: `Fetched ${deals.length} deals from production`,
elapsed: formatElapsedTime(startTime),
});
let totalInserted = 0;
let totalUpdated = 0;
if (deals.length > 0) {
// Batch upsert — filter to only PIDs that exist locally
const pids = [...new Set(deals.map(d => d.pid))];
const existingResult = await localConnection.query(
`SELECT pid FROM products WHERE pid = ANY($1)`,
[pids]
);
const existingPids = new Set(
(Array.isArray(existingResult) ? existingResult[0] : existingResult)
.rows.map(r => Number(r.pid))
);
const validDeals = deals.filter(d => existingPids.has(Number(d.pid)));
if (validDeals.length > 0) {
// Build batch upsert
const values = validDeals.flatMap(d => [
d.deal_date,
d.pid,
d.price_id,
d.deal_price ?? null,
]);
const placeholders = validDeals
.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`)
.join(',');
const upsertQuery = `
WITH upserted AS (
INSERT INTO product_daily_deals (deal_date, pid, price_id, deal_price)
VALUES ${placeholders}
ON CONFLICT (deal_date, pid) DO UPDATE SET
price_id = EXCLUDED.price_id,
deal_price = EXCLUDED.deal_price
WHERE
product_daily_deals.price_id IS DISTINCT FROM EXCLUDED.price_id OR
product_daily_deals.deal_price IS DISTINCT FROM EXCLUDED.deal_price
RETURNING
CASE WHEN xmax = 0 THEN true ELSE false END as is_insert
)
SELECT
COUNT(*) FILTER (WHERE is_insert) as inserted,
COUNT(*) FILTER (WHERE NOT is_insert) as updated
FROM upserted
`;
const result = await localConnection.query(upsertQuery, values);
const queryResult = Array.isArray(result) ? result[0] : result;
totalInserted = parseInt(queryResult.rows[0].inserted) || 0;
totalUpdated = parseInt(queryResult.rows[0].updated) || 0;
}
const skipped = deals.length - validDeals.length;
if (skipped > 0) {
console.log(`Skipped ${skipped} deals (PIDs not in local products table)`);
}
}
// Hard delete deals older than 7 days past their deal_date
const deleteResult = await localConnection.query(`
DELETE FROM product_daily_deals
WHERE deal_date < CURRENT_DATE - INTERVAL '7 days'
`);
const deletedCount = deleteResult.rowCount ??
(Array.isArray(deleteResult) ? deleteResult[0]?.rowCount : 0) ?? 0;
// Update sync status
await localConnection.query(`
INSERT INTO sync_status (table_name, last_sync_timestamp)
VALUES ('product_daily_deals', NOW())
ON CONFLICT (table_name) DO UPDATE SET
last_sync_timestamp = NOW()
`);
await localConnection.query('COMMIT');
outputProgress({
status: "complete",
operation: "Daily deals import completed",
message: `Inserted ${totalInserted}, updated ${totalUpdated}, deleted ${deletedCount} expired`,
current: totalInserted + totalUpdated,
total: totalInserted + totalUpdated,
duration: formatElapsedTime(startTime),
});
return {
status: "complete",
recordsAdded: totalInserted,
recordsUpdated: totalUpdated,
recordsDeleted: deletedCount,
totalRecords: totalInserted + totalUpdated,
};
} catch (error) {
console.error("Error importing daily deals:", error);
try {
await localConnection.query('ROLLBACK');
} catch (rollbackError) {
console.error("Error during rollback:", rollbackError);
}
outputProgress({
status: "error",
operation: "Daily deals import failed",
error: error.message,
});
throw error;
}
}
module.exports = importDailyDeals;

View File

@@ -0,0 +1,425 @@
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: created_at within 31 days, NOT preorder (mutually exclusive on prod)
// PRE-ORDER: preorder_count > 0, NOT new
// CLEARANCE: (regular_price - price) / regular_price >= 0.35, price > 0
// 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)
//
// 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 COALESCE(sales_30d, 0) > 0",
never_featured: "AND times_featured IS NULL AND line_last_featured_at IS NULL",
};
function buildScoredCTE({ forCount = false } = {}) {
// forCount=true returns minimal columns for COUNT(*)
const selectColumns = forCount ? `
p.pid,
p.created_at,
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.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.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 - 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: within 31 days of reference date, AND not on preorder
CASE
WHEN p.preorder_count > 0 THEN false
WHEN 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, 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 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, 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
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 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
-- In-stock requirement
+ CASE WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 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 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 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' });
}
});
module.exports = router;

View File

@@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup');
const importSessionsRouter = require('./routes/import-sessions');
const newsletterRouter = require('./routes/newsletter');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -132,6 +133,7 @@ async function startServer() {
app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/hts-lookup', htsLookupRouter);
app.use('/api/import-sessions', importSessionsRouter);
app.use('/api/newsletter', newsletterRouter);
// Basic health check route
app.get('/health', (req, res) => {