← back to Marketing Command Center
modules/calendars/lib.js
341 lines
// lib.js — the data engine for the unified "All Calendars" view.
//
// THREE layers, one array:
// • activation — every staged SKU on its DURABLE go-live date+time, projected
// faithfully from the real drain (rr order over bulk_fivefield_worklist,
// 500/day across hourly :10 slots — ported from dw-activation-calendar/
// scripts/generate-schedule.js), then ENRICHED from shopify_products so each
// SKU carries material/price/color/style/book/hue for the left-panel toggles.
// • marketing + campaign — the MCC's own curated dates + campaign schedule
// (read via ../calendar/store.js — one source of truth, no duplication).
// • google — optional/fail-soft overlay (returns [] until OAuth is wired).
//
// Safe by design: dw_unified is READ-only here; the enriched projection is cached
// to data/calendars-activation.json (never enrich 20k rows per request); drag
// reschedules persist to data/calendars-overrides.json and survive regeneration.
const fs = require('fs');
const path = require('path');
const store = require('../calendar/store');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const CACHE_FILE = path.join(DATA_DIR, 'calendars-activation.json');
const OVERRIDES_FILE = path.join(DATA_DIR, 'calendars-overrides.json');
const PER_DAY = parseInt(process.env.SCHED_PER_DAY, 10) || 500; // budget.cjs backlog hard cap
const SLOT_MIN = 10; // drain fires at HH:10
const STALE_MS = parseInt(process.env.SCHED_STALE_MS, 10) || 10 * 60 * 1000;
const hourCap = (h) => (h >= 21 ? 150 : 30); // drain.sh SLOT_MAX (reclaim 150 in 21-23)
// ── Postgres (lazy; dw_unified is canonical on Kamatera, /tmp mirror locally) ──
let _pool = null;
function pool() {
if (_pool) return _pool;
const { Pool } = require('pg');
_pool = process.env.DATABASE_URL
? new Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
: new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'dw_unified',
user: process.env.PGUSER || process.env.USER || 'stevestudio2', max: 4 });
_pool.on('error', (e) => console.error('[calendars pool]', e.message)); // log, but never crash the MCC
return _pool;
}
// The executor's EXACT rr ordering (bulk-fivefield-exec.py), minus reprice-zero.
// dw_sku is NOT unique/present on ~3.3k build-roll rows → shopify_id is identity.
const RR_SQL = `
SELECT rr, shopify_id, dw_sku, mfr_sku, vendor, fix_type FROM (
SELECT (row_number() OVER (PARTITION BY vendor ORDER BY (fix_type='add-sample') DESC, dw_sku))*100000
+ dense_rank() OVER (ORDER BY vendor) AS rr,
shopify_id, dw_sku, mfr_sku, vendor, fix_type
FROM bulk_fivefield_worklist
WHERE status='pending' AND fix_type <> 'reprice-zero'
) t
ORDER BY rr, vendor, dw_sku`;
// Products that already LAUNCHED (live on the storefront) in the last 30 days,
// placed on the calendar on their real launch date — the past-side companion to
// the future-side activation layer. Read-only, bounded by created_at_shopify.
const LAUNCHED_DAYS = 30;
const LAUNCHED_SQL = `
SELECT shopify_id, vendor, product_type, retail_price, min_variant_price, price,
tags, metafields, pattern_name, title, image_url, handle, dw_sku, mfr_sku,
created_at_shopify
FROM shopify_products
WHERE status = 'ACTIVE' AND online_store_published = true
AND created_at_shopify IS NOT NULL
AND created_at_shopify >= now() - ($1 || ' days')::interval
ORDER BY created_at_shopify DESC`;
// which hourly :10 slot a within-day position lands in (0-based pos within the day)
function slotHourForPos(pos) {
let cum = 0;
for (let h = 0; h < 24; h++) { const cap = hourCap(h); if (pos < cum + cap) return h; cum += cap; }
return 23;
}
// ── enrichment field normalizers ────────────────────────────────────────────
const pad = (n) => String(n).padStart(2, '0');
function parseTags(raw) { // handles PG-array text ({"a","b"}) and CSV
if (!raw) return [];
let s = String(raw).trim();
if (s.startsWith('{') && s.endsWith('}')) s = s.slice(1, -1);
const out = []; let cur = '', inq = false;
for (const ch of s) {
if (ch === '"') { inq = !inq; continue; }
if (ch === ',' && !inq) { out.push(cur); cur = ''; continue; }
cur += ch;
}
if (cur) out.push(cur);
return out.map(t => t.trim()).filter(Boolean);
}
function tagVal(tags, ...prefixes) { // "Color: Rattan" / "color:White" → value
for (const t of tags) {
const i = t.indexOf(':'); if (i < 0) continue;
if (prefixes.includes(t.slice(0, i).trim().toLowerCase())) return t.slice(i + 1).trim();
}
return '';
}
function mfVal(metafields, key) { // metafields.global.<key>.value
try { return String(metafields?.global?.[key]?.value ?? '').trim(); } catch { return ''; }
}
function priceBand(p) {
if (p == null || isNaN(p)) return 'Unpriced';
if (p < 50) return '$0–50'; if (p < 100) return '$50–100'; if (p < 200) return '$100–200';
if (p < 500) return '$200–500'; if (p < 1000) return '$500–1,000'; return '$1,000+';
}
// Vendors that must NEVER surface on any front-end surface. Schumacher is
// internal-only (see the schumacher-internal-only standing rule) — it is filtered
// out of BOTH calendar layers at the source so a cache regeneration can't
// reintroduce it. Matched case-insensitively against the resolved vendor.
const HIDDEN_VENDORS = new Set(['schumacher']);
const isHiddenVendor = (v) => HIDDEN_VENDORS.has(String(v || '').trim().toLowerCase());
// Map a shopify_products row → the common filterable fields (shared by the
// activation layer's enrichment join AND the launched layer's direct rows).
function fieldsFrom(e) {
const tags = parseTags(e.tags);
const price = e.retail_price ?? e.min_variant_price ?? e.price ?? null;
return {
material: e.product_type || mfVal(e.metafields, 'Type1') || '',
price: price != null ? Number(price) : null,
priceBand: priceBand(price != null ? Number(price) : null),
color: tagVal(tags, 'color', 'colour') || '',
style: tagVal(tags, 'style') || mfVal(e.metafields, 'Style2') || '',
book: tagVal(tags, 'collection') || e.pattern_name || mfVal(e.metafields, 'Series') || '',
hue: tagVal(tags, 'hue') || '',
title: e.title || '', image_url: e.image_url || '', handle: e.handle || '',
};
}
// ── build the enriched activation layer (+ live launched layer) ──────────────
// The two layers have DECOUPLED failure domains. The launched layer reads only
// shopify_products (present on every host), so it stays LIVE even where the
// drain worklist bulk_fivefield_worklist is absent (e.g. Kamatera, where the
// worklist is Mac-local). In that case the activation layer is served from the
// last good snapshot while launched is recomputed live — a missing worklist no
// longer sinks the launched layer (its own inner try/catch contains it).
async function computeActivation() {
const c = await pool().connect();
let rows = null, enrich = {}, launchedRows = [];
let activationLive = false, activationErr = null;
try {
try {
rows = (await c.query(RR_SQL)).rows;
// enrich in chunks — one round-trip per 5k shopify_ids
const ids = [...new Set(rows.map(r => r.shopify_id).filter(Boolean))];
for (let i = 0; i < ids.length; i += 5000) {
const chunk = ids.slice(i, i + 5000);
const q = await c.query(
`SELECT shopify_id, vendor, product_type, retail_price, min_variant_price, price,
tags, metafields, pattern_name, title, image_url, handle
FROM shopify_products WHERE shopify_id = ANY($1::text[])`, [chunk]);
for (const r of q.rows) enrich[r.shopify_id] = r;
}
activationLive = true;
} catch (e) {
activationErr = e.message; // worklist absent on this host → keep snapshot activation
rows = null;
}
// launched layer is INDEPENDENT of the worklist — always recompute live
launchedRows = (await c.query(LAUNCHED_SQL, [String(LAUNCHED_DAYS)])).rows;
} finally { c.release(); }
// launched layer — one item per already-live product, on its launch date (LIVE)
const launched = launchedRows.filter(r => !isHiddenVendor(r.vendor)).map(r => {
const d = new Date(r.created_at_shopify);
return {
key: r.shopify_id, layer: 'launched',
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
launched_at: (r.created_at_shopify instanceof Date ? r.created_at_shopify.toISOString() : String(r.created_at_shopify)),
vendor: r.vendor || '', dw_sku: r.dw_sku || '', mfr_sku: r.mfr_sku || '',
...fieldsFrom(r),
shopify_id: r.shopify_id,
};
});
// activation layer — project LIVE if the worklist was readable, else reuse the
// last good snapshot's activation items (the intended source where it's absent)
let items, total, start_date, end_date;
if (activationLive && rows) {
const overrides = store.readJson(OVERRIDES_FILE, {}) || {};
const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() + 1);
total = rows.length;
items = new Array(total);
for (let seq = 0; seq < total; seq++) {
const r = rows[seq];
const day = Math.floor(seq / PER_DAY), pos = seq % PER_DAY, hour = slotHourForPos(pos);
let dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + day, hour, SLOT_MIN, 0, 0);
let slot = hour;
// manual drag override wins (keyed by shopify_id or dw_sku)
const ov = overrides[r.shopify_id] || (r.dw_sku && overrides[r.dw_sku]);
if (ov && ov.go_live_date) {
if (ov.slot_hour != null) slot = ov.slot_hour;
const [Y, M, D] = ov.go_live_date.split('-').map(Number);
dt = new Date(Y, M - 1, D, slot, SLOT_MIN, 0, 0);
}
const e = enrich[r.shopify_id] || {};
items[seq] = {
key: r.shopify_id || r.dw_sku,
layer: 'activation',
date: `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`,
go_live_at: dt.toISOString(),
slot_hour: slot,
vendor: e.vendor || r.vendor || '', // prefer sanitized storefront vendor
dw_sku: r.dw_sku || '', mfr_sku: r.mfr_sku || '', fix_type: r.fix_type,
...fieldsFrom(e),
shopify_id: r.shopify_id || '',
};
}
// drop internal-only vendors (e.g. Schumacher) AFTER date projection so the
// remaining SKUs keep their real go-live slots (never resequenced by a regen)
items = items.filter(it => !isHiddenVendor(it.vendor));
total = items.length;
start_date = items[0]?.date || null;
end_date = items[total - 1]?.date || null;
} else {
const disk = store.readJson(CACHE_FILE, null) || {};
items = (disk.items || []).filter(it => !isHiddenVendor(it.vendor));
total = items.length;
start_date = items[0]?.date || disk.start_date || null;
end_date = items[total - 1]?.date || disk.end_date || null;
}
const out = {
generated_at: new Date().toISOString(),
total, per_day: PER_DAY, start_date, end_date,
items,
launched, // already-live products (last 30 days) — LIVE
launched_count: launched.length,
activation_live: activationLive, // false where worklist absent (activation from snapshot)
launched_live: true, // launched always recomputes live from shopify_products
...(activationErr ? { activation_error: activationErr } : {}),
};
store.writeJson(CACHE_FILE, out);
return out;
}
let _mem = null; // { t, data }
async function getActivation(force) {
if (!force && _mem && Date.now() - _mem.t < STALE_MS) return _mem.data;
if (!force) {
const disk = store.readJson(CACHE_FILE, null);
if (disk && disk.generated_at && Date.now() - new Date(disk.generated_at).getTime() < STALE_MS) {
_mem = { t: Date.now(), data: disk }; return disk;
}
}
try {
const data = await computeActivation();
_mem = { t: Date.now(), data };
return data;
} catch (e) {
// fail-soft: keep the MCC alive, surface the reason to the panel banner
const disk = store.readJson(CACHE_FILE, null);
if (disk) return { ...disk, stale: true, error: e.message };
return { generated_at: new Date().toISOString(), total: 0, items: [], mock: true, error: e.message };
}
}
// ── overlay layers (marketing dates + campaigns + google stub) ───────────────
function overlays(startISO, endISO) {
const out = [];
// marketing curated dates — walk each month in the window
const s = new Date(startISO + 'T00:00:00'), e = new Date(endISO + 'T00:00:00');
let y = s.getFullYear(), m = s.getMonth() + 1;
while (y < e.getFullYear() || (y === e.getFullYear() && m <= e.getMonth() + 1)) {
for (const d of store.datesForMonth(y, m)) {
if (d.date >= startISO && d.date <= endISO) {
out.push({ key: 'mk_' + d.date + '_' + d.title, layer: 'marketing', date: d.date,
title: d.title, style: d.type, suggestion: d.suggestion, vendor: '', material: '' });
}
}
if (++m > 12) { m = 1; y++; }
}
// campaigns
for (const c of store.loadSchedule()) {
if (c.date >= startISO && c.date <= endISO) {
out.push({ key: c.id, layer: 'campaign', date: c.date, title: c.title,
channel: c.channel, status: c.status, notes: c.notes || '', vendor: '', material: '' });
}
}
// google — fail-soft stub until OAuth is wired (Steve-gated)
out.push(...googleEvents(startISO, endISO));
return out;
}
function googleEvents(startISO, endISO) { return []; } // stub until OAuth is wired (Steve-gated)
// ── the unified feed the panel consumes ──────────────────────────────────────
async function buildEvents(force) {
const act = await getActivation(force);
const today = new Date();
const todayISO = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
const winStart = (() => { const d = new Date(today); d.setMonth(d.getMonth() - 1); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`; })();
const winEnd = (() => {
const far = new Date(today); far.setMonth(far.getMonth() + 13);
const cand = `${far.getFullYear()}-${pad(far.getMonth() + 1)}-${pad(new Date(far.getFullYear(), far.getMonth() + 1, 0).getDate())}`;
return act.end_date && act.end_date > cand ? act.end_date : cand;
})();
const over = overlays(winStart, winEnd);
const items = [...(act.items || []), ...(act.launched || []), ...over];
const counts = items.reduce((a, it) => { a[it.layer] = (a[it.layer] || 0) + 1; return a; }, {});
return {
generated_at: act.generated_at,
stale: act.stale || false, mock: act.mock || false, error: act.error || null,
activation_live: act.activation_live !== false, // false only where the worklist is absent (activation from snapshot)
launched_live: act.launched_live || false, // launched recomputed live from shopify_products
activation_error: act.activation_error || null, // diagnostic only — NOT surfaced as the top-level error banner
activation: { total: act.total || 0, start: act.start_date, end: act.end_date, per_day: act.per_day || PER_DAY },
window: { start: winStart, end: winEnd, today: todayISO },
counts, items,
};
}
// ── drag reschedule — persist + patch in-place (no 20k recompute per drag) ────
function applyMove({ key, to_date, layer }) {
if (!key || !to_date || !/^\d{4}-\d{2}-\d{2}$/.test(to_date)) return { ok: false, error: 'key and valid to_date required' };
if (layer === 'campaign') {
const list = store.loadSchedule();
const c = list.find(x => x.id === key);
if (!c) return { ok: false, error: 'campaign not found' };
const r = store.upsertCampaign({ ...c, date: to_date });
return r.ok ? { ok: true, key, to_date, layer } : r;
}
// activation (default): record override + patch the cached item so the move shows immediately
const ov = store.readJson(OVERRIDES_FILE, {}) || {};
ov[key] = { go_live_date: to_date };
store.writeJson(OVERRIDES_FILE, ov);
const cache = store.readJson(CACHE_FILE, null);
if (cache && cache.items) {
const it = cache.items.find(x => x.key === key || x.shopify_id === key || x.dw_sku === key);
if (it) {
const [Y, M, D] = to_date.split('-').map(Number);
const dt = new Date(Y, M - 1, D, it.slot_hour ?? 10, SLOT_MIN, 0, 0);
it.date = to_date; it.go_live_at = dt.toISOString();
store.writeJson(CACHE_FILE, cache);
_mem = { t: Date.now(), data: cache };
}
}
return { ok: true, key, to_date, layer: layer || 'activation' };
}
module.exports = { buildEvents, applyMove, getActivation };