← back to Marketing Command Center

modules/journeys/index.js

640 lines

// journeys — Cross-Channel Journeys module for the Marketing Command Center.
// A journey is an ordered sequence of marketing steps for one segment:
//   email → wait → SMS → wait → social retarget → email re-engagement
// Steps carry an optional "condition" (e.g. if-no-click) so the user can plan a
// branching cross-channel cadence. Journeys persist to data/journeys.json.
//
// PLANNING ONLY. Nothing here fires a live send/SMS/social post. POST /:id/run
// returns a dry-run simulation (estimated reach + drop-off across steps) — never
// a side effect. If a real automation engine wires up later, it can read these
// journeys from disk; this module owns the plan, not the execution.
//
// Routes (mounted at /api/journeys by the shell):
//   GET    /meta                       — module metadata, step kinds, recommendation rules
//   GET    /refs                       — segments + templates the UI uses to populate dropdowns
//   GET    /journeys                   — list all journeys (summary shape)
//   GET    /journeys/:id               — one journey (full steps)
//   POST   /journeys                   — create
//   PUT    /journeys/:id               — update (name/description/segment/steps)
//   DELETE /journeys/:id               — remove
//   POST   /journeys/:id/recommend     — recommended next steps to append after current journey
//   POST   /journeys/:id/run           — dry-run simulation (audience flow, never sends)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

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

const DATA_FILE = path.join(__dirname, '..', '..', 'data', 'journeys.json');
const SEG_FILE  = path.join(__dirname, '..', '..', 'data', 'segments.json');

// ── step schema ─────────────────────────────────────────────────────────────
// Each kind declares the fields the UI should collect. The validator below
// uses the same shape so the schema and the gate never drift.
const STEP_KINDS = {
  email: {
    label: 'Email',
    icon: '✉️',
    description: 'Send a templated email (drafted; live send is gated by Constant Contact).',
    fields: [
      { key: 'subject',    label: 'Subject line',   required: true },
      { key: 'templateId', label: 'Template id (optional)', required: false },
      { key: 'note',       label: 'Internal note',  required: false },
    ],
    supportsCondition: true,
  },
  wait: {
    label: 'Wait',
    icon: '⏱',
    description: 'Pause the journey for a defined window before evaluating the next step.',
    fields: [
      { key: 'hours', label: 'Wait (hours)', required: true, type: 'number' },
      { key: 'label', label: 'Reason (optional)', required: false },
    ],
    supportsCondition: false,
  },
  sms: {
    label: 'SMS',
    icon: '📱',
    description: 'Send a short SMS follow-up (planning only; live send routes through a gated provider).',
    fields: [
      { key: 'message', label: 'SMS message (160 chars)', required: true },
      { key: 'note',    label: 'Internal note',           required: false },
    ],
    supportsCondition: true,
  },
  social: {
    label: 'Social',
    icon: '📸',
    description: 'Stage an organic social post (Instagram / Facebook / TikTok). Live publish stays gated.',
    fields: [
      { key: 'platform', label: 'Platform', required: true, enum: ['instagram', 'facebook', 'tiktok', 'linkedin'] },
      { key: 'caption',  label: 'Caption',  required: true },
      { key: 'note',     label: 'Internal note', required: false },
    ],
    supportsCondition: true,
  },
  retarget: {
    label: 'Retarget',
    icon: '🎯',
    description: 'Add the segment slice to a retargeting audience (Meta / Google). Planning only.',
    fields: [
      { key: 'platform',     label: 'Retarget platform', required: true, enum: ['meta', 'google', 'tiktok'] },
      { key: 'audienceType', label: 'Audience slice',    required: true, enum: ['non-openers', 'non-clickers', 'openers', 'all'] },
      { key: 'note',         label: 'Internal note',     required: false },
    ],
    supportsCondition: true,
  },
};

// Conditions that can gate a non-wait step. "always" = no branching. The other
// values fire only when the prior email step's outcome matches.
const CONDITIONS = {
  always:        { label: 'Always',              applies: () => true },
  'if-no-open':  { label: 'If no open',          requiresPriorEmail: true },
  'if-no-click': { label: 'If no click',         requiresPriorEmail: true },
  'if-opened':   { label: 'If opened',           requiresPriorEmail: true },
  'if-clicked':  { label: 'If clicked',          requiresPriorEmail: true },
};

