← back to Sample Followup Sweep

scripts/fmpro.mjs

278 lines

#!/usr/bin/env node
// FMPro write-back engine for the sample follow-up system.
//
// Two writes to the WALLPAPER (WALLPAPER2) memo records, keyed by the vendor's
// `vid` + the 10–60 day outstanding window:
//   • "Date Email Sent to Vendor after 10 Days" — stamped the day the follow-up email goes out.
//   • "Priority Memo Notes"                     — a plain-language note when the vendor replies.
//
// Reuses the filemaker-mcp client (FileMaker Cloud / Claris ID auth) so we never
// re-implement the Cognito handshake. Creds are read from ~/.claude.json's
// mcpServers.filemaker.env block (single source of truth) at runtime.
//
// CLI:
//   node scripts/fmpro.mjs backfill                       # stamp every already-sent vendor's outstanding SKUs
//   node scripts/fmpro.mjs stamp --vid DGD --date 08/14/2026
//   node scripts/fmpro.mjs note  --record 536324 --text "Discontinued"
//   node scripts/fmpro.mjs note  --vid DGD --sku DWKK123171 --text "Sending now"
//
// server.js spawns `stamp --vid <vid> --date <today>` after a successful send so
// the FileMaker field is recorded automatically (the gap this whole build closes).

import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dir, '..');

const DB = 'WALLPAPER';
const LAYOUT = 'Report for old memo samples';     // exposes vid, combo sku, today for client, Date WP Sample Sent
const FIELD_SENT = 'Date Email Sent to Vendor after 10 Days';
// Steve 8/25: on a real send, ALSO stamp the 2nd-request date. No dedicated "2nd request date"
// field is exposed to the Data API; this real date field on the same layout is the chosen target.
const FIELD_2ND_DATE = 'Date Sample Request Letter Sent';
const FIELD_NOTES = 'Priority Memo Notes';
const MIN_AGE_DAYS = 10;   // don't chase newer than 10 days
const MAX_AGE_DAYS = 60;   // >60 = dead lead, don't chase (Steve's rule)

// --- load FileMaker Cloud creds from the canonical MCP config, then the client ---
function loadFmEnv() {
  const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
  const env = cfg?.mcpServers?.filemaker?.env || {};
  for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) {
    if (!env[k]) throw new Error(`~/.claude.json mcpServers.filemaker.env.${k} missing`);
    process.env[k] = env[k];
  }
  process.env.FM_READONLY = '0'; // this engine writes
}
loadFmEnv();
const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');

