← back to Marketing Command Center

modules/browse-abandon/index.js

532 lines

// browse-abandon — Browse Abandonment drip module for the Marketing Command Center.
//
// A designer (or trade contact) loads a product page on the DW store but never
// asks for a memo sample. After a quiet window we stage a small drip:
//   Day 1   — storytelling email (the pattern, the maker, the collection lineage)
//   Day 14  — room-setting email (the same product photographed in situ)
//   Day 30  — suppress (move to lapsed cohort; stop the drip)
//
// PLANNING ONLY. Nothing here ever fires a real email. The /events endpoint
// shows what WOULD fire today + over the next 7 days; the /fire/:id endpoint is
// a dry-run renderer that returns the proposed subject + body for inspection.
//
// State lives in two files:
//   data/browse-abandon-config.json — drip steps + the "no-sample threshold"
//   data/browse-abandon-events.json — mock browse events (no real shopper data)
//
// External integrations (Shopify event stream, Constant Contact send) are not
// wired here — Steve gates those separately. The mock event seed gives the UI
// realistic-shaped data so the dashboard works out of the box.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const brand = require('../../lib/brand.js');

const CONFIG_FILE = path.join(__dirname, '..', '..', 'data', 'browse-abandon-config.json');
const EVENTS_FILE = path.join(__dirname, '..', '..', 'data', 'browse-abandon-events.json');

// ── default drip config ─────────────────────────────────────────────────────
// Three canonical steps. Steve can edit / disable / reorder via PUT /config.
// noSampleThresholdDays = the silence window before the drip evaluates the
// contact (so a designer who requested a sample within 24h never enters).
function defaultConfig() {
  return {
    enabled: true,
    noSampleThresholdDays: 1,
    dailySendCap: 250,
    steps: [
      {
        id: 'step_day1_storytelling',
        dayOffset: 1,
        kind: 'email',
        enabled: true,
        title: 'Day 1 — Storytelling',
        subject: 'The story behind {pattern_name}',
        bodyTemplate:
          'Hello {first_name},\n\n' +
          'You spent a moment with {pattern_name} from our {collection} edit — and we wanted to share where it comes from. ' +
          '{vendor} has been hand-finishing this {product_type} for decades, and every roll carries the irregularities that ' +
          'separate a true wallcovering from a printed substitute.\n\n' +
          'If it stayed with you, the next move is a memo sample. Hold it against your scheme; the gold, the cream, the ink ' +
          'all read differently in your light.\n\n' +
          'Request a memo: {memo_url}\n\n' +
          'Quietly,\nThe Designer Wallcoverings atelier',
        cta: 'Request memo samples',
        suppressIfSampleRequested: true,
      },
      {
        id: 'step_day14_room',
        dayOffset: 14,
        kind: 'email',
        enabled: true,
        title: 'Day 14 — Room setting',
        subject: '{pattern_name} — see it in a room',
        bodyTemplate:
          'Hello {first_name},\n\n' +
          '{pattern_name} reads one way as a flat scan and another way on a wall. We staged a room-setting render so ' +
          'you can see the scale, the drop, and the way the {color_name} sits against trim.\n\n' +
          'View the room setting: {product_url}\n\n' +
          'A memo box ships free to your trade address — the swatches let you confirm the field colour under your client\'s light.\n\n' +
          'Request a memo: {memo_url}\n\n' +
          'With care,\nThe Designer Wallcoverings atelier',
        cta: 'Explore the collection',
        suppressIfSampleRequested: true,
      },
      {
        id: 'step_day30_suppress',
        dayOffset: 30,
        kind: 'suppress',
        enabled: true,
        title: 'Day 30 — Suppress',
        suppressList: 'browse-abandon-lapsed',
        note:
          'Move the contact to the "browse-abandon-lapsed" cohort, stop the drip, and roll them into the quarterly ' +
          'editorial cadence instead. No email fires on Day 30.',
      },
    ],
    updatedAt: new Date().toISOString(),
  };
}

