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