// --- date helpers (FileMaker wants MM/DD/YYYY; dates are judged in Pacific time) ---
const pad = (n) => String(n).padStart(2, '0');
function fmtDate(d) { return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`; }
function ptDateFromISO(iso) {
  // Convert an ISO instant to a MM/DD/YYYY calendar date in America/Los_Angeles.
  const s = new Date(iso).toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles' }); // M/D/YYYY
  const [m, day, y] = s.split('/');
  return `${pad(m)}/${pad(day)}/${y}`;
}
function windowRange(today = new Date()) {
  const lo = new Date(today); lo.setDate(lo.getDate() - MAX_AGE_DAYS); // oldest still chased
  const hi = new Date(today); hi.setDate(hi.getDate() - MIN_AGE_DAYS); // newest chased (>10d)
  return `${fmtDate(lo)}...${fmtDate(hi)}`;
}

// --- match ONE page SKU to its live memo record, exactly ---
// Keyed by the exact DW# (`combo sku`) shown on the console — NOT vid (FileMaker text
// find is begins-with-word, so vid over-matches: "SAN"→SANDB, "GREEN"→unrelated rows).
// `==` forces an exact match; `"="` matches an EMPTY field, so already-stamped/fulfilled
// rows are excluded (idempotent). Disambiguated by request date when a SKU repeats.
async function matchPageSku(dw, requested, pass = 1) {
  // Require BOTH the exact DW# and the exact request date. A DW# alone maps to many
  // records (the live memo + blank pattern-masters + year-old orders); the request
  // date pins it to the one current memo. No request date = don't guess, skip.
  if (!dw || !String(dw).trim() || !requested) return [];
  // TK-11409 (Steve, 2026-09-10): the 2nd request must also update the WALLPAPER file.
  // pass 1 (first chase)  -> rows whose FIELD_SENT is still EMPTY.
  // pass 2 (the resend)   -> rows already stamped by pass 1 whose 2nd-request field is
  //                          still EMPTY. Keying pass 2 off `FIELD_SENT: '*'` (non-empty)
  //                          is what makes the resend visible at all: the old query hard-
  //                          coded `FIELD_SENT: '='` for every send, so on a resend it
  //                          matched ZERO records and silently wrote nothing.
  const query = {
    'combo sku': `==${dw}`,
    'today for client': `==${requested}`,   // exact page-row request date
    'Date WP Sample Sent': '=',             // still outstanding
    ...(pass === 2
      ? { [FIELD_SENT]: '*', [FIELD_2ND_DATE]: '=' }   // chased once, not yet 2nd-requested
      : { [FIELD_SENT]: '=' }),                        // not yet stamped
  };
  try {
    const { records } = await fm.findRecords(DB, LAYOUT, query, { limit: 50 });
    return records.map((r) => ({
      recordId: r.recordId,
      sku: r.fieldData['combo sku'] || '',
      client: (r.fieldData['company for client fileS'] || r.fieldData['Clients::Company'] || '').trim(),
      requested: r.fieldData['today for client'] || '',
    }));
  } catch (e) {
    if (e.fmCode === '401') return []; // no records found
    throw e;
  }
}

// --- plan/stamp one vendor from its exact page SKUs (fleet.json items) ---
async function planVendor(v, pass = 1) {
  // TK-11409 gate: pass 2 asserts "a 2nd request was sent", so it requires positive
  // evidence that one actually was. Returns a REASON, never a bare empty list — a silent
  // zero-row result is the exact failure this ticket exists to kill.
  if (pass === 2) {
    const ev = sendEvidence(v.slug);
    if (!ev.known) { v._skip = `NO SEND RECORD for ${v.slug} (${ev.addrs.join(', ') || 'no address'}) — cannot assert a 2nd request; refusing`; return []; }
    if (ev.count < 2) { v._skip = `only ${ev.count} recorded send(s) for ${v.slug} — no 2nd request happened; refusing`; return []; }
  }
  const seen = new Set();
  const rows = [];
  for (const it of (v.items || [])) {
    const matches = await matchPageSku(it.dw, it.initialReq || it.req, pass);
    for (const m of matches) {
      if (seen.has(m.recordId)) continue;
      seen.add(m.recordId);
      rows.push({ ...m, date: v.date, vid: v.vid, vendor: v.name });
    }
  }
  return rows;
}
async function stampVendor(v, pass = 1) {
  const rows = await planVendor(v, pass);
  const stamped = [];
  for (const r of rows) {
    // Pass 1 writes ONLY the 10-day chase date, so the 2nd-request field stays free as a
    // real slot. (It used to write both on the first send, which left request #2 nowhere
    // to record itself.) Pass 2 writes ONLY the 2nd-request field, preserving the 1st date.
    const fields = pass === 2 ? { [FIELD_2ND_DATE]: r.date } : { [FIELD_SENT]: r.date };
    const res = await fm.updateRecord(DB, LAYOUT, r.recordId, fields, { dryRun: false });
    if (res.committed) stamped.push(r);
  }
  // `skip` distinguishes "refused for lack of evidence" from "nothing left to do" —
  // both yield 0 rows, and conflating them is what made the original bug invisible.
  return { vid: v.vid, date: v.date, pass, count: stamped.length, found: rows.length, stamped, skip: v._skip || null };
}

// --- write a plain-language reply note to a specific memo record ---
async function noteRecord(recordId, text) {
  const res = await fm.updateRecord(DB, LAYOUT, recordId, { [FIELD_NOTES]: text }, { dryRun: false });
  return { recordId, text, committed: !!res.committed };
}
async function findRecordIdByVidSku(vid, sku) {
  const { records } = await fm.findRecords(DB, LAYOUT, { vid, 'combo sku': `==${sku}`, 'Date WP Sample Sent': '=' }, { limit: 10 });
  return records[0]?.recordId || null;
}

// --- fmpro-posted state (drives the console chip) ---
const POSTED = join(ROOT, 'data', 'fmpro-posted.json');
function readPosted() { try { return JSON.parse(readFileSync(POSTED, 'utf8')); } catch { return { byVid: {} }; } }
function writePosted(p) { mkdirSync(join(ROOT, 'data'), { recursive: true }); writeFileSync(POSTED, JSON.stringify(p, null, 2)); }
function recordPosted(vid, r) {
  const p = readPosted();
  const prev = p.byVid[vid] || { count: 0 };
  p.byVid[vid] = { date: r.date, count: (prev.count || 0) + r.count, lastPostedAt: new Date().toISOString() };
  writePosted(p);
}

// --- how many times has this vendor actually been emailed? (TK-11409) ---
// The evidence oracle for pass 2. DB field state is NOT contact history: rows stamped
// before 2026-08-25 (commit 32145b7, when the 2nd-request field started being written)
// carry a chase date with an EMPTY 2nd-request field purely because that field did not
// exist yet — 8 such legacy rows are live right now. Matching on field-emptiness alone
// would read those as "chased once, awaiting a 2nd request" and fabricate a 2nd-request
// date for a resend that never happened. So pass 2 additionally demands positive evidence
// from sent.json, the record of what was actually sent.
// Returns { count, addrs, known } — `known:false` means we have NO send record for this
// vendor at all, which is a DIFFERENT condition from "sent once" and must never be
// silently treated as "no work to do".
function sendEvidence(slug) {
  let contacts = {}, sent = {};
  try { contacts = JSON.parse(readFileSync(join(ROOT, 'data', 'contacts.json'), 'utf8')); } catch (e) {}
  try { sent = JSON.parse(readFileSync(join(ROOT, 'data', 'sent.json'), 'utf8')).byEmail || {}; } catch (e) {}
  const norm = (s) => String(s || '').toLowerCase().trim();
  const sentMap = {}; for (const [k, v] of Object.entries(sent)) sentMap[norm(k)] = v;
  const addrs = norm((contacts[slug] || {}).sample_email).split(',').map((a) => a.trim()).filter((a) => a.includes('@'));
  const hits = addrs.map((a) => sentMap[a]).filter(Boolean);
  if (!hits.length) return { count: 0, addrs, known: false };
  // The vendor was chased N times only if EVERY recipient received N — a resend goes to
  // the whole address list, so the minimum is the honest count.
  return { count: Math.min(...hits.map((h) => h.count || 1)), addrs, known: true };
}

// --- sent-vendor roster: fleet vendors whose contacts email is in the Sent snapshot ---
function sentVendors() {
  const fleet = JSON.parse(readFileSync(join(ROOT, 'data', 'fleet.json'), 'utf8')).vendors;
  const contacts = JSON.parse(readFileSync(join(ROOT, 'data', 'contacts.json'), 'utf8'));
  const sent = JSON.parse(readFileSync(join(ROOT, 'data', 'sent.json'), 'utf8')).byEmail || {};
  const norm = (s) => String(s || '').toLowerCase().trim();
  const sentMap = {}; for (const [k, v] of Object.entries(sent)) sentMap[norm(k)] = v;
  const out = [];
  for (const v of fleet) {
    const c = contacts[v.slug] || {};
    if (c.disposition === 'no-chase') continue;
    const addrs = norm(c.sample_email).split(',').map((a) => a.trim()).filter((a) => a.includes('@'));
    const hits = addrs.map((a) => sentMap[a]).filter(Boolean);
    if (!hits.length) continue;
    const lastISO = hits.map((h) => h.lastSent).sort().pop();
    out.push({ slug: v.slug, vid: v.vid, name: v.name, items: v.items || [], date: ptDateFromISO(lastISO) });
  }
  return out;
}

// ---------------- CLI ----------------
function arg(name) { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined; }
const cmd = process.argv[2];

if (cmd === 'plan') {
  // READ-ONLY: show exactly which sent-vendor records would be stamped, and with what date.
  // --pass 2 previews the RESEND (2nd-request) match set without writing anything.
  const planPass = String(arg('pass') || '1') === '2' ? 2 : 1;
  const vendors = sentVendors();
  const plan = [];
  const refused = [];
  for (const v of vendors) {
    const rows = await planVendor(v, planPass);
    if (v._skip) refused.push(v._skip);
    rows.forEach((r) => plan.push(r));
  }
  console.log(`PLAN (read-only, pass ${planPass}${planPass === 2 ? ' = 2nd request' : ' = first chase'}) — ${plan.length} record(s) would be stamped:\n`);
  if (refused.length) {
    console.log(`  REFUSED (${refused.length} vendor(s)) — no evidence a 2nd request was sent:`);
    for (const r of refused) console.log(`    · ${r}`);
    console.log('');
  }
  for (const p of plan) console.log(`  ${p.recordId}  ${p.date}  [${p.vid}] ${p.sku}  ${p.client}  (req ${p.requested})  — ${p.vendor}`);
  console.log(`\nJSON:`); console.log(JSON.stringify(plan));
} else if (cmd === 'backfill') {
  const vendors = sentVendors();
  console.log(`FMPro backfill — ${vendors.length} already-sent vendors · window ${windowRange()} (${MIN_AGE_DAYS}–${MAX_AGE_DAYS}d)\n`);
  let total = 0;
  for (const v of vendors) {
    try {
      const r = await stampVendor(v);
      if (r.count) recordPosted(v.vid, r);
      total += r.count;
      const note = r.found === 0 ? 'already clean / none outstanding' : `stamped ${r.count}/${r.found} with ${v.date}`;
      console.log(`  ${r.count ? '✓' : '·'} ${v.name.padEnd(34)} [${v.vid}]  ${note}`);
      r.stamped.forEach((s) => console.log(`       - ${s.sku}  ${s.client}  (req ${s.requested})`));
    } catch (e) {
      console.log(`  ✗ ${v.name} [${v.vid}]  ERROR: ${e.message}`);
    }
  }
  console.log(`\nDone. ${total} record(s) newly stamped. State → data/fmpro-posted.json`);
} else if (cmd === 'stamp') {
  // Single vendor, keyed by --slug (server.js passes this at send time). Matches
  // that vendor's exact page SKUs — never vid — so it can't over-match other vendors.
  const slug = arg('slug'); const date = arg('date') || fmtDate(new Date());
  // --pass 2 = this send was a RESEND (the 2nd request). Default 1 keeps the original
  // first-chase behaviour, so nothing that calls `stamp` without --pass changes.
  const pass = String(arg('pass') || '1') === '2' ? 2 : 1;
  if (!slug) { console.error('need --slug'); process.exit(1); }
  // TK-11255: slug is legitimately non-unique (declared aliases ship as items:0
  // stubs on another vendor's slug); prefer the row that actually has items
  // instead of depending on array order.
  const _fv = JSON.parse(readFileSync(join(ROOT, 'data', 'fleet.json'), 'utf8')).vendors.filter((x) => x.slug === slug);
  const fleetV = _fv.find((x) => x.items && x.items.length) || _fv[0];
  if (!fleetV) { console.error(`unknown --slug ${slug}`); process.exit(1); }
  const r = await stampVendor({ slug: fleetV.slug, vid: fleetV.vid, name: fleetV.name, items: fleetV.items, date }, pass);
  if (r.count) recordPosted(fleetV.vid, r);
  console.log(JSON.stringify(r));
} else if (cmd === 'note') {
  const text = arg('text'); if (!text) { console.error('need --text'); process.exit(1); }
  let recordId = arg('record');
  if (!recordId) { const vid = arg('vid'), sku = arg('sku'); if (!vid || !sku) { console.error('need --record OR --vid + --sku'); process.exit(1); } recordId = await findRecordIdByVidSku(vid, sku); }
  if (!recordId) { console.error('record not found'); process.exit(1); }
  console.log(JSON.stringify(await noteRecord(recordId, text)));
} else {
  console.log('usage: fmpro.mjs backfill | stamp --vid V --date MM/DD/YYYY | note (--record R | --vid V --sku S) --text "..."');
}