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