// ── default mock events ─────────────────────────────────────────────────────
// A spread of realistic-shaped browse events: trade designers + retail, across
// the canonical DW product lines (grasscloth, silk, cork, flocked, glass bead),
// each browsed N days ago so the dashboard shows entries at every drip stage.
const MOCK_PEOPLE = [
  { email: 'audrey.bell@studiobell.com',       firstName: 'Audrey',  segment: 'Trade & Designers — engaged' },
  { email: 'marcus@hauserarch.com',            firstName: 'Marcus',  segment: 'Trade & Designers — engaged' },
  { email: 'priya@vermillionatelier.com',      firstName: 'Priya',   segment: 'High-intent — clicked memo CTA' },
  { email: 'eli.kane@kanedesign.studio',       firstName: 'Eli',     segment: 'Trade & Designers — engaged' },
  { email: 'naomi.s@hospitalitypartnersgroup.com', firstName: 'Naomi', segment: 'Hospitality / Commercial spec' },
  { email: 'rhianna@laurelandfern.co',         firstName: 'Rhianna', segment: 'Retail — lapsed 90d' },
  { email: 'cyrus@morrowinteriors.com',        firstName: 'Cyrus',   segment: 'Trade & Designers — engaged' },
  { email: 'wren.day@daydesignhouse.com',      firstName: 'Wren',    segment: 'High-intent — clicked memo CTA' },
  { email: 'imogen@borealis-design.co.uk',     firstName: 'Imogen',  segment: 'Trade & Designers — engaged' },
  { email: 'tobias@northernlightstudio.com',   firstName: 'Tobias',  segment: 'Hospitality / Commercial spec' },
  { email: 'sasha@sashalindersinteriors.com',  firstName: 'Sasha',   segment: 'Trade & Designers — engaged' },
  { email: 'leo.b@leonardobruzzese.it',        firstName: 'Leo',     segment: 'High-intent — clicked memo CTA' },
];

const MOCK_PRODUCTS = [
  { sku: 'DWBR-7821', pattern: 'Cliveden Grasscloth',   color: 'Ink Slate',    productType: 'Wallcovering', vendor: 'Brewster',     collection: 'Brewster Naturals', line: 'grasscloth' },
  { sku: 'DWKK-4490', pattern: 'Hampshire Silk',        color: 'Antique Cream', productType: 'Wallcovering', vendor: 'Kravet',       collection: 'Heritage Silk',     line: 'silk' },
  { sku: 'DWAR-1208', pattern: 'Estoril Cork',          color: 'Mineral Sage', productType: 'Wallcovering', vendor: 'Arte',         collection: 'Mediterranean',     line: 'cork' },
  { sku: 'DWMR-3361', pattern: 'Aurora Flock',          color: 'Carmine',      productType: 'Wallcovering', vendor: 'Maya Romanoff',collection: 'Aurora',            line: 'flocked' },
  { sku: 'DWPR-9904', pattern: 'Coral Gables Vinyl',    color: 'Surf',         productType: 'Wallcovering', vendor: 'Phillipe Romano', collection: 'Hollywood Vinyls Vol. 1', line: 'vinyl' },
  { sku: 'DWGB-1145', pattern: 'Beaded Constellation',  color: 'Champagne',    productType: 'Wallcovering', vendor: 'Glass Bead',   collection: 'Constellation',     line: 'glass bead' },
  { sku: 'DWPI-2003', pattern: 'Empire Linen',          color: 'Bone',         productType: 'Wallcovering', vendor: 'Phillip Jeffries', collection: 'Empire',         line: 'linen' },
  { sku: 'DWSC-6677', pattern: 'Schiaparelli Mural',    color: 'Garden',       productType: 'Mural',        vendor: 'Schumacher',   collection: 'Couture Murals',    line: 'mural' },
];

function seedMockEvents(now = Date.now()) {
  // Spread browse events across day-windows so every drip stage is visible.
  // browsedDaysAgo controls which drip step the event has reached.
  const pattern = [
    { browsedDaysAgo: 0,  sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 1,  sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 1,  sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 2,  sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 5,  sampleRequestedDaysAgo: 4 },     // requested → suppressed
    { browsedDaysAgo: 7,  sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 14, sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 15, sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 21, sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 28, sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 30, sampleRequestedDaysAgo: null },
    { browsedDaysAgo: 33, sampleRequestedDaysAgo: null },
  ];
  const day = 24 * 60 * 60 * 1000;
  return pattern.map((p, i) => {
    const person  = MOCK_PEOPLE[i % MOCK_PEOPLE.length];
    const product = MOCK_PRODUCTS[i % MOCK_PRODUCTS.length];
    const browsedAt = new Date(now - p.browsedDaysAgo * day).toISOString();
    const sampleRequestedAt = p.sampleRequestedDaysAgo != null
      ? new Date(now - p.sampleRequestedDaysAgo * day).toISOString()
      : null;
    return {
      id: 'evt_' + crypto.randomBytes(4).toString('hex'),
      contactEmail: person.email,
      contactFirstName: person.firstName,
      segment: person.segment,
      productSku: product.sku,
      productPattern: product.pattern,
      productColor: product.color,
      productType: product.productType,
      vendor: product.vendor,
      collection: product.collection,
      productLine: product.line,
      productUrl: `https://designerwallcoverings.com/products/${product.sku.toLowerCase()}`,
      memoUrl: `https://designerwallcoverings.com/memos/request?sku=${product.sku}`,
      browsedAt,
      sampleRequestedAt,
      mock: true,
    };
  });
}

