← 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
M data/runs/scheduled-08-17-2026.jsonA launchd/com.steve.sample-followup-send.plistA scripts/scheduled-run.mjs
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 →