[object Object]

← back to Sample Followup Sweep

TK-11409: the 2nd request now updates the WALLPAPER file

f69c9f78a00e45f405a08d757572f4657ffb940d · 2026-09-10 10:32:04 -0700 · Steve Abrams

The resend called the stamper but wrote nothing, silently. matchPageSku hard-coded
[FIELD_SENT]: '=' (FileMaker '=' matches an EMPTY field) on every send, so on a resend
every SKU was already stamped from request #1, the find returned zero records, and
nothing was written. stampFmpro spawns detached with stdio:'ignore', so the no-op was
indistinguishable from success.

- matchPageSku/planVendor/stampVendor take a  (default 1, so existing callers
  are unchanged). pass 2 finds rows where FIELD_SENT is non-empty ('*') and the
  2nd-request field is still empty.
- pass 1 now writes ONLY 'Date Email Sent to Vendor after 10 Days'. It used to write
  the 2nd-request field at the same time, which left request #2 nowhere to record.
- pass 2 writes ONLY 'Date Sample Request Letter Sent', preserving the 1st-chase date.
- server.js derives isResend from sent.json (the record of fact) rather than b.force,
  so a forced genuine first send stays pass 1.

Not yet exercised against live FileMaker — no records written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit f69c9f78a00e45f405a08d757572f4657ffb940d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 10:32:04 2026 -0700

    TK-11409: the 2nd request now updates the WALLPAPER file
    
    The resend called the stamper but wrote nothing, silently. matchPageSku hard-coded
    [FIELD_SENT]: '=' (FileMaker '=' matches an EMPTY field) on every send, so on a resend
    every SKU was already stamped from request #1, the find returned zero records, and
    nothing was written. stampFmpro spawns detached with stdio:'ignore', so the no-op was
    indistinguishable from success.
    
    - matchPageSku/planVendor/stampVendor take a  (default 1, so existing callers
      are unchanged). pass 2 finds rows where FIELD_SENT is non-empty ('*') and the
      2nd-request field is still empty.
    - pass 1 now writes ONLY 'Date Email Sent to Vendor after 10 Days'. It used to write
      the 2nd-request field at the same time, which left request #2 nowhere to record.
    - pass 2 writes ONLY 'Date Sample Request Letter Sent', preserving the 1st-chase date.
    - server.js derives isResend from sent.json (the record of fact) rather than b.force,
      so a forced genuine first send stays pass 1.
    
    Not yet exercised against live FileMaker — no records written.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 scripts/fmpro.mjs | 34 +++++++++++++++++++++++++---------
 server.js         | 21 ++++++++++++++-------
 2 files changed, 39 insertions(+), 16 deletions(-)

diff --git a/scripts/fmpro.mjs b/scripts/fmpro.mjs
index 4ab60d3..c5d4e32 100644
--- a/scripts/fmpro.mjs
+++ b/scripts/fmpro.mjs
@@ -70,16 +70,25 @@ function windowRange(today = new Date()) {
 // 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) {
+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
-    [FIELD_SENT]: '=',                      // not yet stamped
+    ...(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 });
@@ -96,11 +105,11 @@ async function matchPageSku(dw, requested) {
 }
 
 // --- plan/stamp one vendor from its exact page SKUs (fleet.json items) ---
-async function planVendor(v) {
+async function planVendor(v, pass = 1) {
   const seen = new Set();
   const rows = [];
   for (const it of (v.items || [])) {
-    const matches = await matchPageSku(it.dw, it.initialReq || it.req);
+    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);
@@ -109,14 +118,18 @@ async function planVendor(v) {
   }
   return rows;
 }
-async function stampVendor(v) {
-  const rows = await planVendor(v);
+async function stampVendor(v, pass = 1) {
+  const rows = await planVendor(v, pass);
   const stamped = [];
   for (const r of rows) {
-    const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: r.date, [FIELD_2ND_DATE]: r.date }, { dryRun: false });
+    // 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);
   }
-  return { vid: v.vid, date: v.date, count: stamped.length, found: rows.length, stamped };
+  return { vid: v.vid, date: v.date, pass, count: stamped.length, found: rows.length, stamped };
 }
 
 // --- write a plain-language reply note to a specific memo record ---
@@ -196,10 +209,13 @@ if (cmd === 'plan') {
   // 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); }
   const fleetV = JSON.parse(readFileSync(join(ROOT, 'data', 'fleet.json'), 'utf8')).vendors.find((x) => x.slug === slug);
   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 });
