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