// ── recommendations engine ──────────────────────────────────────────────────
// Given a journey, recommend cross-channel next steps. The first recommendation
// only fires when the journey ends on an email step — that's the inflection
// point where cross-channel follow-up actually helps.
function recommendNextSteps(journey) {
  const steps = Array.isArray(journey.steps) ? journey.steps : [];
  const last = steps[steps.length - 1] || null;
  const lastEmailIdx = (() => {
    for (let i = steps.length - 1; i >= 0; i--) if (steps[i].kind === 'email') return i;
    return -1;
  })();
  const hasEmail = lastEmailIdx >= 0;
  const recs = [];

  if (!steps.length) {
    recs.push({
      title: 'Start with a welcome email',
      reason: 'Every journey opens with one anchor email — set the brand voice before any cross-channel follow-up.',
      steps: [
        {
          kind: 'email',
          condition: 'always',
          subject: 'Welcome to the Designer Wallcoverings atelier',
          note: 'Use the welcome-designer-1 template from the Campaign Templates module.',
        },
      ],
    });
    return recs;
  }

  if (!hasEmail) {
    recs.push({
      title: 'Add an anchor email before branching cross-channel',
      reason: 'The cross-channel recommendations below assume an email open/click signal to react to. Add an email step first.',
      steps: [
        { kind: 'email', condition: 'always', subject: 'Designer Wallcoverings — a curated look', note: 'Anchor send for the journey.' },
      ],
    });
    return recs;
  }

  // Standard cross-channel ladder after an email send.
  recs.push({
    title: '24h SMS to non-clickers',
    reason: 'A short SMS one day later catches designers who opened the email on mobile but did not tap through. Keep it under 160 characters and lead with the memo offer — never a discount.',
    steps: [
      { kind: 'wait', hours: 24, label: '24h after email send' },
      {
        kind: 'sms',
        condition: 'if-no-click',
        message: 'Designer Wallcoverings — your memo box from this week\'s edit is one tap away: dwall.co/memos. Reply STOP to opt out.',
        note: 'Provider TBD; live send stays gated behind Steve approval.',
      },
    ],
  });

  recs.push({
    title: '3-day Instagram retarget on non-openers',
    reason: 'After three days, anyone who never opened is unreachable by email until next send — surface the collection through paid social retargeting instead. Plan only; never auto-publish.',
    steps: [
      { kind: 'wait', hours: 72, label: '3 days after email send' },
      {
        kind: 'social',
        condition: 'if-no-open',
        platform: 'instagram',
        caption: 'Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth',
        note: 'Stage as a retarget creative; tag the journey segment in the audience set.',
      },
      {
        kind: 'retarget',
        condition: 'if-no-open',
        platform: 'meta',
        audienceType: 'non-openers',
        note: 'Push the non-opener slice into a Meta custom audience for a 7-day retarget window.',
      },
    ],
  });

  recs.push({
    title: '7-day re-engagement email to non-openers',
    reason: 'The graceful close — a final email re-cast at a different angle (editorial vs. transactional). After this, the contact rolls off this journey.',
    steps: [
      { kind: 'wait', hours: 168, label: '7 days after email send' },
      {
        kind: 'email',
        condition: 'if-no-open',
        subject: 'Still considering? A note from the atelier',
        note: 'Use the memo-followup-day21 or re-engagement template — softer, no pressure.',
      },
    ],
  });

  // If the journey is already long, hint at an exit step.
  if (steps.length >= 5) {
    recs.push({
      title: 'Add a graceful exit',
      reason: 'A journey longer than five steps risks fatigue. Close with a wait + retarget rollover so the audience moves to evergreen, not silence.',
      steps: [
        { kind: 'wait', hours: 168, label: 'Cool-down before exit' },
        {
          kind: 'retarget',
          condition: 'always',
          platform: 'meta',
          audienceType: 'all',
          note: 'Roll the whole segment into the evergreen brand-retention audience and exit the journey.',
        },
      ],
    });
  }

  // Annotate each recommendation with the anchor email it follows so the UI can
  // explain "this 24h SMS follows step #2 (your welcome email)".
  for (const r of recs) r.anchorStepIndex = lastEmailIdx;
  return recs;
}