+  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') {
diff --git a/server.js b/server.js
index 530e479..c9ce23f 100644
--- a/server.js
+++ b/server.js
@@ -27,9 +27,11 @@ const replies = () => readJSON(p('data', 'replies.json'), { byVid: {} });
 // After a send, stamp "Date Email Sent to Vendor after 10 Days" on that vendor's exact
 // page SKUs in FileMaker (WALLPAPER2) and record it for the console chip. Fire-and-forget:
 // the email already went out, so a FileMaker hiccup must never fail the send response.
-function stampFmpro(slug) {
+function stampFmpro(slug, pass = 1) {
   try {
-    const child = spawn(process.execPath, [p('scripts', 'fmpro.mjs'), 'stamp', '--slug', slug],
+    const args = [p('scripts', 'fmpro.mjs'), 'stamp', '--slug', slug];
+    if (pass === 2) args.push('--pass', '2');   // TK-11409: the resend writes the 2nd-request field
+    const child = spawn(process.execPath, args,
       { cwd: ROOT, stdio: 'ignore', detached: true });
     child.on('error', () => {});
     child.unref();
@@ -130,11 +132,14 @@ const server = http.createServer(async (req, res) => {
     let payload;
     if (st) payload = { account: 'info', to: st.to, subject: st.subject, body: st.body };
     else { const v = fleet().vendors.find(x => x.slug === b.slug); if (!v) return send(res, 404, { error: 'no vendor' }); const d = draftFor(v, c); payload = { account: 'info', to: d.to, subject: d.subject, body: d.html }; }
+    // Was this vendor already emailed BEFORE this send? Computed from sent.json (the record of
+    // fact), not from b.force — someone can force a genuine first send, and that must stay pass 1.
+    const _addrs = String(payload.to).split(',').map(a => a.trim().toLowerCase()).filter(a => a.includes('@'));
+    const _be = (sent().byEmail) || {};
+    const isResend = _addrs.length > 0 && _addrs.every(a => _be[a]);
     // Resend guard (Cody FIX FIRST): refuse if this vendor's recipients were already emailed, unless {force:true}.
-    if (!b.force) {
-      const be = (sent().byEmail) || {};
-      const addrs = String(payload.to).split(',').map(a => a.trim().toLowerCase()).filter(a => a.includes('@'));
-      if (addrs.length && addrs.every(a => be[a])) return send(res, 409, { error: 'already sent to this vendor — click again to force-resend', to: payload.to, alreadySent: true });
+    if (!b.force && isResend) {
+      return send(res, 409, { error: 'already sent to this vendor — click again to force-resend', to: payload.to, alreadySent: true });
     }
     // George's canonical creds live in the DW-MCP .env (the file George's server loads into `creds`);
     // GEORGE_EXTERNAL_SEND_TOKEN lives in george-gmail/.env. Search both, canonical first. (2026-08-15 fix:
@@ -148,7 +153,9 @@ const server = http.createServer(async (req, res) => {
         let ok = false, mid = ''; try { const j = JSON.parse(gb); ok = !!j.success; mid = j.messageId || ''; } catch (e) {}
         if (ok) {
           try { const s = sent(); s.byEmail = s.byEmail || {}; const iso = new Date().toISOString(); for (let a of String(payload.to).split(',')) { a = a.trim().toLowerCase(); if (!a.includes('@')) continue; const cur = s.byEmail[a]; if (!cur) s.byEmail[a] = { lastSent: iso, count: 1 }; else { cur.count++; cur.lastSent = iso; } } fs.writeFileSync(p('data', 'sent.json'), JSON.stringify(s, null, 2)); } catch (e) {}
-          stampFmpro(b.slug); // write the sent-date back to FileMaker + light the FMPro chip
+          // 1st chase -> "Date Email Sent to Vendor after 10 Days"; resend (2nd request) ->
+          // "Date Sample Request Letter Sent". Both light the FMPro chip. (TK-11409)
+          stampFmpro(b.slug, isResend ? 2 : 1);
           return send(res, 200, { ok: true, messageId: mid, to: payload.to });
         }
         return send(res, 502, { error: 'George send blocked/failed', detail: gb.slice(0, 200) });

← c613b79 auto-data-snapshot: 2026-09-10T08:12:39 (1 data files) — dat  ·  back to Sample Followup Sweep  ·  auto-data-snapshot: 2026-09-10T10:34:55 (2 data files) — dat 0603d8e →