diff --git a/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js b/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js index eccf643..328ae04 100644 --- a/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js +++ b/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js @@ -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; diff --git a/inventory-server/db/daily-deals-schema.sql b/inventory-server/db/daily-deals-schema.sql new file mode 100644 index 0000000..bae36f1 --- /dev/null +++ b/inventory-server/db/daily-deals-schema.sql @@ -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); diff --git a/inventory-server/scripts/import-from-prod.js b/inventory-server/scripts/import-from-prod.js index 3c3404c..2003b97 100644 --- a/inventory-server/scripts/import-from-prod.js +++ b/inventory-server/scripts/import-from-prod.js @@ -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), diff --git a/inventory-server/scripts/import/daily-deals.js b/inventory-server/scripts/import/daily-deals.js new file mode 100644 index 0000000..8bba8d5 --- /dev/null +++ b/inventory-server/scripts/import/daily-deals.js @@ -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; diff --git a/inventory-server/src/routes/newsletter.js b/inventory-server/src/routes/newsletter.js new file mode 100644 index 0000000..4c9be12 --- /dev/null +++ b/inventory-server/src/routes/newsletter.js @@ -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; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index f6847cd..2e496d5 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -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) => { diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index d385b5f..69399db 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -28,6 +28,7 @@ const Categories = lazy(() => import('./pages/Categories')); const Brands = lazy(() => import('./pages/Brands')); const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders')); const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard')); +const Newsletter = lazy(() => import('./pages/Newsletter')); // 2. Dashboard app - separate chunk const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -215,6 +216,15 @@ function App() { } /> + {/* Newsletter recommendations */} + + }> + + + + } /> + {/* Dashboard app - separate chunk */} diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 56e336a..b991087 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -14,6 +14,7 @@ import { FileSearch, ShoppingCart, FilePenLine, + Mail, } from "lucide-react"; import { IconCrystalBall } from "@tabler/icons-react"; import { @@ -120,6 +121,12 @@ const toolsItems = [ icon: FilePenLine, url: "/product-editor", permission: "access:product_editor" + }, + { + title: "Newsletter", + icon: Mail, + url: "/newsletter", + permission: "access:newsletter" } ]; diff --git a/inventory/src/components/newsletter/NewsletterStats.tsx b/inventory/src/components/newsletter/NewsletterStats.tsx new file mode 100644 index 0000000..87fa0aa --- /dev/null +++ b/inventory/src/components/newsletter/NewsletterStats.tsx @@ -0,0 +1,92 @@ +import { useQuery } from "@tanstack/react-query" +import { Card, CardContent } from "@/components/ui/card" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Sparkles, RotateCcw, TrendingUp, Clock, CalendarClock, EyeOff, Info } from "lucide-react" +import config from "@/config" + +interface Stats { + unfeatured_new: number + back_in_stock_ready: number + high_score_available: number + last_campaign_date: string + avg_days_since_featured: number + never_featured: number +} + +export function NewsletterStats() { + const { data } = useQuery({ + queryKey: ["newsletter-stats"], + queryFn: async () => { + const res = await fetch(`${config.apiUrl}/newsletter/stats`) + if (!res.ok) throw new Error("Failed to fetch stats") + return res.json() + }, + }) + + if (!data) return null + + const stats = [ + { + label: "Unfeatured New", + value: data.unfeatured_new?.toLocaleString() ?? "—", + icon: Sparkles, + tooltip: "New products (< 31 days) that haven't appeared in any newsletter yet. If this is high, prioritize new arrivals today.", + }, + { + label: "Back in Stock Ready", + value: data.back_in_stock_ready?.toLocaleString() ?? "—", + icon: RotateCcw, + tooltip: "Restocked products that haven't been featured since their restock date. These are time-sensitive — customers are waiting and stock could sell through.", + }, + { + label: "High Score Available", + value: data.high_score_available?.toLocaleString() ?? "—", + icon: TrendingUp, + tooltip: "Products scoring 40+ that haven't been featured in the last 2 days. Shows how deep your bench of strong picks is for today's send.", + }, + { + label: "Last Campaign", + value: data.last_campaign_date ? new Date(data.last_campaign_date).toLocaleDateString() : "—", + icon: Clock, + tooltip: "Date of the most recent synced campaign. Useful to confirm your Klaviyo sync is up to date.", + }, + { + label: "Avg Days Since Featured", + value: data.avg_days_since_featured ?? "—", + icon: CalendarClock, + tooltip: "Average days since in-stock products were last featured. If this is climbing, you're not cycling through enough of your catalog.", + }, + { + label: "Never Featured", + value: data.never_featured?.toLocaleString() ?? "—", + icon: EyeOff, + tooltip: "Visible, in-stock products that have never appeared in any newsletter. Your untapped opportunity pool.", + }, + ] + + return ( + +
+ {stats.map((s) => ( + + +
+ + {s.label} + + + + + +

{s.tooltip}

+
+
+
+

{s.value}

+
+
+ ))} +
+
+ ) +} diff --git a/inventory/src/components/newsletter/RecommendationTable.tsx b/inventory/src/components/newsletter/RecommendationTable.tsx new file mode 100644 index 0000000..d4584cf --- /dev/null +++ b/inventory/src/components/newsletter/RecommendationTable.tsx @@ -0,0 +1,307 @@ +import { useQuery } from "@tanstack/react-query" +import { useState } from "react" +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, +} from "@/components/ui/tooltip" +import { ChevronLeft, ChevronRight, ExternalLink, Layers } from "lucide-react" +import config from "@/config" + +interface Product { + pid: number + title: string + sku: string + brand: string + vendor: string + price: number + regular_price: number + image: string + permalink: string + stock_quantity: number + preorder_count: number + sales_7d: number + sales_30d: number + revenue_30d: number + current_stock: number + on_order_qty: number + abc_class: string + line: string | null + times_featured: number | null + last_featured_at: string | null + days_since_featured: number | null + line_products_featured: number | null + line_total_features: number | null + line_last_featured_at: string | null + line_products_featured_30d: number | null + line_product_count: number | null + line_days_since_featured: number | null + effective_last_featured: string | null + effective_days_since_featured: number | null + age_days: number + score: number + is_new: boolean + is_preorder: boolean + is_clearance: boolean + discount_pct: number + is_low_stock: boolean + is_back_in_stock: boolean + is_daily_deal: boolean + deal_price: number | null + baskets: number + notifies: number +} + +interface RecommendationResponse { + products: Product[] + pagination: { + total: number + pages: number + currentPage: number + limit: number + } +} + +interface RecommendationTableProps { + category: string +} + +function FeaturedCell({ p }: { p: Product }) { + const directCount = p.times_featured ?? 0 + const hasLineHistory = p.line && p.line_last_featured_at && !p.last_featured_at + + return ( +
+ {directCount}× + {hasLineHistory && ( + + + + + + +

Line: {p.line}

+

+ {p.line_products_featured} of {p.line_product_count} products featured + ({p.line_products_featured_30d} in last 30d) +

+

+ Line last featured {p.line_days_since_featured}d ago +

+
+
+
+ )} +
+ ) +} + +function LastFeaturedCell({ p }: { p: Product }) { + if (p.last_featured_at) { + return {p.days_since_featured === 0 ? "Today" : `${p.days_since_featured}d ago`} + } + if (p.line_last_featured_at) { + const lineLabel = p.line_days_since_featured === 0 ? "Today" : `${p.line_days_since_featured}d ago` + return ( + + + + + {lineLabel} + + +

Product never featured directly.

+

Line "{p.line}" was last featured {lineLabel.toLowerCase()}.

+
+
+
+ ) + } + return Never +} + +export function RecommendationTable({ category }: RecommendationTableProps) { + const [page, setPage] = useState(1) + const limit = 50 + + const { data, isLoading } = useQuery({ + queryKey: ["newsletter-recommendations", category, page], + queryFn: async () => { + const res = await fetch( + `${config.apiUrl}/newsletter/recommendations?category=${category}&page=${page}&limit=${limit}` + ) + if (!res.ok) throw new Error("Failed to fetch recommendations") + return res.json() + }, + }) + + if (isLoading) { + return
Loading recommendations…
+ } + + const products = data?.products ?? [] + const pagination = data?.pagination + + return ( +
+
+ + + + Score + Image + Product + Brand + Price + Stock + 7d Sales + 30d Sales + Tags + Featured + Last Featured + + + + + {products.length === 0 ? ( + + + No products found for this category + + + ) : ( + products.map((p) => ( + + + = 40 ? "text-green-600" : + p.score >= 20 ? "text-yellow-600" : + "text-muted-foreground" + }`}> + {p.score} + + + + {p.image ? ( + + ) : ( +
+ )} + + +
+

{p.title}

+

{p.sku}

+ {p.line && ( +

{p.line}

+ )} +
+
+ {p.brand} + +
+ {p.is_daily_deal && p.deal_price ? ( + <> + ${Number(p.deal_price).toFixed(2)} +
+ + ${Number(p.price).toFixed(2)} + + + -{Math.round((1 - Number(p.deal_price) / Number(p.price)) * 100)}% + +
+ + ) : ( + <> + ${Number(p.price).toFixed(2)} + {p.is_clearance && ( +
+ + ${Number(p.regular_price).toFixed(2)} + + -{p.discount_pct}% +
+ )} + + )} +
+
+ + + {p.current_stock ?? 0} + + {p.on_order_qty > 0 && ( + (+{p.on_order_qty}) + )} + + {p.sales_7d ?? 0} + {p.sales_30d ?? 0} + +
+ {p.is_new && New} + {p.is_preorder && Pre-Order} + {p.is_clearance && Clearance} + {p.is_daily_deal && Deal} + {p.is_back_in_stock && Back in Stock} + {p.is_low_stock && Low Stock} + {(p.baskets > 0 || p.notifies > 0) && ( + + {p.baskets > 0 ? `${p.baskets} 🛒` : ""}{p.baskets > 0 && p.notifies > 0 ? " " : ""}{p.notifies > 0 ? `${p.notifies} 🔔` : ""} + + )} +
+
+ + + + + + + + {p.permalink && ( + + + + )} + + + )) + )} + +
+
+ + {pagination && pagination.pages > 1 && ( +
+

+ {pagination.total.toLocaleString()} products +

+
+ + + Page {page} of {pagination.pages} + + +
+
+ )} +
+ ) +} diff --git a/inventory/src/pages/Newsletter.tsx b/inventory/src/pages/Newsletter.tsx new file mode 100644 index 0000000..68cb41f --- /dev/null +++ b/inventory/src/pages/Newsletter.tsx @@ -0,0 +1,48 @@ +import { useState } from "react" + +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { NewsletterStats } from "@/components/newsletter/NewsletterStats" +import { RecommendationTable } from "@/components/newsletter/RecommendationTable" + +const CATEGORIES = [ + { value: "all", label: "All Recommendations" }, + { value: "new", label: "New Products" }, + { value: "preorder", label: "Pre-Orders" }, + { value: "bestsellers", label: "Bestsellers" }, + { value: "back_in_stock", label: "Back in Stock" }, + { value: "clearance", label: "Clearance" }, + { value: "daily_deals", label: "Daily Deals" }, + { value: "never_featured", label: "Never Featured" }, +] + +export function Newsletter() { + const [category, setCategory] = useState("all") + + return ( +
+
+

Newsletter Recommendations

+
+ + + + + + {CATEGORIES.map((c) => ( + + {c.label} + + ))} + + + {CATEGORIES.map((c) => ( + + + + ))} + +
+ ) +} + +export default Newsletter