← back to Sample Followup Sweep

scripts/send-reconciled.mjs

176 lines

#!/usr/bin/env node
// Reconciled sample-follow-up DRAFTER (Steve 8/20).
// Pulls outstanding memos in the "fill-in-since-last-send / 10-day-floor" window and DRAFTS one
// letter per vendor into info@ Drafts (NEVER auto-sends). Steve reviews + sends from Drafts.
// Letter = intro + "Attn / processed by: <vendor processor>" + Ref#·Date Ordered·Manufacturer# table.
//   WIN env overrides the FileMaker date range (default = today's fill-in window).
import { readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ROOT = join(homedir(), 'Projects/sample-followup-sweep');
const { georgeRequest } = require(join(ROOT, 'lib/george-transport.js'));
// TK-12085: this drafter used to carry its OWN hardcoded 10-vendor map (V below) that had
// diverged from contacts.json — the exact failure mode lib/slug-aliases.cjs's header warns
// about. Result: 68 overdue rows were blocked "no vendor map" for vendors contacts.json
// already knows (MDC, QUA, SAN, VAH, ASTSAND, BAR, KRA, PAUL, SAND, RBL, INN, TWI, 1838 ...).
// Fix: the curated V stays (for its per-vendor `proc` names), but any vid NOT in V now FALLS
// BACK to contacts.json via the same slugForVid bridge scheduled-run.mjs uses — ONE source of
// truth. no-chase vendors are honoured (not drafted); an unresolvable vid still reports cleanly.
const { slugForVid, normVid } = require(join(ROOT, 'lib/slug-aliases.cjs'));
const { suppressChase } = require(join(ROOT, 'lib/sweep.js'));   // TK-12105 never-false-chase guard
const GUARD_LAYOUT = 'Report for old memo samples';              // has entered + WP Sample Sent + Letter Sent
const GUARD_FIELD_LETTER = 'Date Sample Request Letter Sent';   // Steve's authoritative "10-day letter sent" stamp
const CONTACTS = (() => { try { return JSON.parse(readFileSync(join(ROOT, 'data/contacts.json'), 'utf8')); } catch { return {}; } })();
// Resolve a raw FileMaker vid → { name, to, acct, proc, needsConfirm } | { noChase, name } | null.
function resolveVendor(rawVid) {
  const key = normVid(rawVid);                         // strips stray CR/whitespace (KRA\r -> KRA)
  const merged = (key === 'BM' || key === 'GREEN') ? 'BMGREEN' : key;
  if (V[merged]) return { ...V[merged], needsConfirm: false };   // curated wins (keeps proc names)
  const c = CONTACTS[slugForVid(key)];
  if (!c) return null;                                 // genuinely unmapped -> report "no vendor map"
  if (c.disposition === 'no-chase') return { noChase: true, name: c.name || key };
  const to = c.sample_email || c.main_email;
  if (!to || !c.account_number) return null;           // incomplete -> do not draft a half-formed letter
  return { name: c.name || key, to, acct: c.account_number,
    proc: (c.name ? c.name + ' ' : '') + 'sample/order desk', needsConfirm: !c.sample_email || !!c.needs_confirm };
}
// Rolling window: memos that crossed the 10-day follow-up floor within the last CATCHUP days (default 7),
// so a daily scheduled run always covers any gap. Env WIN overrides for a manual one-off. (Draft-dedup
// below stops the same memo being drafted twice across runs.)
const _pad = (n) => String(n).padStart(2, '0');
const _fmt = (d) => `${_pad(d.getMonth() + 1)}/${_pad(d.getDate())}/${d.getFullYear()}`;
// Steve 2026-09-22 (TK-12013/TK-12014): full active window [today-MAX_AGE .. today-MIN_AGE] inclusive,
// NOT a narrow rolling catch-up band — so no older still-outstanding memo is ever dropped. Draft-dedup
// (reconciled-draft-ledger keyed by vendor+combo-sku) makes re-covering already-drafted memos a no-op.
// TK-12091 (Steve 2026-09-23): DAY-OF-WEEK-AWARE window (mirrors scheduled-run.mjs windowRange).
// A follow-up fires the day a sample's Entered date hits its 10th CALENDAR day, mapped onto the
// Tue-Fri run schedule so every landing-day is chased exactly once:
//   • TUESDAY → 4-day catch-up: Entered ∈ [today-13 .. today-10] (covers Sat/Sun/Mon/Tue landings)
//   • WED/THU/FRI (+ any manual off-day) → STRICT single day: Entered == today-10
// hi is always today-10; only Tuesday widens the low end. WIN fully overrides; WIDEN_DAYS>0 is an
// explicit manual recovery net that widens the low end further (dedup makes any overlap safe).
const _MIN_AGE = 10;
const _now = new Date();
const _span = Math.max((_now.getDay() === 2 ? 3 : 0), Number(process.env.WIDEN_DAYS) || 0);
const _hi = new Date(); _hi.setDate(_hi.getDate() - _MIN_AGE);              // today-10
const _lo = new Date(); _lo.setDate(_lo.getDate() - _MIN_AGE - _span);      // today-10-span
const WIN = process.env.WIN || `${_fmt(_lo)}...${_fmt(_hi)}`;

// --- FileMaker: pull outstanding (Date WP Sample Sent empty) in WIN, group by vid ---
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
const fenv = cfg?.mcpServers?.filemaker?.env || {};
for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) process.env[k] = fenv[k];
process.env.FM_READONLY = '1';
const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
const A = (await fm.findRecords('WALLPAPER', 'REPORT ON SAMPLES ORDERED',
  { 'Date WP Sample Sent': '=', 'today for client': WIN }, { limit: 1200 }).catch(() => ({ records: [] }))).records;
