← back to Dw Signup Fulfillment
TK-11377: --send-only must not email an already-decided application
b0c42809e084efec80d0d64e183cf6fe5a33fcc9 · 2026-09-10 08:49:43 -0700 · Steve Abrams
Found while measuring whether TK-11377 had any live impact. The reconciliation
gap itself is latent (0 affected rows on prod), but the same filter carries a
live hazard: scripts/recover-stuck-apps.js's --send-only selected on
link_status + shopify_customer_id + !recovery_emailed with NO status check.
Measured on prod 2026-09-10: 20 rows matched, and 4 of them are already-approved
REAL designers decided 2026-09-03 (kmdesigncompany.com, veronica-valencia.com,
lenorekingluxuryinteriors.com, bonvivantinteriors.com). One
`DRY_RUN=0 ... --send-only` would have sent those 4 an "your account is ready,
activate it" letter a week after they were approved and already emailed, plus 4
"[Now approvable]" notices to the office for applications it had already
approved - 8 wrong emails to real people.
Both letters are wrong for a decided application, so the fix is to select only
pending ones. Skipped rows are printed rather than silently dropped, so an
operator can see what was held back and why.
Pre-existing; NOT introduced by the TK-11285 deploy, which does not touch this
path and is landed and verified.
Tests: verification/tk11285/sendonly-filter-test.js, 6 checks shaped from the
real prod rows, proven non-tautological (reverting the filter fails 3/6 and
would email approved-a, approved-b and rejected-a). selftest gains a decided-row
case; its assertion is loosened off the exact label text and onto the count plus
a real behavioural check of what was actually SENT.
Against a copy of live prod data: old filter 20 emails (4 wrong), new filter 16.
Suite: trade-approval 27/27, commitRows 9/9, sendonly-filter 6/6, selftest pass.
Local branch only - not merged, not deployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
M scripts/recover-stuck-apps.jsM scripts/selftest.jsA verification/tk11285/sendonly-filter-test.js
Diff
commit b0c42809e084efec80d0d64e183cf6fe5a33fcc9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 08:49:43 2026 -0700
TK-11377: --send-only must not email an already-decided application
Found while measuring whether TK-11377 had any live impact. The reconciliation
gap itself is latent (0 affected rows on prod), but the same filter carries a
live hazard: scripts/recover-stuck-apps.js's --send-only selected on
link_status + shopify_customer_id + !recovery_emailed with NO status check.
Measured on prod 2026-09-10: 20 rows matched, and 4 of them are already-approved
REAL designers decided 2026-09-03 (kmdesigncompany.com, veronica-valencia.com,
lenorekingluxuryinteriors.com, bonvivantinteriors.com). One
`DRY_RUN=0 ... --send-only` would have sent those 4 an "your account is ready,
activate it" letter a week after they were approved and already emailed, plus 4
"[Now approvable]" notices to the office for applications it had already
approved - 8 wrong emails to real people.
Both letters are wrong for a decided application, so the fix is to select only
pending ones. Skipped rows are printed rather than silently dropped, so an
operator can see what was held back and why.
Pre-existing; NOT introduced by the TK-11285 deploy, which does not touch this
path and is landed and verified.
Tests: verification/tk11285/sendonly-filter-test.js, 6 checks shaped from the
real prod rows, proven non-tautological (reverting the filter fails 3/6 and
would email approved-a, approved-b and rejected-a). selftest gains a decided-row
case; its assertion is loosened off the exact label text and onto the count plus
a real behavioural check of what was actually SENT.
Against a copy of live prod data: old filter 20 emails (4 wrong), new filter 16.
Suite: trade-approval 27/27, commitRows 9/9, sendonly-filter 6/6, selftest pass.
Local branch only - not merged, not deployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
scripts/recover-stuck-apps.js | 16 +++++++++--
scripts/selftest.js | 8 +++++-
verification/tk11285/sendonly-filter-test.js | 42 ++++++++++++++++++++++++++++
3 files changed, 63 insertions(+), 3 deletions(-)
diff --git a/scripts/recover-stuck-apps.js b/scripts/recover-stuck-apps.js
index ddadff4..94ac45e 100644
--- a/scripts/recover-stuck-apps.js
+++ b/scripts/recover-stuck-apps.js
@@ -153,11 +153,23 @@ async function sendForApp(app) {
// --send-only: email apps that are LINKED but not-yet-emailed. No Shopify writes, no id
// stamping — only sets recovery_emailed on a real send. Idempotent (skips already-emailed).
+//
+// PENDING ONLY. Both letters this sends are wrong for a decided application: the
+// designer gets "your account is ready — activate it" after they were already
+// approved and sent the trade-approved letter, and the office gets "[Now
+// approvable]" for something it already approved. Measured on prod 2026-09-10:
+// 4 of 20 matching rows were already-approved real designers (TK-11377).
async function sendOnly() {
const rows = readRows();
- const targets = rows.filter((a) => a.link_status === 'linked' && a.shopify_customer_id && !a.recovery_emailed).slice(0, LIMIT);
+ const eligible = rows.filter((a) => a.link_status === 'linked' && a.shopify_customer_id && !a.recovery_emailed);
+ const decided = eligible.filter((a) => a.status !== 'pending');
+ const targets = eligible.filter((a) => a.status === 'pending').slice(0, LIMIT);
console.log(`--send-only DRY_RUN(config)=${config.DRY_RUN} file=${FILE}`);
- console.log(`LINKED not-yet-emailed=${targets.length} (of ${rows.filter((a) => a.link_status === 'linked').length} linked; limit ${LIMIT})`);
+ if (decided.length) {
+ console.log(`SKIPPING ${decided.length} already-decided application(s) — the activation letter is wrong for them:`);
+ for (const d of decided) console.log(` ${d.id} ${d.email} (${d.status}${d.decided_at ? ' ' + d.decided_at.slice(0, 10) : ''})`);
+ }
+ console.log(`LINKED + PENDING + not-yet-emailed=${targets.length} (of ${rows.filter((a) => a.link_status === 'linked').length} linked; limit ${LIMIT})`);
if (!targets.length) { console.log('Nothing to email.'); return; }
let sent = 0;
for (const app of targets) { if (await sendForApp(app)) sent++; await sleep(DELAY_MS); }
diff --git a/scripts/selftest.js b/scripts/selftest.js
index fc59f56..b7b9162 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -246,14 +246,20 @@ async function main() {
fs.writeFileSync(soFix, [
JSON.stringify({ id: 'L1', email: 'need@x.com', status: 'pending', created_at: '2026-07-10T10:00:00Z', shopify_customer_id: '999001', link_status: 'linked' }),
JSON.stringify({ id: 'L2', email: 'done@x.com', status: 'pending', created_at: '2026-07-11T10:00:00Z', shopify_customer_id: '999002', link_status: 'linked', recovery_emailed: true }),
+ // TK-11377: a DECIDED application must never receive the activation letter.
+ JSON.stringify({ id: 'L3', email: 'approved@x.com', status: 'approved', created_at: '2026-07-12T10:00:00Z', decided_at: '2026-07-13T10:00:00Z', decision: 'approved', shopify_customer_id: '999003', link_status: 'linked' }),
JSON.stringify({ id: 'U3', email: 'unl@x.com', status: 'pending', created_at: '2026-07-12T10:00:00Z', shopify_customer_id: null }),
].join('\n') + '\n');
const soOut = cp.execFileSync('node', [script, '--send-only', '--file', soFix],
{ env: { ...process.env, DRY_RUN: '1' }, encoding: 'utf8' });
const soRows = fs.readFileSync(soFix, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
const l1After = soRows.find((r) => r.id === 'L1');
- if (/LINKED not-yet-emailed=1\b/.test(soOut)) ok('--send-only targets ONLY linked+not-emailed (1 of 2 linked; already-emailed + unlinked excluded)');
+ if (/not-yet-emailed=1\b/.test(soOut)) ok('--send-only targets ONLY linked+PENDING+not-emailed (1 of 3 linked; already-emailed + unlinked + DECIDED excluded)');
else fail('--send-only selection wrong: ' + soOut.split('\n').find((l) => /not-yet-emailed/.test(l)));
+ // TK-11377 — an approved applicant must not be offered the "activate your account" letter.
+ const soSent = [...soOut.matchAll(/\[email:designer\]\s+(\S+)/g)].map((m) => m[1]);
+ if (!soSent.includes('approved@x.com') && /SKIPPING 1 already-decided/.test(soOut)) ok('--send-only EXCLUDES a decided (approved) application and says so');
+ else fail('--send-only would email an already-decided application (TK-11377 regression)');
if (!l1After.recovery_emailed) ok('--send-only DRY preview does NOT set recovery_emailed → idempotent (a real run still sends)');
else fail('DRY preview wrongly consumed the recovery_emailed flag');
try { fs.unlinkSync(soFix); } catch {}
diff --git a/verification/tk11285/sendonly-filter-test.js b/verification/tk11285/sendonly-filter-test.js
new file mode 100644
index 0000000..ce9cf04
--- /dev/null
+++ b/verification/tk11285/sendonly-filter-test.js
@@ -0,0 +1,42 @@
+'use strict';
+// --send-only must never email an application that has already been decided.
+// Shaped from the real prod rows measured 2026-09-10 (TK-11377): 4 approved
+// designers sat inside the old filter's target set.
+const fs=require('fs'),path=require('path'),{execFileSync}=require('child_process');
+const ROOT=path.join(__dirname,'..','..');
+const trade=require(path.join(ROOT,'lib','trade'));
+const APPS=trade.APPS_PATH;
+const snap=fs.existsSync(APPS)?fs.readFileSync(APPS):null;
+
+const row=(id,status,extra={})=>({id,email:id.toLowerCase()+'@example.com',business_name:id,
+ shopify_customer_id:'700'+id.length,link_status:'linked',link_via:'existing',
+ status,created_at:'2026-09-02T00:00:00.000Z',
+ decided_at:status==='pending'?null:'2026-09-03T00:00:00.000Z',
+ decision:status==='pending'?null:status,assigned_rep:null,...extra});
+
+fs.writeFileSync(APPS,[
+ row('PENDING-A','pending'), row('PENDING-B','pending'),
+ row('APPROVED-A','approved'), row('APPROVED-B','approved'),
+ row('REJECTED-A','rejected'),
+ row('ALREADY-EMAILED','pending',{recovery_emailed:true}),
+].map(r=>JSON.stringify(r)).join('\n')+'\n');
+
+// DRY preview: prints who it WOULD email, sends nothing, writes nothing.
+const out=execFileSync(process.execPath,[path.join(ROOT,'scripts','recover-stuck-apps.js'),'--send-only'],
+ {cwd:ROOT,env:{...process.env,DRY_RUN:'1'},encoding:'utf8'});
+
+const emailed=[...out.matchAll(/\[email:designer\]\s+(\S+)/g)].map(m=>m[1]);
+const checks=[
+ ['pending applications are still emailed', emailed.includes('pending-a@example.com')&&emailed.includes('pending-b@example.com')],
+ ['APPROVED application is NOT emailed', !emailed.some(e=>e.startsWith('approved-'))],
+ ['REJECTED application is NOT emailed', !emailed.some(e=>e.startsWith('rejected-'))],
+ ['already-emailed row still skipped (idempotent)', !emailed.includes('already-emailed@example.com')],
+ ['skipped decided rows are reported, not silent', /SKIPPING 3 already-decided/.test(out)],
+ ['exactly 2 designers emailed', emailed.length===2],
+];
+if(snap!==null) fs.writeFileSync(APPS,snap); else { try{fs.unlinkSync(APPS)}catch{} }
+const failed=checks.filter(c=>!c[1]);
+console.log(JSON.stringify({suite:'send-only pending-only filter',emailed,
+ results:checks.map(([name,ok])=>({name,verdict:ok?'PASS':'FAIL'})),
+ passed:checks.length-failed.length,failed:failed.length},null,2));
+process.exit(failed.length?1:0);
← ff9bd17 TK-10836: live checkout render blocked by mandatory customer
·
back to Dw Signup Fulfillment
·
TK-11377: stop my own test being a fourth writer of the live 86dd5a0 →