← back to Marketing Command Center

modules/engine/candidates.js

198 lines

// Engine candidates — pick the day's candidate products from the local dw_unified
// Postgres mirror (READ-ONLY). Feeds generate.js. Everything here degrades
// gracefully: a missing pg module, an unreachable DB, or an empty result all
// resolve to [] rather than throwing, so the daily engine never crashes the MCC.
//
// Pipeline: SQL pull (recent ACTIVE, published, imaged products) → JS filters
//   (a) leak-deny drop, (b) Kravet-family drop, (c) no-repeat-window drop,
//   (d) variety pick (prefer unseen vendor + unseen product_type) → N products.
const store = require('./store');
const config = require('./config');
const { isLeaky } = require('../../lib/leak-deny');
const { isKravet } = require('../../lib/kravet.js');

// ── Postgres (lazy; dw_unified is canonical on Kamatera, /tmp mirror locally) ──
// Mirrors modules/calendars/lib.js: env-first, DATABASE_URL wins, else /tmp
// socket to the dw_unified mirror. require('pg') is inside a try/catch so a host
// without the pg module simply yields no candidates instead of crashing mount.
let _pool = null;
let _pgUnavailable = false;
function pool() {
  if (_pool) return _pool;
  if (_pgUnavailable) return null;
  let Pool;
  try { ({ Pool } = require('pg')); }
  catch (e) { console.error('[engine candidates] pg module missing —', e.message); _pgUnavailable = true; return null; }
  try {
    _pool = process.env.DATABASE_URL
      ? new Pool({ connectionString: process.env.DATABASE_URL, max: 3 })
      : new Pool({
          host: process.env.PGHOST || '/tmp',
          database: process.env.PGDATABASE || 'dw_unified',
          user: process.env.PGUSER || process.env.USER || 'stevestudio2',
          max: 3,
        });
    _pool.on('error', (e) => console.error('[engine candidates pool]', e.message)); // log, never crash the MCC
    return _pool;
  } catch (e) {
    console.error('[engine candidates] pool init failed —', e.message);
    _pgUnavailable = true;
    return null;
  }
}

// Base query — most-recent ACTIVE, online-store-published, imaged products within
// a lookback window. The interval days are a sanitized integer interpolated into
// the SQL (pg can't parameterize an interval literal); we hard-coerce to a small
// positive integer so nothing user-derived reaches the query text.
function buildSql(lookbackDays) {
  const days = Math.min(3650, Math.max(1, parseInt(lookbackDays, 10) || 14));
  return `
    SELECT shopify_id, dw_sku, sku, variant_sku, mfr_sku,
           title, vendor, product_type, image_url, handle, tags,
           created_at_shopify
      FROM shopify_products
     WHERE status = 'ACTIVE'
       AND online_store_published = true
       AND image_url IS NOT NULL
       AND created_at_shopify >= now() - interval '${days} days'
     ORDER BY created_at_shopify DESC
     LIMIT 200`;
}

// Escalation ladder — config.lookbackDays is the PREFERENCE/floor. If the primary
// window yields nothing (recent products simply haven't landed — the newest
// published+imaged SKU can be weeks old), widen the window so the daily engine
// never silently produces zero posts. DTD verdict (2026-07-21): resilient widen.
function lookbackLadder(base) {
  const b = Math.max(1, parseInt(base, 10) || 14);
  const ladder = [b, 30, 60, 90, 180];
  // de-dupe + keep strictly increasing from the base
  return [...new Set(ladder.filter(d => d >= b))].sort((a, c) => a - c);
}

// Parse a PG text[] / CSV tags column into a clean string[] (same handling as
// modules/calendars/lib.js parseTags).
function parseTags(raw) {
  if (!raw) return [];
  if (Array.isArray(raw)) return raw.map(t => String(t).trim()).filter(Boolean);
  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);
}

// (c) Build the set of product identifiers already queued within noRepeatDays so
// we never re-feature the same product. Scans the existing queue's item.product.
function recentlyFeaturedKeys(noRepeatDays) {
  const days = Math.max(0, parseInt(noRepeatDays, 10) || 0);
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
  const keys = new Set();
  for (const it of store.load()) {
    const t = new Date(it.createdAt || `${it.date}T00:00:00`).getTime();
    if (!Number.isFinite(t) || t < cutoff) continue;
    const p = it.product || {};
    if (p.dwSku) keys.add(`sku:${String(p.dwSku).toLowerCase()}`);
    if (p.shopifyId) keys.add(`sid:${String(p.shopifyId)}`);
  }
  return keys;
}