const snap = {};
for (const r of A) { const d = r.fieldData; const v = String(d.vid || '').toUpperCase(); if (!v) continue;
  (snap[v] = snap[v] || []).push({ mfr: (d['Mfr Pattern'] || '').trim(), sku: d['combo sku'] || '', req: d['today for client'] || '' }); }
writeFileSync(join(ROOT, 'data/outstanding-snapshot.json'), JSON.stringify(snap, null, 2));

const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const SHIP = { name: 'Designer Wallcoverings', line1: '15442 Ventura Blvd. #102', csz: 'Sherman Oaks, CA 91403', phone: '1-888-373-4564' };
// Sent-verified addresses + processor (person at vendor who handled the order), per vendor
const V = {
  YOR:    { name: 'York', to: 'RiveraH@yorkwall.com, orders@yorkwall.com', acct: '34328', proc: 'York Sample Dept (order-thru-website desk)' },
  BRE:    { name: 'Brewster', to: 'brewsterorders@brewp.com', acct: '6003784', proc: 'Brewster Orders desk' },
  BMGREEN:{ name: 'Greenland / bmwallpaper', to: 'sales@bmwallpaper.com', acct: 'On File', proc: 'Nora (NORA & GREENLAND)' },
  OSB:    { name: 'Osborne & Little', to: 'Polancol@oalusa.com', acct: '1433803', proc: 'Leandra Polanco' },
  KRA:    { name: 'Kravet', to: 'matt.schoffman@kravet.com', acct: '10087117', proc: 'Matt Schoffman' },
  ROMO:   { name: 'Romo', to: 'fawn.bowen@romo.com', acct: '223641', proc: 'Fawn Bowen (Sampling Coordinator)' },
  SANDB:  { name: 'Sandberg (via Gimmersta)', to: 'support@gimmersta.com', acct: 'On File', proc: 'Alva' },
  WQ:     { name: 'WallQuest / Malibu', to: 'orders@wallquest.com', acct: '651317', proc: 'WallQuest Orders desk' },
  THIB:   { name: 'Thibaut', to: 'cathy.dy@thibautdesign.com', acct: '0108751', proc: 'Cathy Dy' },
  ARTE:   { name: 'Arte (@ Egg & Dart)', to: 'Therese@egg-and-dart.com', acct: 'On File', proc: 'Therese Stachowiak' },
};
// merge BM + GREEN under one bmwallpaper letter. TK-12085: normalise the vid (strip stray CR/
// whitespace) BEFORE grouping so 'KRA\r' folds into 'KRA' instead of becoming its own unmapped key.
const items = {};
for (const [vid, arr] of Object.entries(snap)) { const nv = normVid(vid); const key = (nv === 'BM' || nv === 'GREEN') ? 'BMGREEN' : nv; (items[key] = items[key] || []).push(...arr); }