// ── persistence ─────────────────────────────────────────────────────────────
function readJson(file, fallback) {
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
  catch { return fallback; }
}
function writeJson(file, val) {
  try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
  fs.writeFileSync(file, JSON.stringify(val, null, 2));
}

function readConfig() {
  const v = readJson(CONFIG_FILE, null);
  if (v && typeof v === 'object') return v;
  const def = defaultConfig();
  writeJson(CONFIG_FILE, def);
  return def;
}
function writeConfig(cfg) { writeJson(CONFIG_FILE, cfg); }

function readEvents() {
  const v = readJson(EVENTS_FILE, null);
  if (Array.isArray(v) && v.length) return v;
  const seeded = seedMockEvents();
  writeJson(EVENTS_FILE, seeded);
  return seeded;
}
function writeEvents(rows) { writeJson(EVENTS_FILE, rows); }

// ── validation ──────────────────────────────────────────────────────────────
const STEP_KINDS = ['email', 'suppress'];

function validateConfig(body) {
  const errs = [];
  if (!body || typeof body !== 'object') return ['body must be an object'];
  if (body.enabled !== undefined && typeof body.enabled !== 'boolean') errs.push('enabled must be boolean');
  if (body.noSampleThresholdDays !== undefined) {
    const n = Number(body.noSampleThresholdDays);
    if (!Number.isFinite(n) || n < 0 || n > 90) errs.push('noSampleThresholdDays must be a number between 0 and 90');
  }
  if (body.dailySendCap !== undefined) {
    const n = Number(body.dailySendCap);
    if (!Number.isFinite(n) || n < 0 || n > 10000) errs.push('dailySendCap must be a number between 0 and 10000');
  }
  if (body.steps !== undefined) {
    if (!Array.isArray(body.steps)) errs.push('steps must be an array');
    else {
      if (body.steps.length === 0) errs.push('at least one step is required');
      const seenOffsets = new Set();
      body.steps.forEach((s, i) => {
        if (!s || typeof s !== 'object') { errs.push(`step #${i + 1} must be an object`); return; }
        if (!STEP_KINDS.includes(s.kind)) errs.push(`step #${i + 1} has unknown kind "${s.kind}" (allowed: ${STEP_KINDS.join(', ')})`);
        const off = Number(s.dayOffset);
        if (!Number.isFinite(off) || off < 0 || off > 365) errs.push(`step #${i + 1} dayOffset must be 0–365`);
        else if (seenOffsets.has(off)) errs.push(`step #${i + 1} duplicate dayOffset ${off} — each step needs a unique day`);
        else seenOffsets.add(off);
        if (s.kind === 'email') {
          if (!String(s.subject || '').trim()) errs.push(`step #${i + 1} (email) subject is required`);
          if (!String(s.bodyTemplate || '').trim()) errs.push(`step #${i + 1} (email) bodyTemplate is required`);
        }
        if (s.kind === 'suppress') {
          if (!String(s.suppressList || '').trim()) errs.push(`step #${i + 1} (suppress) suppressList is required`);
        }
      });
    }
  }
  return errs;
}

function normalizeStep(s) {
  const out = {
    id: s.id && /^step_/.test(s.id) ? s.id : 'step_' + crypto.randomBytes(3).toString('hex'),
    dayOffset: Math.max(0, Math.min(365, Math.round(Number(s.dayOffset) || 0))),
    kind: STEP_KINDS.includes(s.kind) ? s.kind : 'email',
    enabled: s.enabled === false ? false : true,
    title: String(s.title || '').trim().slice(0, 80) || `Day ${s.dayOffset || 0}`,
  };
  if (out.kind === 'email') {
    out.subject = String(s.subject || '').trim().slice(0, 200);
    out.bodyTemplate = String(s.bodyTemplate || '').trim().slice(0, 4000);
    out.cta = String(s.cta || '').trim().slice(0, 60);
    out.suppressIfSampleRequested = s.suppressIfSampleRequested !== false;
  }
  if (out.kind === 'suppress') {
    out.suppressList = String(s.suppressList || '').trim().slice(0, 80);
    out.note = String(s.note || '').trim().slice(0, 400);
  }
  return out;
}