// ── dry-run simulation ──────────────────────────────────────────────────────
// Walk the journey and project audience flow: how many enter each step, how
// many proceed, how many drop off (because the condition gated them out, or
// because we model an open/click rate per email).
//
// Deterministic per-journey via a seeded PRNG so the same journey simulates to
// the same numbers — predictable for the UI.
function mulberry32(seed) {
  let s = seed >>> 0;
  return function () {
    s |= 0; s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function hashSeed(s) {
  let h = 2166136261 >>> 0;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619) >>> 0;
  }
  return h >>> 0;
}

const BASE_OPEN = 0.22;
const BASE_CLICK = 0.032;

function simulateJourney(journey, audienceSize) {
  const seed = hashSeed((journey.id || 'jny') + ':' + journey.steps.length);
  const rnd = mulberry32(seed);
  const noise = () => 0.92 + rnd() * 0.16;

  // Track the audience as it flows: { active, opened, clicked }. "active" is
  // the count still in the journey at this step; opens/clicks come from the
  // most recent email and reset whenever a new email is sent.
  let active = Math.max(0, Math.round(Number(audienceSize) || 0));
  let opened = 0;
  let clicked = 0;
  let lastEmailIdx = -1;

  const stepResults = [];
  for (let i = 0; i < journey.steps.length; i++) {
    const step = journey.steps[i];
    const entry = active;
    let proceeded = active;
    let droppedByCondition = 0;
    let note = '';

    if (step.kind === 'email') {
      // Send to everyone active; project opens + clicks.
      const openRate  = Math.max(0.02, Math.min(0.7, BASE_OPEN  * noise()));
      const clickRate = Math.max(0.005, Math.min(0.4, BASE_CLICK * noise()));
      opened  = Math.round(active * openRate);
      clicked = Math.round(active * clickRate);
      lastEmailIdx = i;
      note = `Projected ${opened} opens (${(openRate * 100).toFixed(1)}%) · ${clicked} clicks (${(clickRate * 100).toFixed(1)}%)`;
      proceeded = active;
    } else if (step.kind === 'wait') {
      const hours = Number(step.hours) || 0;
      note = `Pause ${hours}h — audience continues.`;
      proceeded = active;
    } else {
      // sms / social / retarget — gate by condition relative to the last email.
      const cond = step.condition || 'always';
      if (cond === 'if-no-open')  { proceeded = Math.max(0, active - opened); }
      else if (cond === 'if-no-click') { proceeded = Math.max(0, active - clicked); }
      else if (cond === 'if-opened')  { proceeded = opened; }
      else if (cond === 'if-clicked') { proceeded = clicked; }
      else proceeded = active;
      droppedByCondition = entry - proceeded;
      if (cond !== 'always' && lastEmailIdx < 0) {
        note = `Condition "${cond}" needs a prior email step — no signal to branch on, treating as 0 reach.`;
        proceeded = 0;
        droppedByCondition = entry;
      } else {
        note = `${proceeded.toLocaleString()} continue${droppedByCondition ? ` · ${droppedByCondition.toLocaleString()} held by "${cond}"` : ''}`;
      }
    }

    stepResults.push({
      index: i,
      kind: step.kind,
      condition: step.condition || 'always',
      entry,
      proceeded,
      droppedByCondition,
      opens: step.kind === 'email' ? opened : null,
      clicks: step.kind === 'email' ? clicked : null,
      note,
    });

    // Audience that drops by condition exits the journey entirely.
    active = proceeded;
  }

  return {
    audienceSize: Math.max(0, Math.round(Number(audienceSize) || 0)),
    finalActive: active,
    totalEmails:  journey.steps.filter(s => s.kind === 'email').length,
    totalSms:     journey.steps.filter(s => s.kind === 'sms').length,
    totalSocial:  journey.steps.filter(s => s.kind === 'social').length,
    totalRetargets: journey.steps.filter(s => s.kind === 'retarget').length,
    stepResults,
    simulatedAt: new Date().toISOString(),
  };
}