// TK-12105 NEVER-FALSE-CHASE guard — before drafting any sku, pull ALL FileMaker records for that
// combo sku and drop the row if the sample already arrived on a duplicate same-entered-date record,
// or a 10-day letter was already sent. Fail-open (empty siblings => keep); human batch-send gate +
// 14-day Gmail anti-dup are backstops so an FM hiccup can never silently drop a real chase.
const guardSuppressed = [];
{
  const skus = [...new Set(Object.values(items).flat().map((r) => r.sku).filter(Boolean))];
  const sib = {};
  for (const sku of skus) {
    const rr = await fm.findRecords('WALLPAPER', GUARD_LAYOUT, { 'combo sku': sku }, { limit: 50 }).catch(() => ({ records: [] }));
    sib[sku] = (rr.records || []).map((x) => ({
      entered: x.fieldData['today for client'] || '',
      wpSampleSent: x.fieldData['Date WP Sample Sent'] || '',
      letterSent: x.fieldData[GUARD_FIELD_LETTER] || '',
    }));
  }
  for (const [key, rows] of Object.entries(items)) {
    const kept = [];
    for (const r of rows) {
      const g = suppressChase(r.req, sib[r.sku] || []);
      if (g.suppress) guardSuppressed.push({ key, sku: r.sku, mfr: r.mfr, reason: g.reason });
      else kept.push(r);
    }
    if (kept.length) items[key] = kept; else delete items[key];
  }
}

