← back to Sample Followup Sweep
sample-followup: FIX 2nd-request stamping — was silently stamping nothing
5ab8a3d9c590fa17f0ac093fd8d53611ea91ded7 · 2026-09-01 12:37:23 -0700 · Steve
The Sent-poller matched the subject account # against FileMaker's account field,
but that field is a DW-internal sequence # (1062xxx), not the vendor account — so
6/25 processed sends stamped ZERO records while the ledger filled up (looked healthy,
did nothing). Rewrote matching: recipient email -> candidate vids (all slug variants,
not last-wins) -> stamp ONLY records whose Manufacturer # actually appears in the SENT
letter body. Body-presence is precise + immune to shared-desk aliasing (Christian
Lacroix via Osborne desk, Anna French via Thibaut desk stamp only if truly in the
letter). Also switched the search phrase to the no-hyphen 'Outstanding Memos' (less
flaky than 'Sample Follow-Up'). Stamps Date Sample Request Letter Sent +
Date Email Sent to Vendor after 10 Days (no dedicated 2nd-Request field exists via API).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M scripts/watch-sent-stamp.mjs
Diff
commit 5ab8a3d9c590fa17f0ac093fd8d53611ea91ded7
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Sep 1 12:37:23 2026 -0700
sample-followup: FIX 2nd-request stamping — was silently stamping nothing
The Sent-poller matched the subject account # against FileMaker's account field,
but that field is a DW-internal sequence # (1062xxx), not the vendor account — so
6/25 processed sends stamped ZERO records while the ledger filled up (looked healthy,
did nothing). Rewrote matching: recipient email -> candidate vids (all slug variants,
not last-wins) -> stamp ONLY records whose Manufacturer # actually appears in the SENT
letter body. Body-presence is precise + immune to shared-desk aliasing (Christian
Lacroix via Osborne desk, Anna French via Thibaut desk stamp only if truly in the
letter). Also switched the search phrase to the no-hyphen 'Outstanding Memos' (less
flaky than 'Sample Follow-Up'). Stamps Date Sample Request Letter Sent +
Date Email Sent to Vendor after 10 Days (no dedicated 2nd-Request field exists via API).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/watch-sent-stamp.mjs | 92 +++++++++++++++++++++++++++++++-------------
1 file changed, 65 insertions(+), 27 deletions(-)
diff --git a/scripts/watch-sent-stamp.mjs b/scripts/watch-sent-stamp.mjs
index 8e0694e..e3a1e82 100644
--- a/scripts/watch-sent-stamp.mjs
+++ b/scripts/watch-sent-stamp.mjs
@@ -21,7 +21,8 @@ const MAX_AGE_DAYS = 60; // >60 = dead lead, never chased
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 = '0';
+const DRY_RUN = process.argv.includes('--dry') || process.env.DRY_RUN === '1';
+process.env.FM_READONLY = DRY_RUN ? '1' : '0';
const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
const readJSON = (f, d) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return d; } };
@@ -41,21 +42,58 @@ const genv = k => { for (const f of ['/Users/macstudio3/Projects/Designer-Wallco
let auth = genv('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
auth = 'Basic ' + Buffer.from(auth).toString('base64');
function gsearch(q) { return new Promise(res => { const p = '/api/messages?account=info&maxResults=30&q=' + encodeURIComponent(q); const rq = http.request({ host: '127.0.0.1', port: 9850, path: p, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { try { const j = JSON.parse(d); res(j.messages || (Array.isArray(j) ? j : [])); } catch { res([]); } }); }); rq.on('error', () => res([])); rq.end(); }); }
+function gget(id) { return new Promise(res => { const rq = http.request({ host: '127.0.0.1', port: 9850, path: `/api/messages/${id}?account=info`, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { try { res(JSON.parse(d)); } catch { res({}); } }); }); rq.on('error', () => res({})); rq.end(); }); }
-// Stamp the outstanding, NOT-YET-STAMPED memos for one account, in the rolling chase window.
-// Idempotent: FIELD_SENT empty ('=') filter means a memo already stamped is never re-stamped
-// (so re-processing / overlapping emails can't move the 2nd-request date forward). LAYOUT carries
-// account + Date WP Sample Sent + FIELD_SENT + FIELD_2ND_DATE, so no cross-layout hop is needed.
-async function stampAccount(acct, win, today) {
- const rows = (await fm.findRecords(DB, LAYOUT,
- { account: `==${acct}`, 'Date WP Sample Sent': '=', [FIELD_SENT]: '=', 'today for client': win },
- { limit: 300 }).catch(() => ({ records: [] }))).records;
- const stamped = [];
- for (const r of rows) {
- // Steve 8/25: on a real send stamp BOTH the dedup guard (Date Email Sent…) AND the
- // 2nd-request date (Date Sample Request Letter Sent — the chosen API-reachable date field).
- const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }).catch(() => ({}));
- if (res.committed) stamped.push((r.fieldData['Mfr Pattern'] || r.recordId));
+// Steve 9/01 FIX: identify the vendor by the follow-up's RECIPIENT EMAIL → vid, NOT the subject
+// account #. FileMaker's `account` field is a DW-internal sequence #, not the vendor account, so the
+// old account-match stamped nothing (ledger: 6/25 matched 0). Email→vid also covers "On File" vendors.
+// One vendor email can map to MULTIPLE vid variants (Thibaut THI/THIB, Osborne DGD/OSB); the records
+// may sit under any of them, so map email → the SET of candidate vids and stamp across all of them.
+function buildEmailVid() {
+ const fleet = readJSON(join(ROOT, 'data/fleet.json'), { vendors: [] }).vendors;
+ const contacts = readJSON(join(ROOT, 'data/contacts.json'), {});
+ // a slug can map to MULTIPLE fleet vids (thib → THIB, ANNA, ANNA FRENCH); keep ALL, not last-wins.
+ const slugVids = {}; for (const v of fleet) { if (!v.slug) continue; (slugVids[v.slug] = slugVids[v.slug] || new Set()).add(String(v.vid || '').toUpperCase()); }
+ const map = {};
+ for (const [slug, c] of Object.entries(contacts)) {
+ const vids = slugVids[slug]; if (!vids || !c) continue;
+ for (const field of [c.sample_email, c.main_email]) for (const one of String(field || '').toLowerCase().split(/[,;]\s*/)) { const t = one.trim(); if (t) { const set = (map[t] = map[t] || new Set()); for (const v of vids) set.add(v); } }
+ }
+ return map;
+}
+const EMAIL_VID = buildEmailVid();
+function vidsForRecipient(to) {
+ const out = new Set();
+ for (const e of (String(to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || [])) if (EMAIL_VID[e]) for (const v of EMAIL_VID[e]) out.add(v);
+ return [...out];
+}
+
+// Stamp a vendor's outstanding, NOT-YET-STAMPED memos by VID. Set-difference over recordId (stable
+// across layouts — the proven scheduled-run pattern): layout A 'REPORT ON SAMPLES ORDERED' has vid;
+// layout B 'Report for old memo samples' has the stamp fields. Idempotent (already-stamped excluded).
+async function stampVids(vids, bodyText, win, today) {
+ // PRECISE scope: stamp a vendor record ONLY if its Manufacturer # actually appears in the SENT
+ // letter body. This is immune to shared-desk aliasing (Christian Lacroix via Osborne's desk, Anna
+ // French via Thibaut's desk) — a sibling vid's item is stamped only if it was really in the letter.
+ const bodyNorm = String(bodyText || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').toLowerCase();
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
+ const Bs = (await fm.findRecords(DB, LAYOUT,
+ { [FIELD_SENT]: '*', 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 800 }).catch(() => ({ records: [] }))).records;
+ const already = new Set(Bs.map(r => String(r.recordId)));
+ const stamped = [], done = new Set();
+ for (const vid of vids) {
+ const A = (await fm.findRecords(DB, 'REPORT ON SAMPLES ORDERED',
+ { vid: `==${vid}`, 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 300 }).catch(() => ({ records: [] }))).records;
+ for (const r of A) {
+ const id = String(r.recordId); if (already.has(id) || done.has(id)) continue; // already stamped / dedup
+ const mfr = (r.fieldData['Mfr Pattern'] || '').trim(); if (!mfr) continue;
+ if (!bodyNorm.includes(norm(mfr))) continue; // only items PRINTED IN the sent letter
+ done.add(id);
+ if (DRY_RUN) { stamped.push(mfr + ' [dry]'); continue; }
+ // Steve 8/25: stamp BOTH the dedup guard (Date Email Sent…) AND the 2nd-request date.
+ const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }).catch(() => ({}));
+ if (res.committed) stamped.push(mfr);
+ }
}
return stamped;
}
@@ -65,22 +103,22 @@ async function pass() {
const win = chaseWindow();
const today = fmt(new Date()); // per-pass so a midnight-crossing loop stamps the real date
// NB: the em-dash in the full subject breaks Gmail matching over HTTP — match the ASCII prefix.
- // newer_than:2d so a poll gap never loses a send; the per-message ledger guards against re-stamping.
- const msgs = await gsearch('in:sent subject:"Sample Follow-Up" newer_than:2d');
- if (process.env.DEBUG) console.log(` [debug] gsearch → ${msgs.length} msg(s); win ${win}; ledger has ${Object.keys(seen).length}`);
+ // newer_than:2d so a poll gap never loses a send; the per-message ledger guards against re-work.
+ const msgs = await gsearch('in:sent subject:"Outstanding Memos" newer_than:3d'); // no hyphen → less flaky than "Sample Follow-Up"
+ if (process.env.DEBUG) console.log(` [debug] gsearch → ${msgs.length} msg(s); win ${win}; ledger has ${Object.keys(seen).length}${DRY_RUN ? ' · DRY-RUN' : ''}`);
let acted = 0;
for (const m of msgs) {
- if (seen[m.id]) continue;
- const acctM = String(m.subject || '').match(/Acct\s*(\d+)/i); // subject = "…(Acct NNNNN)"
- if (!acctM) { seen[m.id] = { skip: 'no acct in subject', subject: m.subject || '', to: m.to || '' }; continue; }
- const acct = acctM[1];
- const stamped = await stampAccount(acct, win, today);
- seen[m.id] = { acct, stamped: stamped.length, patterns: stamped, to: m.to || '', at: new Date().toISOString() };
+ if (seen[m.id] && !DRY_RUN) continue;
+ const vids = vidsForRecipient(m.to); // recipient email → candidate vendor vids
+ if (!vids.length) { if (!DRY_RUN) seen[m.id] = { skip: 'no vid for recipient', to: m.to || '', subject: m.subject || '' }; console.log(` · no vid mapping for ${m.to || '(no to)'}`); continue; }
+ const full = await gget(m.id); // fetch the sent letter body
+ const stamped = await stampVids(vids, full.body || '', win, today);
+ if (!DRY_RUN) seen[m.id] = { vids, stamped: stamped.length, patterns: stamped, to: m.to || '', at: new Date().toISOString() };
acted += stamped.length;
- console.log(` ✓ SENT → ${m.to || ''} (Acct ${acct}) → stamped ${stamped.length} memo(s) [${stamped.join(' + ')}] 2ndReq=${today}`);
+ console.log(` ${DRY_RUN ? '·' : '✓'} SENT → ${m.to || ''} [${vids.join('/')}] → ${DRY_RUN ? 'WOULD stamp' : 'stamped'} ${stamped.length} memo(s) [${stamped.join(' + ')}] 2ndReq=${today}`);
}
- writeFileSync(LEDGER, JSON.stringify(seen, null, 2));
- if (!acted) console.log(` (no new sent follow-ups to stamp) — ${new Date().toLocaleTimeString()}`);
+ if (!DRY_RUN) writeFileSync(LEDGER, JSON.stringify(seen, null, 2));
+ if (!acted) console.log(` (no ${DRY_RUN ? 'stampable' : 'new sent'} follow-ups) — ${new Date().toLocaleTimeString()}`);
return acted;
}
← ea9f7d9 auto-data-snapshot: 2026-09-01T12:16:45 (5 data files) — dat
·
back to Sample Followup Sweep
·
sample-followup: env knobs (SEARCH_DAYS/MAX_AGE) to backfill 35ba693 →