function normalizeConfig(body, existing) {
  const cur = existing || readConfig();
  const merged = {
    enabled: body.enabled !== undefined ? !!body.enabled : !!cur.enabled,
    noSampleThresholdDays: body.noSampleThresholdDays !== undefined
      ? Math.max(0, Math.min(90, Math.round(Number(body.noSampleThresholdDays))))
      : Number(cur.noSampleThresholdDays) || 1,
    dailySendCap: body.dailySendCap !== undefined
      ? Math.max(0, Math.min(10000, Math.round(Number(body.dailySendCap))))
      : Number(cur.dailySendCap) || 250,
    steps: Array.isArray(body.steps) ? body.steps.map(normalizeStep) : (cur.steps || []),
    updatedAt: new Date().toISOString(),
  };
  // Keep steps sorted by dayOffset so the UI + scheduler walk them in order.
  merged.steps.sort((a, b) => a.dayOffset - b.dayOffset);
  return merged;
}

// ── drip evaluation ─────────────────────────────────────────────────────────
// Given an event and the current config, decide what stage it sits in and
// which step (if any) WOULD fire today.
function dayDiffDays(fromIso, now) {
  if (!fromIso) return null;
  const t = new Date(fromIso).getTime();
  if (!Number.isFinite(t)) return null;
  return Math.floor((now - t) / (24 * 60 * 60 * 1000));
}

function evaluateEvent(evt, cfg, now = Date.now()) {
  const browsedDaysAgo = dayDiffDays(evt.browsedAt, now);
  const sampleRequested = !!evt.sampleRequestedAt;
  const sampleDaysAgo = dayDiffDays(evt.sampleRequestedAt, now);

  // Sample requested → out of the drip immediately. The Day 30 suppress also
  // takes any contact past day 30 out, regardless of intermediate progress.
  let stage = 'pending';
  let reason = '';
  if (sampleRequested) {
    stage = 'sample-requested';
    reason = `Sample requested ${sampleDaysAgo} day${sampleDaysAgo === 1 ? '' : 's'} ago — removed from drip.`;
  } else if (browsedDaysAgo == null || browsedDaysAgo < cfg.noSampleThresholdDays) {
    stage = 'cooling';
    reason = `Browsed ${browsedDaysAgo == null ? '—' : browsedDaysAgo + 'd'} ago · still inside the ${cfg.noSampleThresholdDays}d no-sample window.`;
  }

  // Which step is *due today*? A step is "due" when browsedDaysAgo === dayOffset.
  // "would fire today" = the matching step is enabled and the contact is still in the drip.
  const orderedSteps = (cfg.steps || []).slice().sort((a, b) => a.dayOffset - b.dayOffset);
  let dueStep = null;
  let lastDueIdx = -1;
  for (let i = 0; i < orderedSteps.length; i++) {
    const s = orderedSteps[i];
    if (browsedDaysAgo != null && browsedDaysAgo >= s.dayOffset) lastDueIdx = i;
    if (browsedDaysAgo === s.dayOffset) dueStep = s;
  }

  // Determine "current step" for stage labelling: the latest step the contact
  // has reached (used so the dashboard groups them as "at Day 1" / "at Day 14").
  let currentStep = lastDueIdx >= 0 ? orderedSteps[lastDueIdx] : null;
  if (currentStep && currentStep.kind === 'suppress') stage = 'suppressed';
  else if (stage === 'pending' && currentStep) stage = 'in-drip';

  // What WOULD fire today?
  let wouldFire = null;
  if (dueStep && dueStep.enabled !== false && stage !== 'sample-requested' && stage !== 'cooling') {
    wouldFire = dueStep;
  }

  return {
    browsedDaysAgo,
    sampleRequestedDaysAgo: sampleDaysAgo,
    stage,
    reason,
    currentStep: currentStep ? { id: currentStep.id, title: currentStep.title, dayOffset: currentStep.dayOffset, kind: currentStep.kind } : null,
    wouldFire: wouldFire ? { id: wouldFire.id, title: wouldFire.title, dayOffset: wouldFire.dayOffset, kind: wouldFire.kind } : null,
  };
}

