[object Object]

← back to Sample Followup Sweep

Scheduled Tue-Fri 7am auto-sender: send un-chased >10-day follow-ups, stamp=dedup

54155fb0856d5acb905849a7c85e1b68295d0a91 · 2026-08-17 09:09:25 -0700 · Steve Abrams

- scripts/scheduled-run.mjs: fresh two-read sweep (REPORT ON SAMPLES ORDERED minus already-chased), group by vid (case-normalized), compose+send via George, stamp Date Email Sent to Vendor after 10 Days (dedup so no request goes twice). Dry-run default; --send for the job.
- launchd com.steve.sample-followup-send: Tue/Wed/Thu/Fri 7:00am (Weekday 2-5). Steve-gated install (auto-sends).
- /sample-followup skill: documents schedule + vp-dw-commerce ownership.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 54155fb0856d5acb905849a7c85e1b68295d0a91
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 17 09:09:25 2026 -0700

    Scheduled Tue-Fri 7am auto-sender: send un-chased >10-day follow-ups, stamp=dedup
    
    - scripts/scheduled-run.mjs: fresh two-read sweep (REPORT ON SAMPLES ORDERED minus already-chased), group by vid (case-normalized), compose+send via George, stamp Date Email Sent to Vendor after 10 Days (dedup so no request goes twice). Dry-run default; --send for the job.
    - launchd com.steve.sample-followup-send: Tue/Wed/Thu/Fri 7:00am (Weekday 2-5). Steve-gated install (auto-sends).
    - /sample-followup skill: documents schedule + vp-dw-commerce ownership.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/runs/scheduled-08-17-2026.json          | 163 ++++++++++++++++++++++++++-
 launchd/com.steve.sample-followup-send.plist |  57 ++++++++++
 scripts/scheduled-run.mjs                    | 145 ++++++++++++++++++++++++
 3 files changed, 360 insertions(+), 5 deletions(-)