// ── 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 readAll()  { const v = readJson(DATA_FILE, null); return Array.isArray(v) ? v : []; }
function writeAll(rows) { writeJson(DATA_FILE, rows); }
function newId() { return 'jny_' + crypto.randomBytes(4).toString('hex'); }
function newStepId() { return 'stp_' + crypto.randomBytes(3).toString('hex'); }

// Seed a couple of realistic journeys on first load so the UI has content out
// of the gate. Only written if the file doesn't already exist.
function defaultJourneys() {
  return [
    {
      id: 'jny_seed_welcome',
      name: 'Designer Welcome — Cross-Channel',
      description: 'New trade-approved designer: welcome email → 24h SMS for non-clickers → 3-day IG retarget on non-openers → 7-day re-engagement.',
      segmentId: 'seg_trade_engaged',
      segmentName: 'Trade & Designers — engaged',
      audienceSize: 240,
      status: 'staged',
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      steps: [
        {
          id: 'stp_seed_1', kind: 'email', condition: 'always',
          subject: 'Welcome to the Designer Wallcoverings atelier',
          templateId: 'welcome-designer-1',
          note: 'Anchor welcome email (Part 1 of 2).',
        },
        { id: 'stp_seed_2', kind: 'wait', hours: 24, label: '24 hours' },
        {
          id: 'stp_seed_3', kind: 'sms', condition: 'if-no-click',
          message: 'Designer Wallcoverings — your memo box from this week\'s edit is one tap away: dwall.co/memos. Reply STOP to opt out.',
          note: 'Catches mobile openers who did not tap through.',
        },
        { id: 'stp_seed_4', kind: 'wait', hours: 72, label: '3 days' },
        {
          id: 'stp_seed_5', kind: 'social', condition: 'if-no-open',
          platform: 'instagram',
          caption: 'Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth',
          note: 'IG retarget creative for non-openers of the welcome email.',
        },
        {
          id: 'stp_seed_6', kind: 'retarget', condition: 'if-no-open',
          platform: 'meta', audienceType: 'non-openers',
          note: 'Push non-openers into Meta custom audience for a 7-day window.',
        },
        { id: 'stp_seed_7', kind: 'wait', hours: 168, label: '7 days' },
        {
          id: 'stp_seed_8', kind: 'email', condition: 'if-no-open',
          subject: 'Still considering? A note from the atelier',
          templateId: 'memo-followup-day21',
          note: 'Graceful re-engagement before exit.',
        },
      ],
    },
  ];
}

// ── validation ──────────────────────────────────────────────────────────────
function validateStep(step, index) {
  const errs = [];
  if (!step || typeof step !== 'object') { errs.push(`step #${index + 1} must be an object`); return errs; }
  const kindSpec = STEP_KINDS[step.kind];
  if (!kindSpec) { errs.push(`step #${index + 1} has unknown kind "${step.kind}"`); return errs; }
  for (const f of kindSpec.fields) {
    if (!f.required) continue;
    const v = step[f.key];
    if (v === undefined || v === null || String(v).trim() === '') {
      errs.push(`step #${index + 1} (${kindSpec.label}) missing required field "${f.key}"`);
    } else if (f.enum && !f.enum.includes(String(v))) {
      errs.push(`step #${index + 1} field "${f.key}" must be one of: ${f.enum.join(', ')}`);
    } else if (f.type === 'number' && !Number.isFinite(Number(v))) {
      errs.push(`step #${index + 1} field "${f.key}" must be a number`);
    }
  }
  if (kindSpec.supportsCondition) {
    const c = step.condition || 'always';
    if (!CONDITIONS[c]) errs.push(`step #${index + 1} has unknown condition "${c}"`);
  }
  return errs;
}

