← 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);
}));
},
};