diff --git a/data/runs/scheduled-08-17-2026.json b/data/runs/scheduled-08-17-2026.json
index 1914f7c..9b46bbd 100644
--- a/data/runs/scheduled-08-17-2026.json
+++ b/data/runs/scheduled-08-17-2026.json
@@ -1,10 +1,163 @@
 {
-  "ranAt": "2026-08-17T16:03:47.646Z",
+  "ranAt": "2026-08-17T16:08:00.953Z",
   "mode": "DRY-RUN",
   "window": "06/18/2026...08/07/2026",
   "stampDate": "08/17/2026",
-  "vendorsSent": 0,
-  "vendorsSkipped": 0,
-  "sent": [],
-  "skipped": []
+  "vendorsSent": 9,
+  "vendorsSkipped": 11,
+  "sent": [
+    {
+      "vid": "OSB",
+      "name": "Osborne & Little",
+      "to": "Polancol@oalusa.com",
+      "n": 3,
+      "skus": [
+        "EUR71256",
+        "EUR71254",
+        "EUR71252"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "ASTSAND",
+      "name": "Astek",
+      "to": "amy.d@astek.com",
+      "n": 1,
+      "skus": [
+        "DWPX500016"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "CHIN",
+      "name": "China Seas by Quadrille — inferred",
+      "to": "Rhaiza@quadrilleinc.com",
+      "n": 2,
+      "skus": [
+        "DWCS914048",
+        "DWCS913548"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "KRA",
+      "name": "Kravet",
+      "to": "matt.schoffman@kravet.com",
+      "n": 1,
+      "skus": [
+        "DWKK135711"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "BRE",
+      "name": "Brewster",
+      "to": "brewsterorders@brewp.com",
+      "n": 2,
+      "skus": [
+        "DWJS91471",
+        "DWJS90108"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "RBL",
+      "name": "Rebel Walls (via Gimmersta US)",
+      "to": "support@gimmersta.com",
+      "n": 1,
+      "skus": [
+        "DWRW76184"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "YOR",
+      "name": "York",
+      "to": "RiveraH@yorkwall.com, orders@yorkwall.com",
+      "n": 1,
+      "skus": [
+        "DWJS15436"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "THIB",
+      "name": "Thibaut",
+      "to": "cathy.dy@thibautdesign.com",
+      "n": 3,
+      "skus": [
+        "DWAT65047",
+        "DWAT65047",
+        "DWTT73921"
+      ],
+      "dryRun": true
+    },
+    {
+      "vid": "GREEN",
+      "name": "Greenland (bmwallpaper)",
+      "to": "sales@bmwallpaper.com",
+      "n": 1,
+      "skus": [
+        "PWV10040"
+      ],
+      "dryRun": true
+    }
+  ],
+  "skipped": [
+    {
+      "vid": "TBD",
+      "n": 2,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "RAD",
+      "n": 1,
+      "reason": "no-chase vendor"
+    },
+    {
+      "vid": "SCHUMACHER",
+      "n": 2,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "SCH",
+      "n": 1,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "VER",
+      "n": 1,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "SPN",
+      "n": 9,
+      "reason": "no-chase vendor"
+    },
+    {
+      "vid": "COOR",
+      "n": 2,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "POI",
+      "n": 1,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "MIN",
+      "n": 1,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "ARTE",
+      "n": 1,
+      "reason": "no sample email/account on file"
+    },
+    {
+      "vid": "ROMO",
+      "n": 1,
+      "reason": "no sample email/account on file"
+    }
+  ]
 }
\ No newline at end of file
diff --git a/launchd/com.steve.sample-followup-send.plist b/launchd/com.steve.sample-followup-send.plist
new file mode 100644
index 0000000..8aa79f8
--- /dev/null
+++ b/launchd/com.steve.sample-followup-send.plist
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!--
+  Scheduled >10-day follow-up SENDER — Tue/Wed/Thu/Fri 7:00am PT.
+  Runs `node scripts/scheduled-run.mjs --send`:
+    • sends the follow-up letter to each vendor with un-chased overdue memos (10-60d)
+    • stamps "Date Email Sent to Vendor after 10 Days" = today  → dedup (never sends twice)
+  Because Mon is NOT a run day, Tuesday's run naturally covers everything new since the
+  previous Friday. The stamp is the "do not duplicate request" guard: an already-filled
+  2nd-request date is excluded.
+
+  ⚠ THIS AUTO-SENDS VENDOR EMAILS. Install only after reviewing the dry-run list.
+  Install (Steve):
+    cp ~/Projects/sample-followup-sweep/launchd/com.steve.sample-followup-send.plist ~/Library/LaunchAgents/
+    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.sample-followup-send.plist
+  Preview any time (no send):  node ~/Projects/sample-followup-sweep/scripts/scheduled-run.mjs
+  Uninstall:                   launchctl bootout gui/$(id -u)/com.steve.sample-followup-send
+-->
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.sample-followup-send</string>
+
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/sample-followup-sweep/scripts/scheduled-run.mjs</string>
+    <string>--send</string>
+  </array>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3/Projects/sample-followup-sweep</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+  </dict>
+
+  <!-- Tue(2) Wed(3) Thu(4) Fri(5) at 07:00 -->
+  <key>StartCalendarInterval</key>
+  <array>
+    <dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
+  </array>
+
+  <key>RunAtLoad</key>
+  <false/>
+
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Projects/sample-followup-sweep/out/scheduled-send.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Projects/sample-followup-sweep/out/scheduled-send.log</string>
+</dict>
+</plist>
diff --git a/scripts/scheduled-run.mjs b/scripts/scheduled-run.mjs
new file mode 100644
index 0000000..b3b0e6c
--- /dev/null
+++ b/scripts/scheduled-run.mjs
@@ -0,0 +1,145 @@
+#!/usr/bin/env node
+// Scheduled follow-up sender — Tue/Wed/Thu/Fri 7:00am PT.
+//
+// Sends the >10-day sample follow-up letter to each vendor that has outstanding
+// memos NOT yet chased, then stamps "Date Email Sent to Vendor after 10 Days" so
+// the same request never goes twice. The stamp IS the dedup: a SKU chased on the
+// previous run (e.g. last Friday) is already filled and is excluded here, so each
+// run only sends the follow-ups that are NEW since the last run.
+//
+// Rules enforced:
+//   • window 10–60 days (>60 = dead lead, skip)      • Date WP Sample Sent empty (still outstanding)
+//   • Date Email Sent to Vendor after 10 Days EMPTY  → the "do not duplicate" guard
+//   • vendor must have a sample email + account       (else skip + report — can't send)
+//
+// DRY-RUN by default (prints what WOULD send). The launchd job runs it with --send.
+//   node scripts/scheduled-run.mjs            # dry run
+//   node scripts/scheduled-run.mjs --send     # actually send + stamp
+
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { homedir } from 'node:os';
+import { createRequire } from 'node:module';
+import http from 'node:http';
+
+const require = createRequire(import.meta.url);
+const __dir = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dir, '..');
+const { compose } = require(join(ROOT, 'lib', 'compose.js'));
+
+const SEND = process.argv.includes('--send');
+const DB = 'WALLPAPER';
+const LAYOUT = 'Report for old memo samples';
+const FIELD_SENT = 'Date Email Sent to Vendor after 10 Days';
+const MIN_AGE_DAYS = 10, MAX_AGE_DAYS = 60;
+const SHIP_TO = { name: 'Designer Wallcoverings', line1: '15442 Ventura Blvd. #102', city_state_zip: 'Sherman Oaks, CA 91403', phone: '1-888-373-4564' };
+
+// --- FileMaker client (reuse the connector; creds from ~/.claude.json) ---
+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']) process.env[k] = env[k];
+  process.env.FM_READONLY = '0';
+}
+loadFmEnv();
+const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
+
+const pad = (n) => String(n).padStart(2, '0');
+const fmtDate = (d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
+function windowRange(today = new Date()) {
+  const lo = new Date(today); lo.setDate(lo.getDate() - MAX_AGE_DAYS);
+  const hi = new Date(today); hi.setDate(hi.getDate() - MIN_AGE_DAYS);
+  return `${fmtDate(lo)}...${fmtDate(hi)}`;
+}
+const norm = (s) => String(s || '').toLowerCase().trim();
+const readJSON = (f, d) => { try { return JSON.parse(readFileSync(join(ROOT, f), 'utf8')); } catch { return d; } };
+
+// vid -> contact (sample_email + account), built from fleet(slug↔vid) + contacts(slug→email)
+function vidContactMap() {
+  const fleet = readJSON('data/fleet.json', { vendors: [] }).vendors;
+  const contacts = readJSON('data/contacts.json', {});
+  const map = {};
+  for (const v of fleet) {
+    const c = contacts[v.slug] || {};
+    const key = String(v.vid || '').toUpperCase();   // FileMaker vid case varies (yor vs YOR)
+    if (c.disposition === 'no-chase') { map[key] = { noChase: true, name: v.name }; continue; }
+    if (c.sample_email && c.account_number && !map[key]) {
+      map[key] = { slug: v.slug, name: c.name || v.name, sample_email: c.sample_email, account_number: c.account_number };
+    }
+  }
+  return map;
+}
+
+// --- George send (same path/token as the console) ---
+function georgeCreds() {
+  const files = ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env'];
+  const g = (k) => { for (const f of files) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
+  let auth = g('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + g('GEORGE_BASIC_AUTH_PASS');
+  return { auth: 'Basic ' + Buffer.from(auth).toString('base64'), token: g('GEORGE_EXTERNAL_SEND_TOKEN') };
+}
+function georgeSend(payload) {
+  const { auth, token } = georgeCreds();
+  const body = JSON.stringify(payload);
+  return new Promise((resolve) => {
+    const req = http.request({ host: '127.0.0.1', port: 9850, path: '/api/send', method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), Authorization: auth, 'X-Send-Approval': token } },
+      (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => { try { const j = JSON.parse(b); resolve({ ok: !!j.success, id: j.messageId || '', detail: b.slice(0, 200) }); } catch { resolve({ ok: false, detail: b.slice(0, 200) }); } }); });
+    req.on('error', (e) => resolve({ ok: false, detail: e.message }));
+    req.write(body); req.end();
+  });
+}
+
+const run = async () => {
+  const win = windowRange();
+  const today = fmtDate(new Date());
+  // Two-read set-difference: no single API layout has BOTH `vid` and FIELD_SENT.
+  //   A = fresh outstanding overdue list (REPORT ON SAMPLES ORDERED — has vid + Mfr Pattern)
+  //   B = already-chased recordIds (Report for old memo samples — has FIELD_SENT, filter non-empty "*")
+  //   un-chased = A minus B  (recordId is stable across layouts — one WALLPAPER2 table)
+  const catchAll = (e) => { if (e.fmCode === '401') return { records: [] }; throw e; };
+  const A = (await fm.findRecords(DB, 'REPORT ON SAMPLES ORDERED',
+    { 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 1200 }).catch(catchAll)).records;
+  const B = (await fm.findRecords(DB, LAYOUT,
+    { 'Date WP Sample Sent': '=', [FIELD_SENT]: '*', 'today for client': win }, { limit: 1200 }).catch(catchAll)).records;
+  const chasedIds = new Set(B.map((r) => String(r.recordId)));
+
+  // Group the un-chased by vid.
+  const byVid = {};
+  for (const r of A) {
+    if (chasedIds.has(String(r.recordId))) continue;   // already requested → do not duplicate
+    const vid = String(r.fieldData.vid || '').toUpperCase(); if (!vid) continue;
+    (byVid[vid] = byVid[vid] || []).push({
+      recordId: r.recordId, mfr: (r.fieldData['Mfr Pattern'] || '').trim(),
+      sku: r.fieldData['combo sku'] || '', requested: r.fieldData['today for client'] || '',
+    });
+  }
+
+  const cmap = vidContactMap();
+  const sent = [], skipped = [];
+  for (const [vid, rows] of Object.entries(byVid)) {
+    const c = cmap[vid];
+    if (!c || c.noChase) { skipped.push({ vid, n: rows.length, reason: c?.noChase ? 'no-chase vendor' : 'no sample email/account on file' }); continue; }
+    const draft = compose({ name: c.name, account_number: c.account_number, sample_email: c.sample_email, ship_to: SHIP_TO }, rows.map((r) => ({ mfr: r.mfr })));
+    if (!SEND) { sent.push({ vid, name: c.name, to: draft.to, n: rows.length, skus: rows.map((r) => r.sku), dryRun: true }); continue; }
+    const res = await georgeSend({ account: 'info', to: draft.to, subject: draft.subject, body: draft.html });
+    if (res.ok) {
+      for (const r of rows) { try { await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today }, { dryRun: false }); } catch {} }
+      sent.push({ vid, name: c.name, to: draft.to, n: rows.length, messageId: res.id });
+    } else {
+      skipped.push({ vid, n: rows.length, reason: 'George send failed: ' + res.detail });
+    }
+  }
+
+  const report = { ranAt: new Date().toISOString(), mode: SEND ? 'SEND' : 'DRY-RUN', window: win, stampDate: today,
+    vendorsSent: sent.length, vendorsSkipped: skipped.length, sent, skipped };
+  mkdirSync(join(ROOT, 'data', 'runs'), { recursive: true });
+  writeFileSync(join(ROOT, 'data', 'runs', `scheduled-${today.replace(/\//g, '-')}.json`), JSON.stringify(report, null, 2));
+
+  console.log(`Scheduled follow-up — ${report.mode} · window ${win} (${MIN_AGE_DAYS}-${MAX_AGE_DAYS}d) · dedup=FIELD empty\n`);
+  console.log(`Would ${SEND ? 'HAVE SENT' : 'send'} to ${sent.length} vendor(s):`);
+  sent.forEach((s) => console.log(`  ${SEND ? '✓' : '·'} ${s.name.padEnd(30)} [${s.vid}] → ${s.to}  (${s.n} SKU${s.n > 1 ? 's' : ''})`));
+  if (skipped.length) { console.log(`\nSkipped ${skipped.length} (no contact / no-chase / error):`); skipped.forEach((s) => console.log(`  - [${s.vid}] ${s.n} SKU(s): ${s.reason}`)); }
+  console.log(`\n→ data/runs/scheduled-${today.replace(/\//g, '-')}.json`);
+};
+run().catch((e) => { console.error('scheduled-run error:', e.message); process.exit(1); });

← e4bd94a auto-data-snapshot: 2026-08-17T09:04:04 (1 data files) — dat  ·  back to Sample Followup Sweep  ·  auto-data-snapshot: 2026-08-17T09:25:02 (1 data files) — dat 38b88cf →