function validateJourney(body, { partial = false } = {}) {
  const errs = [];
  if (!body || typeof body !== 'object') return ['body must be an object'];
  if (!partial && !String(body.name || '').trim()) errs.push('name is required');
  if (body.steps !== undefined) {
    if (!Array.isArray(body.steps)) errs.push('steps must be an array');
    else if (!partial && body.steps.length === 0) errs.push('journey needs at least one step');
    else body.steps.forEach((s, i) => errs.push(...validateStep(s, i)));
  } else if (!partial) {
    errs.push('steps is required');
  }
  const audience = Number(body.audienceSize);
  if (body.audienceSize !== undefined && (!Number.isFinite(audience) || audience < 0)) {
    errs.push('audienceSize must be a non-negative number');
  }
  return errs;
}

function normalizeStep(step) {
  const kindSpec = STEP_KINDS[step.kind];
  const out = { id: step.id && /^stp_/.test(step.id) ? step.id : newStepId(), kind: step.kind };
  for (const f of kindSpec.fields) {
    if (step[f.key] !== undefined) {
      if (f.type === 'number') out[f.key] = Number(step[f.key]);
      else out[f.key] = String(step[f.key]).trim();
    }
  }
  if (kindSpec.supportsCondition) out.condition = CONDITIONS[step.condition] ? step.condition : 'always';
  return out;
}

