← back to Marketing Command Center
modules/segments/index.js
597 lines
// Audience Segments module — build + preview marketing audience segments for the
// Marketing Command Center. A segment is a named set of RULES over contact fields
// (list membership, engagement, trade-vs-retail type, tags). Segments persist to
// data/segments.json; previewing evaluates the rules against a contact pool.
//
// The contact pool is the SAME shape the constant-contact module uses. When a CTCT
// access token is present we pull real contacts from the v3 API (global fetch);
// otherwise we generate a realistic mock pool of ~300 luxury-design contacts and
// flag every contact-backed response with { mock: true }.
//
// Self-contained per the MODULE CONTRACT (README.md). No outside sends here — this
// is purely audience definition + preview (read-only against contacts).
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const SEG_FILE = path.join(DATA_DIR, 'segments.json');
const CTCT_API = 'https://api.cc.email/v3';
const live = () => Boolean(process.env.CTCT_ACCESS_TOKEN);
// ── tiny JSON 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));
}
// ── rule schema ──────────────────────────────────────────────────────────────
// Fields a rule can target, and the operators each allows. The panel reads this
// to build its dropdowns so the UI and evaluator never drift apart.
const FIELDS = {
type: { label: 'Contact type', ops: ['is', 'is_not'], values: ['trade', 'retail'] },
list: { label: 'List membership', ops: ['in', 'not_in'], values: 'lists' },
tag: { label: 'Tag', ops: ['has', 'not_has'], values: 'tags' },
engagement: { label: 'Engagement', ops: ['is'],
values: ['opened-last-30d', 'clicked-last-30d', 'clicked', 'lapsed-90d', 'never-opened'] },
};
const OP_LABELS = {
is: 'is', is_not: 'is not', in: 'in', not_in: 'not in',
has: 'has', not_has: 'does not have',
};
// Canonical list + tag vocabularies (used to seed mock contacts AND to populate the
// rule-builder value dropdowns so the UI offers real choices).
const LISTS = ['Trade & Designers', 'Retail Newsletter', 'Memo Sample Requests', 'Hospitality / Commercial Spec'];
const TAGS = ['grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo-cta', 'lookbook', 'high-intent', 'vip', 'architect'];
// ── mock contact pool (~300 luxury-design contacts) ───────────────────────────
// Deterministic generator so a given install always evaluates the same pool (no
// flapping preview counts). Shape mirrors what a CTCT contact maps down to:
// { email, type, lists:[...], lastOpen:ISO|null, lastClick:ISO|null, tags:[...] }
let _mockPool = null;
function mockPool() {
if (_mockPool) return _mockPool;
// small seeded PRNG (mulberry32) — deterministic across runs
let seed = 0x9e3779b9;
const rnd = () => {
seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const pick = arr => arr[Math.floor(rnd() * arr.length)];
const studios = ['atelier-noir', 'maison-blanc', 'verdant-interiors', 'harbor-hospitality',
'lumiere-design', 'oak-and-ash', 'studio-meridian', 'thornfield-co', 'cobalt-interiors',
'gilded-room', 'north-star-design', 'ember-and-stone', 'palette-house', 'crestwood-studio',
'ivory-lane', 'monarch-interiors', 'saffron-studio', 'westbrook-design', 'vellum-co', 'aria-spaces'];
const tlds = ['com', 'co', 'studio', 'design'];
const now = Date.now();
const DAY = 86400000;
const isoDaysAgo = d => d == null ? null : new Date(now - d * DAY).toISOString();
const pool = [];
for (let i = 0; i < 300; i++) {
const isTrade = rnd() < 0.58; // trade-leaning house
const type = isTrade ? 'trade' : 'retail';
// list membership
const lists = [];
if (isTrade) { lists.push('Trade & Designers'); if (rnd() < 0.35) lists.push('Hospitality / Commercial Spec'); }
else { lists.push('Retail Newsletter'); }
if (rnd() < 0.30) lists.push('Memo Sample Requests');
// engagement: lastOpen / lastClick in "days ago" or null (never)
// ~22% lapsed (>90d), ~18% never opened, rest engaged within 0-60d
let openDaysAgo, clickDaysAgo;
const r = rnd();
if (r < 0.18) { openDaysAgo = null; clickDaysAgo = null; } // never engaged
else if (r < 0.40) { openDaysAgo = 90 + Math.floor(rnd() * 200); clickDaysAgo = rnd() < 0.3 ? openDaysAgo + Math.floor(rnd() * 10) : null; } // lapsed
else { openDaysAgo = Math.floor(rnd() * 60); clickDaysAgo = rnd() < 0.55 ? openDaysAgo + Math.floor(rnd() * 8) : null; } // engaged
// tags
const tags = [];
const nTags = Math.floor(rnd() * 3);
for (let t = 0; t < nTags; t++) { const tg = pick(TAGS); if (!tags.includes(tg)) tags.push(tg); }
if (clickDaysAgo != null && clickDaysAgo <= 30 && rnd() < 0.5 && !tags.includes('memo-cta')) tags.push('memo-cta');
if (isTrade && rnd() < 0.15 && !tags.includes('vip')) tags.push('vip');
const studio = studios[i % studios.length];
const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
// opens/clicks counts — correlate with recency so RFM scoring is realistic.
// Never-engaged contacts get 0; recently-engaged contacts get the highest counts.
let opens = 0, clicks = 0;
if (openDaysAgo != null) {
if (openDaysAgo <= 14) opens = 6 + Math.floor(rnd() * 9); // 6-14
else if (openDaysAgo <= 30) opens = 4 + Math.floor(rnd() * 6); // 4-9
else if (openDaysAgo <= 60) opens = 2 + Math.floor(rnd() * 4); // 2-5
else if (openDaysAgo <= 120) opens = 1 + Math.floor(rnd() * 3); // 1-3
else opens = Math.floor(rnd() * 2); // 0-1 (lapsed tail)
}
if (clickDaysAgo != null) {
if (clickDaysAgo <= 14) clicks = 2 + Math.floor(rnd() * 5); // 2-6
else if (clickDaysAgo <= 30) clicks = 1 + Math.floor(rnd() * 4); // 1-4
else if (clickDaysAgo <= 60) clicks = 1 + Math.floor(rnd() * 2); // 1-2
else clicks = Math.floor(rnd() * 2); // 0-1
}
pool.push({
email, type, lists,
lastOpen: isoDaysAgo(openDaysAgo),
lastClick: isoDaysAgo(clickDaysAgo),
opens, clicks,
tags,
});
}
_mockPool = pool;
return pool;
}
// ── live CTCT contact fetch (best-effort, maps down to the same shape) ─────────
async function ctct(pathname) {
const headers = {
Authorization: `Bearer ${process.env.CTCT_ACCESS_TOKEN}`,
Accept: 'application/json',
};
if (process.env.CTCT_API_KEY) headers['x-api-key'] = process.env.CTCT_API_KEY;
const r = await fetch(`${CTCT_API}${pathname}`, { headers });
const text = await r.text();
let json; try { json = text ? JSON.parse(text) : {}; } catch { json = {}; }
if (!r.ok) { const e = new Error(`CC GET ${pathname} → ${r.status}`); e.status = r.status; e.detail = json; throw e; }
return json;
}
// Map a v3 contact + a list-id→name lookup down to our evaluation shape.
function mapContact(c, listNameById) {
const email = (c.email_address && c.email_address.address) || c.email || '';
const lists = (c.list_memberships || []).map(id => listNameById[id]).filter(Boolean);
// CC has no first-class trade/retail flag; infer from list names, else 'retail'.
const isTrade = lists.some(n => /trade|designer|spec|commercial|hospitality/i.test(n));
const tags = (c.taggings || c.tags || []).map(t => (typeof t === 'string' ? t : (t.name || t.tag_id))).filter(Boolean);
const lastOpen = c.last_open_date || null;
const lastClick = c.last_click_date || null;
// CC v3 doesn't expose lifetime open/click counts on the contact resource.
// Derive a count heuristic from recency so RFM can score live contacts too.
// Explicit lifetime counts (if a future API change exposes them) win.
const explicitOpens = Number.isFinite(c.opens_count) ? c.opens_count
: Number.isFinite(c.lifetime_opens) ? c.lifetime_opens : null;
const explicitClicks = Number.isFinite(c.clicks_count) ? c.clicks_count
: Number.isFinite(c.lifetime_clicks) ? c.lifetime_clicks : null;
return {
email,
type: isTrade ? 'trade' : 'retail',
lists,
lastOpen,
lastClick,
opens: explicitOpens != null ? explicitOpens : derivedCount(lastOpen, 'open'),
clicks: explicitClicks != null ? explicitClicks : derivedCount(lastClick, 'click'),
tags,
};
}
// Heuristic count for live contacts when CC doesn't surface lifetime open/click counts.
function derivedCount(iso, kind) {
if (!iso) return 0;
const d = daysSince(iso);
if (d === Infinity) return 0;
const scale = kind === 'open' ? 8 : 4; // opens range higher than clicks
return Math.max(0, Math.round(scale / (1 + d / 30)));
}
// Pull a live pool (capped). Falls back to mock on any failure so the panel never
// breaks. Returns { pool, mock }.
async function contactPool() {
if (!live()) return { pool: mockPool(), mock: true };
try {
const listsResp = await ctct('/contact_lists?include_count=false&limit=100');
const listNameById = {};
for (const l of (listsResp.lists || [])) listNameById[l.list_id || l.id] = l.name;
const data = await ctct('/contacts?limit=500&include=list_memberships,taggings');
const pool = (data.contacts || []).map(c => mapContact(c, listNameById)).filter(c => c.email);
if (!pool.length) return { pool: mockPool(), mock: true };
return { pool, mock: false };
} catch {
return { pool: mockPool(), mock: true };
}
}
// ── rule evaluation ────────────────────────────────────────────────────────────
function daysSince(iso) {
if (!iso) return Infinity;
const t = Date.parse(iso);
if (Number.isNaN(t)) return Infinity;
return (Date.now() - t) / 86400000;
}
function evalRule(contact, rule) {
if (!rule || !rule.field) return true;
const { field, op, value } = rule;
switch (field) {
case 'type':
if (op === 'is_not') return contact.type !== value;
return contact.type === value;
case 'list': {
const has = (contact.lists || []).includes(value);
return op === 'not_in' ? !has : has;
}
case 'tag': {
const has = (contact.tags || []).includes(value);
return op === 'not_has' ? !has : has;
}
case 'engagement': {
const openAgo = daysSince(contact.lastOpen);
const clickAgo = daysSince(contact.lastClick);
switch (value) {
case 'opened-last-30d': return openAgo <= 30;
case 'clicked-last-30d': return clickAgo <= 30;
case 'clicked': return clickAgo !== Infinity;
case 'lapsed-90d': return openAgo > 90; // includes never-opened
case 'never-opened': return openAgo === Infinity;
default: return true;
}
}
default:
return true;
}
}
// A contact matches a segment when it satisfies ALL rules (AND). Empty rules = all.
function matches(contact, rules) {
if (!Array.isArray(rules) || !rules.length) return true;
return rules.every(r => evalRule(contact, r));
}
function evaluate(pool, rules, sampleSize = 12) {
const hits = pool.filter(c => matches(c, rules));
const sample = hits.slice(0, sampleSize).map(c => ({
email: c.email, type: c.type,
lists: c.lists, tags: c.tags,
lastOpen: c.lastOpen, lastClick: c.lastClick,
}));
return { count: hits.length, total: pool.length, sample };
}
// ── RFM auto-segmentation ─────────────────────────────────────────────────────
// R(ecency) — days since lastOpen (lower days → higher score, 1-5)
// F(requency) — lifetime opens count (higher → higher score)
// M(onetary proxy) — lifetime clicks (intent / conversion signal)
//
// We use thresholds rather than per-pool quintiles so scores are stable when a
// new contact is added and so small pools (< 100) still bucket sensibly.
const RFM_THRESHOLDS = {
// boundaries: score 5 if value beats first threshold, 4 next, ... 1 last
recencyDays: [14, 30, 60, 120], // days since open — fewer days = higher score
frequencyOpens: [10, 6, 3, 1], // opens count — more = higher score
monetaryClicks: [5, 3, 2, 1], // clicks count — more = higher score
};
function rScore(daysAgo) {
const t = RFM_THRESHOLDS.recencyDays;
if (daysAgo <= t[0]) return 5;
if (daysAgo <= t[1]) return 4;
if (daysAgo <= t[2]) return 3;
if (daysAgo <= t[3]) return 2;
return 1;
}
function fmScore(n, table) {
if (n >= table[0]) return 5;
if (n >= table[1]) return 4;
if (n >= table[2]) return 3;
if (n >= table[3]) return 2;
return 1;
}
function rfmScore(contact) {
const r = rScore(daysSince(contact.lastOpen));
const f = fmScore(Number(contact.opens) || 0, RFM_THRESHOLDS.frequencyOpens);
const m = fmScore(Number(contact.clicks) || 0, RFM_THRESHOLDS.monetaryClicks);
return { r, f, m };
}
// Bucket priority order — first match wins so every contact lands in exactly one.
// Promising catches recently-engaged-but-low-frequency (new subscribers, casual)
// so totals reconcile to pool size.
const RFM_BUCKETS = [
{
id: 'champions',
name: 'Champions',
icon: '🏆',
description: 'Recently engaged, frequent openers, strong click intent — your top tier.',
criteria: 'R≥4 · F≥4 · M≥3',
test: ({ r, f, m }) => r >= 4 && f >= 4 && m >= 3,
nextStep: 'Send the curated trade lookbook + a $0 memo-bundle CTA.',
},
{
id: 'loyal',
name: 'Loyal',
icon: '💛',
description: 'Consistent openers across recent campaigns — keep the cadence.',
criteria: 'R≥3 · F≥3',
test: ({ r, f }) => r >= 3 && f >= 3,
nextStep: 'Reward with a behind-the-scenes vendor story or new-arrival peek.',
},
{
id: 'at-risk',
name: 'At-Risk',
icon: '⚠️',
description: 'Used to engage frequently — opens have gone cold.',
criteria: 'R≤2 · F≥3',
test: ({ r, f }) => r <= 2 && f >= 3,
nextStep: 'Win-back: "We miss you" subject + a hand-picked vignette from past clicks.',
},
{
id: 'hibernating',
name: 'Hibernating',
icon: '🌙',
description: 'Disengaged across recency and frequency — re-permission or sunset.',
criteria: 'R≤2 · F≤2',
test: ({ r, f }) => r <= 2 && f <= 2,
nextStep: 'Final re-permission ask, then move off the active sending pool.',
},
// Catch-all so counts reconcile to pool size (recently joined, low frequency)
{
id: 'promising',
name: 'Promising',
icon: '🌱',
description: 'Recent opens but low frequency — newer or casual subscribers.',
criteria: 'R≥3 · F≤2',
test: ({ r, f }) => r >= 3 && f <= 2,
nextStep: 'Welcome-style nurture: brand story + memo-sample CTA.',
},
];
function bucketFor(score) {
for (const b of RFM_BUCKETS) {
if (b.test(score)) return b.id;
}
return 'promising';
}
// Score every contact, group, and return per-bucket counts + sample.
function rfmBuckets(pool, sampleSize = 8) {
const total = pool.length;
const groups = {};
for (const b of RFM_BUCKETS) groups[b.id] = [];
let sumR = 0, sumF = 0, sumM = 0;
const scored = pool.map(c => {
const s = rfmScore(c);
sumR += s.r; sumF += s.f; sumM += s.m;
return { contact: c, score: s };
});
// Sort by Champions/Loyal preference (highest R+F+M first), so the sample
// pulled per bucket is the strongest representative of that bucket.
scored.sort((a, b) => (b.score.r + b.score.f + b.score.m) - (a.score.r + a.score.f + a.score.m));
for (const row of scored) {
const id = bucketFor(row.score);
groups[id].push(row);
}
const buckets = RFM_BUCKETS.map(b => {
const rows = groups[b.id] || [];
const sample = rows.slice(0, sampleSize).map(({ contact, score }) => ({
email: contact.email,
type: contact.type,
lists: contact.lists,
lastOpen: contact.lastOpen,
lastClick: contact.lastClick,
opens: contact.opens || 0,
clicks: contact.clicks || 0,
r: score.r, f: score.f, m: score.m,
}));
return {
id: b.id,
name: b.name,
icon: b.icon,
description: b.description,
criteria: b.criteria,
nextStep: b.nextStep,
count: rows.length,
share: total ? rows.length / total : 0,
sample,
};
});
return {
total,
buckets,
summary: {
avgR: total ? +(sumR / total).toFixed(2) : 0,
avgF: total ? +(sumF / total).toFixed(2) : 0,
avgM: total ? +(sumM / total).toFixed(2) : 0,
},
thresholds: RFM_THRESHOLDS,
};
}
// ── segment store ────────────────────────────────────────────────────────────
function defaultSegments() {
return [
{
id: 'seg_trade_engaged',
name: 'Trade & Designers — engaged',
description: 'Interior designers / architects on the trade list who opened within 30 days.',
rules: [
{ field: 'type', op: 'is', value: 'trade' },
{ field: 'engagement', op: 'is', value: 'opened-last-30d' },
],
},
{
id: 'seg_retail_lapsed',
name: 'Retail — lapsed 90d',
description: 'Retail newsletter subscribers who have not opened in over 90 days — a win-back audience.',
rules: [
{ field: 'type', op: 'is', value: 'retail' },
{ field: 'engagement', op: 'is', value: 'lapsed-90d' },
],
},
{
id: 'seg_high_intent_memo',
name: 'High-intent — clicked memo CTA',
description: 'Anyone tagged with the memo-sample CTA who has clicked — ready for a trade consult ask.',
rules: [
{ field: 'tag', op: 'has', value: 'memo-cta' },
{ field: 'engagement', op: 'is', value: 'clicked' },
],
},
{
id: 'seg_hospitality_spec',
name: 'Hospitality / Commercial spec',
description: 'Contacts on the hospitality & commercial spec list — large-scale project audience.',
rules: [
{ field: 'list', op: 'in', value: 'Hospitality / Commercial Spec' },
],
},
];
}
function loadSegments() {
const v = readJson(SEG_FILE, null);
if (Array.isArray(v)) return v;
const seeded = defaultSegments();
writeJson(SEG_FILE, seeded);
return seeded;
}
function saveSegments(list) { writeJson(SEG_FILE, list); }
// Validate + normalize an incoming rule. Drops anything off-schema.
function cleanRules(raw) {
if (!Array.isArray(raw)) return [];
const out = [];
for (const r of raw) {
if (!r || !FIELDS[r.field]) continue;
const spec = FIELDS[r.field];
const op = spec.ops.includes(r.op) ? r.op : spec.ops[0];
let value = r.value;
if (Array.isArray(spec.values) && !spec.values.includes(value)) {
// for list/tag the value is free-ish (a list/tag name) — keep as string;
// for enumerated fields (type/engagement) reject unknown values.
if (r.field === 'type' || r.field === 'engagement') continue;
}
out.push({ field: r.field, op, value: value == null ? '' : String(value) });
}
return out;
}
// Attach a live estimatedSize to each segment (best-effort; uses current pool).
function withEstimates(segments, pool) {
return segments.map(s => ({
...s,
estimatedSize: pool ? evaluate(pool, s.rules, 0).count : (s.estimatedSize || 0),
}));
}
// ── module ─────────────────────────────────────────────────────────────────────
module.exports = {
id: 'segments',
title: 'Audience Segments',
icon: '👥',
// exported for reuse / testing
_evaluate: evaluate,
_matches: matches,
_rfmScore: rfmScore,
_rfmBuckets: rfmBuckets,
mount(router) {
const guard = (handler) => async (req, res) => {
try { await handler(req, res); }
catch (e) {
res.status(e.status && e.status < 500 ? e.status : 502)
.json({ error: e.message, detail: e.detail || null });
}
};
// GET /schema — fields, operators + value vocabularies for the rule builder
router.get('/schema', (_req, res) => {
const fields = Object.entries(FIELDS).map(([key, spec]) => ({
key, label: spec.label,
ops: spec.ops.map(o => ({ key: o, label: OP_LABELS[o] || o })),
// resolve value source: 'lists'|'tags' → vocab arrays, else inline enum
values: spec.values === 'lists' ? LISTS
: spec.values === 'tags' ? TAGS
: spec.values,
}));
res.json({ fields, lists: LISTS, tags: TAGS });
});
// GET /segments — saved segments with a live estimatedSize
router.get('/segments', guard(async (_req, res) => {
const segments = loadSegments();
const { pool, mock } = await contactPool();
res.json({ mock, segments: withEstimates(segments, pool) });
}));
// POST /segments — create/update (match on id); ?delete=id removes
router.post('/segments', guard(async (req, res) => {
let segments = loadSegments();
if (req.query.delete) {
const id = String(req.query.delete);
segments = segments.filter(s => s.id !== id);
saveSegments(segments);
const { pool, mock } = await contactPool();
return res.json({ ok: true, deleted: id, mock, segments: withEstimates(segments, pool) });
}
const b = req.body || {};
if (!b.name || !String(b.name).trim()) {
return res.status(400).json({ ok: false, error: 'name is required' });
}
const rules = cleanRules(b.rules);
const { pool, mock } = await contactPool();
const estimatedSize = evaluate(pool, rules, 0).count;
const entry = {
id: b.id && segments.some(s => s.id === b.id)
? String(b.id)
: 'seg_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name: String(b.name).trim().slice(0, 120),
description: String(b.description || '').slice(0, 400),
rules,
estimatedSize,
updatedAt: new Date().toISOString(),
};
const i = segments.findIndex(s => s.id === entry.id);
if (i >= 0) segments[i] = { ...segments[i], ...entry };
else segments.push(entry);
saveSegments(segments);
res.json({ ok: true, mock, entry, segments: withEstimates(segments, pool) });
}));
// GET /contacts/preview?segmentId= — preview a SAVED segment
router.get('/contacts/preview', guard(async (req, res) => {
const id = String(req.query.segmentId || '');
const seg = loadSegments().find(s => s.id === id);
if (!seg) return res.status(404).json({ error: 'segment not found' });
const { pool, mock } = await contactPool();
const result = evaluate(pool, seg.rules);
res.json({ mock, segmentId: id, name: seg.name, rules: seg.rules, ...result });
}));
// POST /preview — preview arbitrary rules (the live rule-builder path)
router.post('/preview', guard(async (req, res) => {
const rules = cleanRules((req.body || {}).rules);
const { pool, mock } = await contactPool();
const result = evaluate(pool, rules);
res.json({ mock, rules, ...result });
}));
// GET /rfm — auto-bucket contacts into Champions / Loyal / At-Risk /
// Hibernating / Promising using Recency-Frequency-Monetary-style scoring
// over email engagement (lastOpen, opens count, lastClick/clicks as M proxy).
router.get('/rfm', guard(async (_req, res) => {
const { pool, mock } = await contactPool();
const result = rfmBuckets(pool);
res.json({ mock, ...result });
}));
},
};