← back to Marketing Command Center
modules/profiles/index.js
540 lines
// Contact Profiles module — a unified 360° view of one contact for the Marketing
// Command Center. Read-only. Surfaces:
// • who they are (name, company, role, list memberships, tags, lifecycle stage)
// • email engagement timeline (campaign-level sent / open / click events)
// • segment memberships (resolved against saved segments from segments.json)
// • cross-channel placeholder: last site visit + last social touch
//
// Live data sources, when CTCT_ACCESS_TOKEN is present, are read via the v3 API
// for the contact roster; per-contact event streams are not first-class in CC v3,
// so the timeline is *derived* from lastOpen/lastClick + recent campaigns and is
// flagged with derived:true on each event. Cross-channel data is ALWAYS a
// placeholder (no GA4 / IG creds wired) and is flagged with placeholder:true.
//
// Self-contained per the MODULE CONTRACT (README.md). No writes to outside
// systems — pure read.
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);
// ── shared lookup vocab (kept aligned with segments + constant-contact mocks) ──
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'];
const ROLES = ['Principal Designer', 'Senior Designer', 'Architect', 'Project Manager',
'Studio Director', 'Procurement Lead', 'Spec Writer', 'Homeowner', 'Showroom Manager'];
const COUNTRIES = ['United States', 'United States', 'United States', 'Canada', 'United Kingdom', 'Mexico'];
const CITIES = [
['New York', 'NY'], ['Los Angeles', 'CA'], ['Chicago', 'IL'], ['Austin', 'TX'],
['Miami', 'FL'], ['Boston', 'MA'], ['Seattle', 'WA'], ['San Francisco', 'CA'],
['Atlanta', 'GA'], ['Denver', 'CO'], ['Charleston', 'SC'], ['Toronto', 'ON'],
['London', null], ['Mexico City', 'CDMX'],
];
const SITE_PAGES = [
{ slug: 'grasscloth-wallcoverings', title: 'Grasscloth Wallcoverings' },
{ slug: 'silk-wallcoverings', title: 'Silk Wallcoverings' },
{ slug: 'cork-wallcoverings', title: 'Cork Wallcoverings' },
{ slug: 'flocked-wallcoverings', title: 'Flocked Wallcoverings' },
{ slug: 'glass-bead-wallcoverings', title: 'Glass-Bead Wallcoverings' },
{ slug: 'trade-program', title: 'Trade Program' },
{ slug: 'memo-samples', title: 'Request Memo Samples' },
{ slug: 'lookbook-2026', title: '2026 Lookbook' },
{ slug: 'product/phillip-jeffries-silver-velvet', title: 'PJ Silver Velvet — product' },
{ slug: 'collections/hollywood-vinyls-vol-1', title: 'Hollywood Vinyls Vol. 1' },
];
const SOCIAL_PLATFORMS = ['instagram', 'pinterest', 'linkedin'];
// Mock email campaigns the timeline can reference (aligned with constant-contact's
// MOCK pool conceptually; we don't import to keep the module standalone).
const MOCK_CAMPAIGNS = [
{ id: 'mock-cmp-001', name: 'Spring Grasscloth Edit', sentAgoDays: 17 },
{ id: 'mock-cmp-101', name: 'Cork & Linen — May Memo', sentAgoDays: 36 },
{ id: 'mock-cmp-102', name: 'Metallic Murals Lookbook', sentAgoDays: 51 },
{ id: 'mock-cmp-103', name: 'Trade-Only Studio Visits', sentAgoDays: 67 },
{ id: 'mock-cmp-104', name: 'Hospitality Spec Highlights', sentAgoDays: 82 },
{ id: 'mock-cmp-105', name: 'Glass-Bead Drop', sentAgoDays: 104 },
];
// ── deterministic mock contact pool ────────────────────────────────────────────
// Seeded mulberry32 PRNG so the same install always sees the same contacts. Pool
// shape is a superset of what segments.js evaluates over so segment membership
// resolution is consistent. ~240 contacts is plenty for search demo without
// making the panel sluggish.
const POOL_SIZE = 240;
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;
};
}
let _pool = null;
function pool() {
if (_pool) return _pool;
const rnd = mulberry32(0xC0FFEE);
const pick = arr => arr[Math.floor(rnd() * arr.length)];
const studios = [
['atelier-noir', 'Atelier Noir'],
['maison-blanc', 'Maison Blanc'],
['verdant-interiors', 'Verdant Interiors'],
['harbor-hospitality', 'Harbor Hospitality Group'],
['lumiere-design', 'Lumière Design Co.'],
['oak-and-ash', 'Oak & Ash Studio'],
['studio-meridian', 'Studio Meridian'],
['thornfield-co', 'Thornfield & Co.'],
['cobalt-interiors', 'Cobalt Interiors'],
['gilded-room', 'The Gilded Room'],
['north-star-design', 'North Star Design'],
['ember-and-stone', 'Ember & Stone'],
['palette-house', 'Palette House'],
['crestwood-studio', 'Crestwood Studio'],
['ivory-lane', 'Ivory Lane Interiors'],
['monarch-interiors', 'Monarch Interiors'],
['saffron-studio', 'Saffron Studio'],
['westbrook-design', 'Westbrook Design Group'],
['vellum-co', 'Vellum Co.'],
['aria-spaces', 'Aria Spaces'],
];
const firstNames = ['Avery', 'Bea', 'Camille', 'Dante', 'Elena', 'Felix', 'Greta', 'Henrik',
'Iris', 'Jude', 'Kira', 'Leon', 'Mira', 'Nadia', 'Otto', 'Petra', 'Quinn', 'Ruth',
'Soren', 'Theo', 'Una', 'Vera', 'Wren', 'Xavi', 'Yael', 'Zara'];
const lastNames = ['Ashford', 'Bellamy', 'Castell', 'Devereux', 'Eastwood', 'Fontaine',
'Greaves', 'Holloway', 'Iverson', 'Janowicz', 'Kestrel', 'Lockhart', 'Marchetti',
'Norwood', 'Okafor', 'Pemberton', 'Quill', 'Rowan', 'Saint-Clair', 'Thackeray',
'Underhill', 'Vance', 'Whitlock', 'Yardley', 'Zborowski'];
const tlds = ['com', 'co', 'studio', 'design'];
const now = Date.now();
const DAY = 86400000;
const ago = d => d == null ? null : new Date(now - d * DAY).toISOString();
const out = [];
for (let i = 0; i < POOL_SIZE; i++) {
const [studioSlug, studioName] = studios[i % studios.length];
const isTrade = rnd() < 0.6;
const type = isTrade ? 'trade' : 'retail';
const first = pick(firstNames);
const last = pick(lastNames);
const role = isTrade
? pick(['Principal Designer', 'Senior Designer', 'Architect', 'Project Manager',
'Studio Director', 'Procurement Lead', 'Spec Writer', 'Showroom Manager'])
: pick(['Homeowner', 'Project Manager', 'Spec Writer']);
const [city, region] = pick(CITIES);
const country = region == null ? pick(['United Kingdom', 'United Kingdom', 'Mexico']) : pick(COUNTRIES);
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 recency mirrors the segments module distribution
let openAgo, clickAgo;
const r = rnd();
if (r < 0.18) { openAgo = null; clickAgo = null; }
else if (r < 0.40) { openAgo = 90 + Math.floor(rnd() * 200);
clickAgo = rnd() < 0.3 ? openAgo + Math.floor(rnd() * 10) : null; }
else { openAgo = Math.floor(rnd() * 60);
clickAgo = rnd() < 0.55 ? openAgo + Math.floor(rnd() * 8) : null; }
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 (clickAgo != null && clickAgo <= 30 && rnd() < 0.5 && !tags.includes('memo-cta')) tags.push('memo-cta');
if (isTrade && rnd() < 0.15 && !tags.includes('vip')) tags.push('vip');
if (role === 'Architect' && !tags.includes('architect')) tags.push('architect');
// opens/clicks counts correlate to recency (same logic the segments RFM uses)
let opens = 0, clicks = 0;
if (openAgo != null) {
if (openAgo <= 14) opens = 6 + Math.floor(rnd() * 9);
else if (openAgo <= 30) opens = 4 + Math.floor(rnd() * 6);
else if (openAgo <= 60) opens = 2 + Math.floor(rnd() * 4);
else if (openAgo <= 120) opens = 1 + Math.floor(rnd() * 3);
else opens = Math.floor(rnd() * 2);
}
if (clickAgo != null) {
if (clickAgo <= 14) clicks = 2 + Math.floor(rnd() * 5);
else if (clickAgo <= 30) clicks = 1 + Math.floor(rnd() * 4);
else if (clickAgo <= 60) clicks = 1 + Math.floor(rnd() * 2);
else clicks = Math.floor(rnd() * 2);
}
const handle = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}`;
const email = `${handle}@${studioSlug}.${pick(tlds)}`;
// last site visit + social: cross-channel placeholders. Most engaged contacts
// get a recent visit; lapsed ones may not.
const visited = openAgo != null && rnd() < 0.85;
const visitAgo = visited ? Math.max(0, (openAgo || 30) + Math.floor(rnd() * 8) - 4) : null;
const page = visited ? pick(SITE_PAGES) : null;
const hasSocial = rnd() < 0.55;
const social = hasSocial ? {
platform: pick(SOCIAL_PLATFORMS),
handle: `@${first.toLowerCase()}.${last.toLowerCase().replace(/[^a-z]/g, '')}`.slice(0, 30),
lastTouch: ago(Math.floor(rnd() * 120)),
action: pick(['liked a post', 'commented', 'saved a post', 'DM\'d', 'followed']),
} : null;
out.push({
id: `prof_${i.toString(36).padStart(3, '0')}`,
email,
name: `${first} ${last}`,
firstName: first,
lastName: last,
company: studioName,
role,
type,
location: { city, region, country },
lists,
tags,
lastOpen: ago(openAgo),
lastClick: ago(clickAgo),
opens,
clicks,
lastSiteVisit: visited ? {
at: ago(visitAgo),
page: page.slug,
pageTitle: page.title,
source: pick(['email', 'google', 'instagram', 'direct', 'pinterest']),
placeholder: true,
} : null,
social: social ? { ...social, placeholder: true } : null,
// a stable "joined" date in the past 0..900 days
since: ago(30 + Math.floor(rnd() * 870)),
});
}
_pool = out;
return out;
}
// ── live CTCT contact pull (best-effort) ───────────────────────────────────────
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;
}
function mapContact(c, listNameById, i) {
const email = (c.email_address && c.email_address.address) || c.email || '';
const lists = (c.list_memberships || []).map(id => listNameById[id]).filter(Boolean);
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;
const fn = c.first_name || '';
const ln = c.last_name || '';
const name = (`${fn} ${ln}`).trim() || email.split('@')[0];
const company = c.company_name || (email.split('@')[1] || '').split('.')[0] || '';
return {
id: `cc_${c.contact_id || c.id || i}`,
email,
name,
firstName: fn,
lastName: ln,
company,
role: c.job_title || '',
type: isTrade ? 'trade' : 'retail',
location: {
city: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].city) || '',
region: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].state) || '',
country: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].country) || '',
},
lists,
tags,
lastOpen,
lastClick,
opens: Number.isFinite(c.opens_count) ? c.opens_count : 0,
clicks: Number.isFinite(c.clicks_count) ? c.clicks_count : 0,
lastSiteVisit: null, // GA4 not wired — kept null for live mode
social: null, // IG/LI not wired — kept null for live mode
since: c.created_at || null,
};
}
async function contactPool() {
if (!live()) return { pool: pool(), 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=200&include=list_memberships,taggings,custom_fields');
const p = (data.contacts || []).map((c, i) => mapContact(c, listNameById, i)).filter(c => c.email);
if (!p.length) return { pool: pool(), mock: true };
return { pool: p, mock: false };
} catch {
return { pool: pool(), mock: true };
}
}
// ── derive an engagement timeline from a contact + campaign list ───────────────
// CC v3 doesn't expose per-contact event streams cheaply, so we synthesise a
// realistic timeline from lastOpen / lastClick / counts against the recent
// campaigns. Each event carries derived:true so the UI can disclose it.
function timelineFor(contact, campaigns = MOCK_CAMPAIGNS, useMock = true) {
const events = [];
const lastOpenAgo = daysSince(contact.lastOpen);
const lastClickAgo = daysSince(contact.lastClick);
for (const c of campaigns) {
const sentAt = isoDaysAgo(c.sentAgoDays);
events.push({ at: sentAt, type: 'sent', campaignId: c.id, campaignName: c.name, derived: useMock });
// "open" if the campaign was sent before lastOpen and within a ~30d window of it
const opened = isFiniteNum(lastOpenAgo) && c.sentAgoDays >= lastOpenAgo && c.sentAgoDays - lastOpenAgo <= 35;
if (opened) {
const openAtAgo = Math.max(0, c.sentAgoDays - Math.floor(Math.random() * 2));
events.push({ at: isoDaysAgo(openAtAgo), type: 'open', campaignId: c.id, campaignName: c.name, derived: true });
// "click" if there's a recent click that aligns
const clicked = isFiniteNum(lastClickAgo) && Math.abs(c.sentAgoDays - lastClickAgo) <= 35
&& contact.clicks > 0;
if (clicked) {
events.push({
at: isoDaysAgo(Math.max(0, openAtAgo - 0)),
type: 'click', campaignId: c.id, campaignName: c.name,
link: pickStable(['/grasscloth-wallcoverings', '/silk-wallcoverings', '/memo-samples', '/trade-program', '/lookbook-2026'], c.id),
derived: true,
});
}
}
}
// sort newest-first
events.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
return events;
}
function isFiniteNum(n) { return typeof n === 'number' && Number.isFinite(n); }
function daysSince(iso) {
if (!iso) return Infinity;
const t = Date.parse(iso);
return Number.isNaN(t) ? Infinity : (Date.now() - t) / 86400000;
}
function isoDaysAgo(d) {
if (d == null) return null;
return new Date(Date.now() - d * 86400000).toISOString();
}
// Deterministic pick — same campaign id → same link, so a re-render doesn't shuffle.
function pickStable(arr, key) {
let h = 0;
for (const ch of String(key)) h = (h * 31 + ch.charCodeAt(0)) | 0;
return arr[Math.abs(h) % arr.length];
}
// ── segment membership resolver ────────────────────────────────────────────────
// We reuse the segment rule schema rather than require()-ing the segments module
// (modules stay independent). Identical evaluation semantics.
function loadSegments() {
try { return JSON.parse(fs.readFileSync(SEG_FILE, 'utf8')) || []; }
catch { return []; }
}
function evalRule(contact, rule) {
if (!rule || !rule.field) return true;
const { field, op, value } = rule;
switch (field) {
case 'type':
return op === 'is_not' ? contact.type !== value : 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 o = daysSince(contact.lastOpen);
const c = daysSince(contact.lastClick);
switch (value) {
case 'opened-last-30d': return o <= 30;
case 'clicked-last-30d': return c <= 30;
case 'clicked': return c !== Infinity;
case 'lapsed-90d': return o > 90;
case 'never-opened': return o === Infinity;
default: return true;
}
}
default: return true;
}
}
function matchesAll(contact, rules) {
if (!Array.isArray(rules) || !rules.length) return true;
return rules.every(r => evalRule(contact, r));
}
function segmentMembershipsFor(contact) {
return loadSegments()
.filter(seg => matchesAll(contact, seg.rules || []))
.map(seg => ({ id: seg.id, name: seg.name, description: seg.description || '' }));
}
// ── lifecycle stage (a friendlier rollup than the raw engagement field) ────────
function lifecycleFor(contact) {
const o = daysSince(contact.lastOpen);
const c = daysSince(contact.lastClick);
if (o === Infinity) return { stage: 'Never engaged', tone: 'cold' };
if (c <= 30) return { stage: 'High-intent', tone: 'hot' };
if (o <= 30) return { stage: 'Engaged', tone: 'warm' };
if (o <= 60) return { stage: 'Recently active', tone: 'warm' };
if (o <= 90) return { stage: 'Cooling', tone: 'neutral' };
return { stage: 'Lapsed', tone: 'cold' };
}
// ── summary row (used by /search and /list) ────────────────────────────────────
function summarize(contact) {
return {
id: contact.id,
email: contact.email,
name: contact.name,
company: contact.company,
role: contact.role,
type: contact.type,
lastOpen: contact.lastOpen,
opens: contact.opens,
clicks: contact.clicks,
lifecycle: lifecycleFor(contact).stage,
};
}
// ── module ─────────────────────────────────────────────────────────────────────
module.exports = {
id: 'profiles',
title: 'Contact Profiles',
icon: '👤',
// exported for tests
_pool: pool,
_timelineFor: timelineFor,
_segmentMembershipsFor: segmentMembershipsFor,
_lifecycleFor: lifecycleFor,
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 /list — recent / most-engaged contacts (default landing list)
router.get('/list', guard(async (req, res) => {
const { pool, mock } = await contactPool();
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 25, 1), 100);
// sort by lastOpen desc (most recently engaged first)
const sorted = [...pool].sort((a, b) =>
(Date.parse(b.lastOpen || 0) || 0) - (Date.parse(a.lastOpen || 0) || 0));
res.json({
mock,
total: pool.length,
contacts: sorted.slice(0, limit).map(summarize),
});
}));
// GET /search?q=... — case-insensitive search across email/name/company/role/tags
router.get('/search', guard(async (req, res) => {
const q = String(req.query.q || '').trim().toLowerCase();
const { pool, mock } = await contactPool();
if (!q) {
return res.json({ mock, query: '', total: pool.length, count: 0, contacts: [] });
}
const hits = pool.filter(c => {
const hay = [
c.email, c.name, c.company, c.role,
...(c.tags || []),
...(c.lists || []),
].join(' ').toLowerCase();
return hay.includes(q);
});
res.json({
mock,
query: q,
total: pool.length,
count: hits.length,
contacts: hits.slice(0, 25).map(summarize),
});
}));
// GET /contact?email=... OR /contact?id=...
// Full 360 view: profile, lifecycle, lists, tags, segment memberships,
// engagement timeline, last site visit, social cross-channel.
router.get('/contact', guard(async (req, res) => {
const email = String(req.query.email || '').trim().toLowerCase();
const id = String(req.query.id || '').trim();
if (!email && !id) {
return res.status(400).json({ error: 'email or id is required' });
}
const { pool, mock } = await contactPool();
const contact = pool.find(c =>
(id && c.id === id) || (email && (c.email || '').toLowerCase() === email));
if (!contact) return res.status(404).json({ error: 'contact not found' });
const lifecycle = lifecycleFor(contact);
const segments = segmentMembershipsFor(contact);
const timeline = timelineFor(contact, MOCK_CAMPAIGNS, mock);
// condense timeline into top-line counts the UI uses for headline stats
const counts = timeline.reduce((a, e) => { a[e.type] = (a[e.type] || 0) + 1; return a; },
{ sent: 0, open: 0, click: 0 });
res.json({
mock,
contact: {
id: contact.id,
email: contact.email,
name: contact.name,
company: contact.company,
role: contact.role,
type: contact.type,
location: contact.location,
since: contact.since,
lists: contact.lists,
tags: contact.tags,
opens: contact.opens,
clicks: contact.clicks,
lastOpen: contact.lastOpen,
lastClick: contact.lastClick,
},
lifecycle,
segments,
timeline,
timelineCounts: counts,
crossChannel: {
lastSiteVisit: contact.lastSiteVisit,
social: contact.social,
// explicit so the UI can render a "wire GA4 / IG to fill these in" note
note: 'Cross-channel signals are placeholders — wire GA4 + social tokens to populate.',
placeholder: true,
},
});
}));
},
};