function normalizeJourney(body, existing = null) {
  const segmentName = body.segmentName !== undefined
    ? String(body.segmentName).trim()
    : (existing && existing.segmentName) || '';
  return {
    id: existing ? existing.id : newId(),
    name: String(body.name || (existing && existing.name) || '').trim().slice(0, 140),
    description: String(body.description !== undefined ? body.description : (existing && existing.description) || '').trim().slice(0, 500),
    segmentId: body.segmentId !== undefined ? (body.segmentId ? String(body.segmentId) : null) : (existing && existing.segmentId) || null,
    segmentName,
    audienceSize: body.audienceSize !== undefined
      ? Math.max(0, Math.round(Number(body.audienceSize)))
      : (existing && existing.audienceSize) || 0,
    status: 'staged',
    steps: Array.isArray(body.steps)
      ? body.steps.map(normalizeStep)
      : (existing && existing.steps) || [],
    createdAt: existing ? existing.createdAt : new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
}

function summarize(j) {
  return {
    id: j.id,
    name: j.name,
    description: j.description,
    segmentId: j.segmentId || null,
    segmentName: j.segmentName || '',
    audienceSize: j.audienceSize || 0,
    status: j.status || 'staged',
    stepCount: (j.steps || []).length,
    kinds: Array.from(new Set((j.steps || []).map(s => s.kind))),
    updatedAt: j.updatedAt,
    createdAt: j.createdAt,
  };
}

// Pull the segments file directly (peer module). Read-only — no mutation.
function loadSegmentRefs() {
  const v = readJson(SEG_FILE, []);
  if (!Array.isArray(v)) return [];
  return v.map(s => ({
    id: s.id,
    name: s.name,
    description: s.description || '',
    estimatedSize: s.estimatedSize || null,
  }));
}

// Templates are in-memory in the templates module — reuse them so the UI can
// show "use this template" labels in email steps without a second fetch.
function loadTemplateRefs() {
  try {
    const tmpl = require('../templates');
    const arr = Array.isArray(tmpl._templates) ? tmpl._templates : [];
    return arr.map(t => ({
      id: t.id,
      title: t.title,
      subject: t.subject,
      category: t.category,
      sequencePosition: t.sequencePosition || '',
    }));
  } catch { return []; }
}

// ── module ──────────────────────────────────────────────────────────────────
module.exports = {
  id: 'journeys',
  title: 'Cross-Channel Journeys',
  icon: '🗺️',

  _STEP_KINDS: STEP_KINDS,
  _CONDITIONS: CONDITIONS,
  _recommend: recommendNextSteps,
  _simulate: simulateJourney,

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

    // Seed on first load — only if data file is empty.
    if (!readAll().length) writeAll(defaultJourneys());

    router.get('/meta', guard(async (_req, res) => {
      const kinds = Object.entries(STEP_KINDS).map(([key, spec]) => ({
        key, label: spec.label, icon: spec.icon, description: spec.description,
        fields: spec.fields, supportsCondition: spec.supportsCondition,
      }));
      const conditions = Object.entries(CONDITIONS).map(([key, spec]) => ({
        key, label: spec.label, requiresPriorEmail: !!spec.requiresPriorEmail,
      }));
      res.json({
        staged: true,
        count: readAll().length,
        kinds,
        conditions,
        brand: { voice: brand.voice, cta: brand.cta },
        gateMessage:
          'Cross-Channel Journeys are PLANNING ONLY. This module visualizes and saves the journey. ' +
          'It never fires an email, SMS, social post, or retarget audience push. Live sends still ' +
          'route through Constant Contact (or the matching channel provider) with Steve gating each ' +
          'send. CAN-SPAM footer (physical mailing address + working unsubscribe) is required on ' +
          'every email step before the real send.',
      });
    }));

    // Combined refs endpoint — segments + templates the UI populates dropdowns with.
    router.get('/refs', guard(async (_req, res) => {
      res.json({
        staged: true,
        segments: loadSegmentRefs(),
        templates: loadTemplateRefs(),
      });
    }));

    router.get('/journeys', guard(async (_req, res) => {
      const rows = readAll();
      res.json({ staged: true, count: rows.length, journeys: rows.map(summarize) });
    }));

    router.get('/journeys/:id', guard(async (req, res) => {
      const row = readAll().find(j => j.id === req.params.id);
      if (!row) return res.status(404).json({ error: 'journey not found' });
      res.json({ staged: true, journey: row });
    }));

    router.post('/journeys', guard(async (req, res) => {
      const errs = validateJourney(req.body);
      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
      const norm = normalizeJourney(req.body);
      const rows = readAll();
      rows.unshift(norm);
      writeAll(rows);
      res.json({ staged: true, journey: norm });
    }));

    router.put('/journeys/:id', guard(async (req, res) => {
      const rows = readAll();
      const idx = rows.findIndex(j => j.id === req.params.id);
      if (idx < 0) return res.status(404).json({ error: 'journey not found' });
      const errs = validateJourney(req.body, { partial: true });
      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
      const norm = normalizeJourney(req.body, rows[idx]);
      rows[idx] = norm;
      writeAll(rows);
      res.json({ staged: true, journey: norm });
    }));

    router.delete('/journeys/:id', guard(async (req, res) => {
      const rows = readAll();
      const idx = rows.findIndex(j => j.id === req.params.id);
      if (idx < 0) return res.status(404).json({ error: 'journey not found' });
      const [removed] = rows.splice(idx, 1);
      writeAll(rows);
      res.json({ staged: true, deleted: removed.id });
    }));

    // Recommended next steps. Reads the journey from disk (or uses a posted
    // draft if the caller hasn't saved yet) and returns a list of recommended
    // appendable step blocks for the user to accept/reject in the UI.
    router.post('/journeys/:id/recommend', guard(async (req, res) => {
      let journey;
      if (req.params.id === 'draft') {
        // Allow drafts that haven't been saved yet — useful for the builder UI.
        const body = req.body || {};
        journey = { id: 'draft', steps: Array.isArray(body.steps) ? body.steps : [] };
      } else {
        journey = readAll().find(j => j.id === req.params.id);
        if (!journey) return res.status(404).json({ error: 'journey not found' });
      }
      const recs = recommendNextSteps(journey);
      res.json({ staged: true, journeyId: journey.id, recommendations: recs });
    }));

    // Dry-run simulation — NEVER sends. Returns the projected step-by-step flow.
    router.post('/journeys/:id/run', guard(async (req, res) => {
      const rows = readAll();
      const journey = rows.find(j => j.id === req.params.id);
      if (!journey) return res.status(404).json({ error: 'journey not found' });
      const audience = (req.body && Number.isFinite(Number(req.body.audienceSize)))
        ? Number(req.body.audienceSize)
        : (journey.audienceSize || 0);
      const sim = simulateJourney(journey, audience);
      res.json({
        staged: true,
        dryRun: true,
        journeyId: journey.id,
        ...sim,
        sendNote: 'PLANNING ONLY — this run produced zero real sends. Live execution requires Steve-gated provider flows.',
      });
    }));
  },
};