// ── template rendering ──────────────────────────────────────────────────────
// Replace {token} placeholders with event fields. Used only for dry-run preview.
function renderTemplate(text, evt) {
  const subs = {
    first_name: evt.contactFirstName || 'there',
    pattern_name: evt.productPattern || 'this pattern',
    color_name: evt.productColor || 'the colour',
    product_type: (evt.productType || 'wallcovering').toLowerCase(),
    vendor: evt.vendor || 'the mill',
    collection: evt.collection || 'the collection',
    product_url: evt.productUrl || 'https://designerwallcoverings.com',
    memo_url: evt.memoUrl || 'https://designerwallcoverings.com/memos',
    sku: evt.productSku || '',
  };
  return String(text || '').replace(/\{(\w+)\}/g, (m, k) => (subs[k] != null ? subs[k] : m));
}

function renderEmailPreview(step, evt) {
  return {
    to: evt.contactEmail,
    subject: renderTemplate(step.subject, evt),
    body: renderTemplate(step.bodyTemplate, evt),
    cta: step.cta || '',
    canSpamFooter:
      `${brand.name} · 8 Greenwich Office Park, Greenwich, CT 06831 · ` +
      `Unsubscribe: https://designerwallcoverings.com/unsubscribe?c=${encodeURIComponent(evt.contactEmail || '')}`,
    compliance: brand.compliance,
  };
}

// ── schedule projection ─────────────────────────────────────────────────────
// For each upcoming N-day window, count how many events WOULD hit each step.
function projectSchedule(events, cfg, days = 7, now = Date.now()) {
  const oneDay = 24 * 60 * 60 * 1000;
  const out = [];
  for (let d = 0; d <= days; d++) {
    const asOf = now + d * oneDay;
    const dayLabel = d === 0 ? 'Today' : d === 1 ? 'Tomorrow' : new Date(asOf).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
    const buckets = {};
    for (const evt of events) {
      // Skip events that have requested a sample by now.
      if (evt.sampleRequestedAt && new Date(evt.sampleRequestedAt).getTime() <= asOf) continue;
      const ev = evaluateEvent(evt, cfg, asOf);
      if (!ev.wouldFire) continue;
      const key = ev.wouldFire.id;
      if (!buckets[key]) buckets[key] = { stepId: key, title: ev.wouldFire.title, kind: ev.wouldFire.kind, dayOffset: ev.wouldFire.dayOffset, count: 0 };
      buckets[key].count += 1;
    }
    const buckArr = Object.values(buckets).sort((a, b) => a.dayOffset - b.dayOffset);
    out.push({
      dayIndex: d,
      label: dayLabel,
      iso: new Date(asOf).toISOString().slice(0, 10),
      totalWouldFire: buckArr.reduce((s, b) => s + b.count, 0),
      byStep: buckArr,
      hitCap: buckArr.reduce((s, b) => s + b.count, 0) > (cfg.dailySendCap || 0),
    });
  }
  return out;
}