function letter(v, rows) {
  // Steve 8/20: do NOT show our internal Ref # (DW SKU) to the vendor — Date Ordered + Mfr # only.
  const tbl = rows.map(r => `<tr style="border-bottom:1px solid #eee"><td style="padding:5px 18px 5px 0;white-space:nowrap">${esc(r.req || '—')}</td><td style="padding:5px 0">${esc(r.mfr)}</td></tr>`).join('\n');
  return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5">
<p>We are following up on memo samples we have not yet received (ordered ~10 days ago).</p>
<p><strong>Our account number is ${esc(v.acct)}</strong></p>
<p><strong>Attn / processed by:</strong> ${esc(v.proc)} — please route these to whoever handled the original order.</p>
<table style="border-collapse:collapse;font-size:14px;margin:8px 0">
<tr style="text-align:left;border-bottom:2px solid #333"><th style="padding:5px 18px 5px 0">Date Ordered</th><th style="padding:5px 0">Manufacturer #</th></tr>
${tbl}
</table>
<p>Ship to:<br>${esc(SHIP.name)}<br>${esc(SHIP.line1)}<br>${esc(SHIP.csz)}<br>${esc(SHIP.phone)}</p>
<p><strong>Sidemark: Samples ASAP</strong></p>
<p>Thank you!<br>Showroom Manager</p>
<p style="font-size:11px;color:#666">Designer Wallcoverings · 15442 Ventura Blvd. #102 · Sherman Oaks, CA 91403<br>
To stop receiving sample follow-up emails, <a href="mailto:info@designerwallcoverings.com?subject=Unsubscribe%20from%20sample%20follow-ups">unsubscribe here</a>.</p></div>`;
}
// George draft (account=info, /api/drafts — no send-approval token)
const genv = k => { for (const f of ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env']) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
let auth = genv('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
auth = 'Basic ' + Buffer.from(auth).toString('base64');
async function draft(to, subject, body) {
  const result = await georgeRequest({ path: '/api/drafts', payload: { account: 'info', to, subject, body }, headers: { Authorization: auth } });
  try { const parsed = JSON.parse(result.body); return { ok: !!parsed.success, id: parsed.draftId || '', detail: parsed.success ? '' : result.body.slice(0, 80) }; }
  catch { return { ok: false, id: '', detail: result.body.slice(0, 80) }; }
}

const DO = process.argv.includes('--draft');
// Draft-dedup: never re-draft a memo (keyed by vendor+combo-sku) that a prior run already drafted,
// so a daily scheduled run over a rolling window can't pile up duplicate drafts.
const LEDGER = join(ROOT, 'data/reconciled-draft-ledger.json');
let ledger = {}; try { ledger = JSON.parse(readFileSync(LEDGER, 'utf8')); } catch {}
const stamp = _fmt(new Date());
console.log(`Reconciled follow-up — window ${WIN} — ${DO ? 'DRAFT into info@' : 'DRY-RUN (add --draft to create)'}\n`);
for (const [key, rows] of Object.entries(items)) {
  const v = resolveVendor(key);
  if (!v) { console.log(`  ? no vendor map for ${key} (${rows.length})`); continue; }
  if (v.noChase) { console.log(`  ⊘ ${(v.name||key).padEnd(26)} — no-chase vendor (${rows.length}, not drafted)`); continue; }
  const fresh = rows.filter(r => !ledger[`${key}:${r.sku}`]);
  if (!fresh.length) { console.log(`  ⊘ ${v.name.padEnd(26)} — all ${rows.length} already drafted (skip)`); continue; }
  const subj = `Sample Follow-Up — Outstanding Memos (Acct ${v.acct}) — Designer Wallcoverings`;
  const cflag = v.needsConfirm ? '  [CONFIRM recipient]' : '';
  if (!DO) { console.log(`  · ${v.name.padEnd(26)} → ${v.to.padEnd(40)} (${fresh.length})${cflag} refs: ${fresh.map(r => r.sku).join(', ')}`); continue; }
  // TK-12107: for a CONFIRM-RECIPIENT (needs_confirm) vendor, prepend an INTERNAL banner so Steve
  // verifies/fixes the To: and DELETES the line before sending — never silently send to a maybe-wrong
  // desk. The subject stays clean (vendor-facing); the banner is a loud in-draft note.
  const banner = v.needsConfirm
    ? `<div style="background:#fff3cd;border:1px solid #ffc107;padding:8px 12px;margin:0 0 14px;font-size:13px;color:#7a5b00"><strong>⚠ INTERNAL — CONFIRM RECIPIENT before sending.</strong> Verify the To: address (<strong>${esc(v.to)}</strong>) is the correct sample desk for this vendor, then DELETE this line. (Resolved from correspondence, not a confirmed sample desk.)</div>`
    : '';
  const res = await draft(v.to, subj, banner + letter(v, fresh));
  console.log(`  ${res.ok ? '✎' : '✗'} ${v.name.padEnd(26)} → ${v.to.padEnd(40)} (${fresh.length})${cflag} ${res.ok ? 'draftId=' + res.id : res.detail}`);
  if (res.ok) for (const row of fresh) ledger[`${key}:${row.sku}`] = v.needsConfirm ? `⚠ CONFIRM RECIPIENT ${stamp}` : stamp;
}
if (guardSuppressed.length) {
  console.log(`\n\u{1F6E1} Never-false-chase guard suppressed ${guardSuppressed.length} SKU(s) (already arrived / 10-day letter already sent):`);
  for (const s of guardSuppressed) console.log(`  ⊘ [${s.key}] ${s.sku} ${s.mfr} — ${s.reason}`);
}
if (DO) writeFileSync(LEDGER, JSON.stringify(ledger, null, 2));