// Resolve the customer-facing DW SKU. dw_sku is often empty on the mirror; the
// real SKU lives in sku/variant_sku carrying a '-Sample' suffix (the first variant
// is the memo sample). Prefer dw_sku, fall back to sku → variant_sku, strip the
// trailing sample suffix, and trim. Returns '' if nothing usable is present.
function resolveSku(row) {
  const raw = String(row.dw_sku || row.sku || row.variant_sku || '').trim();
  if (!raw) return '';
  return raw.replace(/[-_\s]*sample\s*$/i, '').trim();
}

function seenKeyOf(row) {
  const out = [];
  if (row.dw_sku) out.push(`sku:${String(row.dw_sku).toLowerCase()}`);
  if (row.shopify_id) out.push(`sid:${String(row.shopify_id)}`);
  return out;
}

// (d) Variety pick — greedy: at each step prefer a row whose vendor AND
// product_type are both still unseen, then unseen vendor, then unseen type, then
// anything. Preserves the recency ORDER within each preference tier.
function pickVariety(rows, n) {
  const chosen = [];
  const seenVendor = new Set();
  const seenType = new Set();
  const remaining = rows.slice();
  const vendorOf = r => String(r.vendor || '').trim().toLowerCase();
  const typeOf = r => String(r.product_type || '').trim().toLowerCase();

  while (chosen.length < n && remaining.length) {
    let idx = remaining.findIndex(r => !seenVendor.has(vendorOf(r)) && !seenType.has(typeOf(r)));
    if (idx < 0) idx = remaining.findIndex(r => !seenVendor.has(vendorOf(r)));
    if (idx < 0) idx = remaining.findIndex(r => !seenType.has(typeOf(r)));
    if (idx < 0) idx = 0;
    const [r] = remaining.splice(idx, 1);
    seenVendor.add(vendorOf(r));
    seenType.add(typeOf(r));
    chosen.push(r);
  }
  return chosen;
}

// pickCandidates() — the day's products. Never throws; [] on any failure.
async function pickCandidates() {
  const cfg = config.getConfig();
  const p = pool();
  if (!p) return []; // pg module / DB unavailable — degrade gracefully

  let rows = [];
  try {
    // Walk the lookback ladder — stop at the first window that yields rows that
    // survive the leak + Kravet + no-repeat filters.
    const noRepeat = recentlyFeaturedKeys(cfg.noRepeatDays);
    for (const days of lookbackLadder(cfg.lookbackDays)) {
      const res = await p.query(buildSql(days));
      const filtered = res.rows.filter(r => {
        // (a) leak-deny — never surface an upstream private-label name
        if (isLeaky(r.vendor) || isLeaky(r.title)) return false;
        // (b) Kravet-family — brand-controlled imagery is hard-blocked from reposting
        if (isKravet(r.vendor, r.title)) return false;
        // (c) no-repeat window — skip products already queued recently
        if (seenKeyOf(r).some(k => noRepeat.has(k))) return false;
        return true;
      });
      if (filtered.length) { rows = filtered; break; }
    }
  } catch (e) {
    console.error('[engine candidates] query failed —', e.message);
    return [];
  }

  if (!rows.length) return [];

  // (d) variety pick down to productsPerDay
  const n = Math.max(1, parseInt(cfg.productsPerDay, 10) || 3);
  const picked = pickVariety(rows, n);

  return picked.map(r => ({
    shopifyId: r.shopify_id != null ? String(r.shopify_id) : '',
    dwSku: resolveSku(r),
    mfrSku: r.mfr_sku || '',
    handle: r.handle || '',
    title: r.title || '',
    vendor: r.vendor || '',
    productType: r.product_type || '',
    imageUrl: r.image_url || '',
    productUrl: `https://designerwallcoverings.com/products/${r.handle || ''}`,
    tags: parseTags(r.tags),
  }));
}

module.exports = { pickCandidates };