// ── module ──────────────────────────────────────────────────────────────────
module.exports = {
  id: 'browse-abandon',
  title: 'Browse Abandonment',
  icon: '🔁',

  _evaluateEvent: evaluateEvent,
  _renderEmailPreview: renderEmailPreview,
  _projectSchedule: projectSchedule,

  mount(router) {
    const guard = (handler) => async (req, res) => {
      try { await handler(req, res); }
      catch (e) { res.status(500).json({ error: e.message }); }
    };

    // Touch both files on first mount so a fresh deploy has shape.
    readConfig();
    readEvents();

    router.get('/meta', guard(async (_req, res) => {
      const cfg = readConfig();
      res.json({
        staged: true,
        mock: true,
        stepKinds: STEP_KINDS,
        tokens: ['first_name', 'pattern_name', 'color_name', 'product_type', 'vendor', 'collection', 'product_url', 'memo_url', 'sku'],
        defaults: defaultConfig(),
        brand: { voice: brand.voice, cta: brand.cta },
        currentConfigUpdatedAt: cfg.updatedAt,
        gateMessage:
          'Browse Abandonment is PLANNING ONLY. It defines the drip (Day 1 storytelling → Day 14 room-setting → Day 30 ' +
          'suppress) and shows which mock browse events WOULD fire today and over the next 7 days. Nothing is sent. ' +
          'A live execution path requires a Shopify browse-event feed, contact identity resolution, and the existing ' +
          'Constant Contact send pipeline — each step Steve-gated. CAN-SPAM footer (physical address + working ' +
          'unsubscribe) is required on every live email.',
      });
    }));

    router.get('/config', guard(async (_req, res) => {
      res.json({ staged: true, config: readConfig() });
    }));

    router.put('/config', guard(async (req, res) => {
      const errs = validateConfig(req.body);
      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
      const next = normalizeConfig(req.body || {}, readConfig());
      writeConfig(next);
      res.json({ staged: true, config: next, saved: true });
    }));

    router.post('/config/reset', guard(async (_req, res) => {
      const def = defaultConfig();
      writeConfig(def);
      res.json({ staged: true, config: def, reset: true });
    }));

    router.get('/events', guard(async (_req, res) => {
      const cfg = readConfig();
      const events = readEvents();
      const now = Date.now();
      const enriched = events.map(e => {
        const ev = evaluateEvent(e, cfg, now);
        return { ...e, ...ev };
      });
      // Summary buckets the dashboard uses for headline counts.
      const summary = {
        total: enriched.length,
        sampleRequested: enriched.filter(e => e.stage === 'sample-requested').length,
        cooling: enriched.filter(e => e.stage === 'cooling').length,
        inDrip: enriched.filter(e => e.stage === 'in-drip').length,
        suppressed: enriched.filter(e => e.stage === 'suppressed').length,
        wouldFireToday: enriched.filter(e => e.wouldFire).length,
      };
      res.json({
        staged: true,
        mock: true,
        config: { enabled: cfg.enabled, noSampleThresholdDays: cfg.noSampleThresholdDays, dailySendCap: cfg.dailySendCap },
        summary,
        events: enriched.sort((a, b) => (a.browsedDaysAgo ?? 0) - (b.browsedDaysAgo ?? 0)),
      });
    }));

    router.post('/events/refresh-mock', guard(async (_req, res) => {
      const seeded = seedMockEvents();
      writeEvents(seeded);
      res.json({ staged: true, mock: true, count: seeded.length, reseededAt: new Date().toISOString() });
    }));

    router.get('/schedule', guard(async (req, res) => {
      const cfg = readConfig();
      const events = readEvents();
      const days = Math.max(1, Math.min(30, Number(req.query.days) || 7));
      const projection = projectSchedule(events, cfg, days);
      res.json({
        staged: true,
        mock: true,
        days,
        dailySendCap: cfg.dailySendCap,
        projection,
        sendNote: 'PLANNING ONLY — these counts show what WOULD fire if the drip were live. Zero real sends occur.',
      });
    }));

    // Dry-run renderer: returns the rendered email (or suppress note) for the
    // step that is due TODAY for this event. Never sends. The UI uses it as the
    // "Preview what would fire" inspector.
    router.post('/fire/:eventId', guard(async (req, res) => {
      const cfg = readConfig();
      const events = readEvents();
      const evt = events.find(e => e.id === req.params.eventId);
      if (!evt) return res.status(404).json({ error: 'event not found' });
      const ev = evaluateEvent(evt, cfg);
      if (!ev.wouldFire) {
        return res.json({
          staged: true,
          dryRun: true,
          fired: false,
          reason: ev.reason || `No step is due on day ${ev.browsedDaysAgo} for this contact (stage: ${ev.stage}).`,
          stage: ev.stage,
          eventId: evt.id,
        });
      }
      const step = (cfg.steps || []).find(s => s.id === ev.wouldFire.id);
      const payload = { staged: true, dryRun: true, fired: false, eventId: evt.id, stage: ev.stage, step: { id: step.id, title: step.title, kind: step.kind, dayOffset: step.dayOffset } };
      if (step.kind === 'email') {
        payload.preview = renderEmailPreview(step, evt);
        payload.note = 'PLANNING ONLY — this email was NOT sent. It is a rendered preview for inspection.';
      } else if (step.kind === 'suppress') {
        payload.suppress = { list: step.suppressList, note: renderTemplate(step.note, evt) };
        payload.note = 'PLANNING ONLY — the contact would be moved to the "' + step.suppressList + '" cohort. No action taken.';
      }
      res.json(payload);
    }));
  },
};