← back to Dw Signup Fulfillment
TK-11190: recovery tooling for pre-fix stuck trade applications (link retroactively)
164ab386b5b3d808e8bb96020672b77d01c66bd5 · 2026-09-03 11:48:54 -0700 · Steve Abrams
Designers who applied BEFORE the TK-11185 fix are status=pending + shopify_customer_id:null
→ un-approvable. This recovers them by applying the new-flow linkage retroactively.
- scripts/recover-stuck-apps.js: DRY-RUN by default; --apply links (findOrCreateCustomer →
stamp id + link_* + recovered_at). Idempotent (skips already-linked), batch-bounded
(--limit, default 25), reversible (backs up the jsonl + writes data/recovery-<ts>.json map;
--rollback deletes exactly the customers WE created). Emails are separable + OFF by default
(--send-emails); option (b) --auto-approve exists but is NOT recommended (grants trade
pricing without human moderation). Verified locally: dry-run, apply (backup+map), rollback.
- lib/email.js: designerAccountReadyEmail — recovery-cohort activation letter ("account is
ready, sign in via one-time code"); does NOT claim trade pricing is granted yet.
Full DRY_RUN suite green. LOCAL only — the actual recovery run is Steve-gated (customer-facing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M lib/email.jsA scripts/recover-stuck-apps.js
Diff
commit 164ab386b5b3d808e8bb96020672b77d01c66bd5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 11:48:54 2026 -0700
TK-11190: recovery tooling for pre-fix stuck trade applications (link retroactively)
Designers who applied BEFORE the TK-11185 fix are status=pending + shopify_customer_id:null
→ un-approvable. This recovers them by applying the new-flow linkage retroactively.
- scripts/recover-stuck-apps.js: DRY-RUN by default; --apply links (findOrCreateCustomer →
stamp id + link_* + recovered_at). Idempotent (skips already-linked), batch-bounded
(--limit, default 25), reversible (backs up the jsonl + writes data/recovery-<ts>.json map;
--rollback deletes exactly the customers WE created). Emails are separable + OFF by default
(--send-emails); option (b) --auto-approve exists but is NOT recommended (grants trade
pricing without human moderation). Verified locally: dry-run, apply (backup+map), rollback.
- lib/email.js: designerAccountReadyEmail — recovery-cohort activation letter ("account is
ready, sign in via one-time code"); does NOT claim trade pricing is granted yet.
Full DRY_RUN suite green. LOCAL only — the actual recovery run is Steve-gated (customer-facing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
lib/email.js | 31 ++++++++-
scripts/recover-stuck-apps.js | 146 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 176 insertions(+), 1 deletion(-)
diff --git a/lib/email.js b/lib/email.js
index 048f6f2..adfd403 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -170,6 +170,35 @@ function designerWelcomeEmail({ firstName }) {
return { subject, html };
}
+// Recovery-cohort activation letter (TK-11190). For designers who applied BEFORE the
+// server-side find-or-create fix — their account has now been created server-side, so
+// this says "your account is READY, sign in" (not "create one"). Approval stays a human
+// moderation decision — this letter does NOT claim trade pricing is switched on yet.
+function designerAccountReadyEmail({ firstName }) {
+ const fn = firstName ? String(firstName).trim() : '';
+ const greet = fn ? esc(fn.charAt(0).toUpperCase() + fn.slice(1)) : 'there';
+ const subject = `Your Designer Wallcoverings account is ready — sign in to finish`;
+ const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto">
+ <div style="border:1px solid #e2ddd4;border-radius:10px;overflow:hidden">
+ <div style="background:#1a1a1a;color:#fff;text-align:center;padding:26px 20px">
+ <div style="font-size:22px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+ <div style="font-size:11px;letter-spacing:2px;color:#b8afa2;margin-top:4px">TO THE TRADE</div>
+ </div>
+ <div style="padding:26px 28px;font-size:15px;line-height:1.7;color:#2a2a2a">
+ <p style="margin:0 0 14px">Dear ${greet},</p>
+ <p style="margin:0 0 14px">Thank you for applying for a Designer Wallcoverings trade account. We've now <b>set up your account</b> so you can sign in and we can finish reviewing your application.</p>
+ <p style="margin:0 0 14px"><b>How to sign in.</b> We use a one-time code sent to your email — no password to create or remember. Just click below and enter the code we email you.</p>
+ <div style="text-align:center;margin:22px 0 6px">
+ <a href="https://designerwallcoverings.com/account" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:13px 34px;border-radius:30px;font-size:14px;letter-spacing:1px;display:inline-block">Sign In to Your Account</a>
+ </div>
+ <p style="margin:20px 0 14px;color:#333">Once your application is approved, your <b>trade pricing</b> and <b>unlimited memo samples</b> switch on automatically and your dedicated rep will reach out personally.</p>
+ <p style="margin:20px 0 0;color:#2a2a2a">At your service,<br><b>The Designer Wallcoverings Trade Team</b></p>
+ </div>
+ </div>
+</div>`;
+ return { subject, html };
+}
+
// Account confirmation/engagement letter. Sample eligibility itself is account-based
// and enforced by the Shopify Function; confirmation is not a pricing gate.
function verifyEmail({ firstName, url, count }) {
@@ -333,4 +362,4 @@ function tradeApplicationEmail({ app, approveUrl, rejectUrl, adminUrl }) {
function money(v) { return `$${Number(v).toFixed(2)}`; }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
-module.exports = { sendEmail, verifyEmail, verifyResendEmail, samplesUnlockedEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
+module.exports = { sendEmail, verifyEmail, verifyResendEmail, samplesUnlockedEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, designerAccountReadyEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
diff --git a/scripts/recover-stuck-apps.js b/scripts/recover-stuck-apps.js
new file mode 100644
index 0000000..bbc4a3c
--- /dev/null
+++ b/scripts/recover-stuck-apps.js
@@ -0,0 +1,146 @@
+'use strict';
+// TK-11190 — recover PRE-FIX stuck trade applications (status=pending && shopify_customer_id
+// null), i.e. designers who applied BEFORE the TK-11185 server-side find-or-create fix and
+// are therefore un-approvable. Applies the SAME new-flow linkage retroactively:
+// findOrCreateCustomer(email) -> stamp shopify_customer_id -> annotate link_* + recovered_at.
+// Default option (a): LINK only + (optionally) email the designer an activation letter and
+// re-surface the app to the office as now-approvable. Approval STAYS a human moderation
+// decision — this never grants trade_approved (that is option (b), --auto-approve, off).
+//
+// SAFE BY DESIGN:
+// * DRY-RUN by default (no writes). --apply performs the linkage.
+// * IDEMPOTENT — an app that already has a shopify_customer_id is skipped.
+// * BATCH-BOUNDED — --limit N (default 25); run repeatedly to work the backlog down.
+// * REVERSIBLE — before writing it backs up the jsonl and writes a recovery map
+// (data/recovery-<ts>.json) listing every customer WE created, so rollback deletes
+// exactly those + restores the jsonl backup. --rollback <map.json> deletes them.
+// * EMAILS ARE GATED — --send-emails (only with --apply, OFF by default) actually sends;
+// without it, linking happens with ZERO email. Emails go out via George.
+// * Rate-friendly — a short delay between Shopify calls.
+//
+// Usage:
+// node scripts/recover-stuck-apps.js # dry-run, probes create-vs-reuse
+// node scripts/recover-stuck-apps.js --apply --limit 25 # link 25, NO emails
+// node scripts/recover-stuck-apps.js --apply --limit 25 --send-emails # link + activation/office emails
+// node scripts/recover-stuck-apps.js --rollback data/recovery-<ts>.json # delete the customers we created
+
+const fs = require('fs');
+const path = require('path');
+const trade = require('../lib/trade');
+const shopify = require('../lib/shopify');
+const email = require('../lib/email');
+const config = require('../lib/config');
+
+const args = process.argv.slice(2);
+const has = (f) => args.includes(f);
+const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
+const APPLY = has('--apply');
+const SEND_EMAILS = has('--send-emails');
+const AUTO_APPROVE = has('--auto-approve'); // option (b) — NOT recommended
+const NO_PROBE = has('--no-probe');
+const LIMIT = parseInt(val('--limit', '25'), 10);
+const FILE = val('--file', trade.APPS_PATH);
+const ROLLBACK = val('--rollback', null);
+const DELAY_MS = parseInt(val('--delay', '300'), 10);
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function readRows() { return fs.readFileSync(FILE, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); }
+function writeRows(rows) { fs.writeFileSync(FILE, rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '')); }
+function firstLast(app) {
+ const contact = app.contact_name || app.name || '';
+ const firstName = app.first_name || (contact ? String(contact).split(' ')[0] : '');
+ const lastName = app.last_name || (contact ? String(contact).split(' ').slice(1).join(' ') : '');
+ return { firstName, lastName };
+}
+
+async function rollback() {
+ const map = JSON.parse(fs.readFileSync(ROLLBACK, 'utf8'));
+ const created = (map.recovered || []).filter((r) => r.created && r.customer_id);
+ console.log(`[rollback] deleting ${created.length} customer(s) WE created (existing-account links are reverted by restoring the jsonl backup ${map.jsonl_backup || '<see map>'}).`);
+ for (const r of created) {
+ const gid = 'gid://shopify/Customer/' + r.customer_id;
+ const q = 'mutation d($id: ID!){ customerDelete(input:{id:$id}){ deletedCustomerId userErrors{ field message } } }';
+ const res = await shopify.graphql(q, { id: gid });
+ const d = res && res.json && res.json.data ? res.json.data.customerDelete : (res && res.json);
+ console.log(` ${r.email} -> customer ${r.customer_id}:`, JSON.stringify(d));
+ await sleep(DELAY_MS);
+ }
+ console.log(`[rollback] done. Restore the jsonl with: cp "${map.jsonl_backup}" "${FILE}"`);
+}
+
+async function main() {
+ if (ROLLBACK) return rollback();
+
+ const rows = readRows();
+ const stuck = rows.filter((a) => a.status === 'pending' && !a.shopify_customer_id);
+ console.log(`DRY_RUN(config)=${config.DRY_RUN} mode=${APPLY ? 'APPLY' : 'DRY-RUN'} send-emails=${SEND_EMAILS} auto-approve=${AUTO_APPROVE}`);
+ console.log(`file=${FILE}`);
+ console.log(`total rows=${rows.length} STUCK(pending && no customer id)=${stuck.length} batch limit=${LIMIT}`);
+ if (!stuck.length) { console.log('Nothing to recover.'); return; }
+
+ const batch = stuck.slice(0, LIMIT);
+ const recovered = [];
+
+ for (const app of batch) {
+ const { firstName, lastName } = firstLast(app);
+ if (!APPLY) {
+ let preview = 'would find-or-create';
+ if (!NO_PROBE) { const ex = await shopify.findCustomerByEmail(app.email); preview = ex ? `would REUSE existing customer ${ex}` : 'would CREATE new customer'; }
+ console.log(` [dry] ${app.id} ${app.email} "${app.business_name || ''}" ${app.created_at} -> ${preview}`);
+ continue;
+ }
+ // APPLY: link
+ const r = await shopify.findOrCreateCustomer(app.email, { firstName, lastName, phone: app.phone });
+ if (!(r && r.ok && r.id)) {
+ console.log(` [FAIL] ${app.id} ${app.email} -> link failed: ${JSON.stringify(r && (r.error || r))}`);
+ app.link_status = 'unlinked'; app.link_error = (r && r.error) || 'unknown';
+ await sleep(DELAY_MS); continue;
+ }
+ app.shopify_customer_id = r.id;
+ app.link_status = 'linked'; app.link_via = r.via || null; app.link_created = !!r.created;
+ app.recovered_at = new Date().toISOString();
+ recovered.push({ app_id: app.id, email: app.email, customer_id: r.id, created: !!r.created, via: r.via || null });
+ console.log(` [linked] ${app.id} ${app.email} -> customer ${r.id} (${r.via || (r.created ? 'created' : 'existing')})`);
+ await sleep(DELAY_MS);
+ }
+
+ if (!APPLY) { console.log(`\nDry-run only. Re-run with --apply to link (add --send-emails to also send activation/office letters).`); return; }
+
+ // Persist: back up the jsonl, write rows, write the reversible recovery map.
+ const ts = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
+ const jsonlBak = FILE + '.recovery-bak-' + ts;
+ fs.copyFileSync(FILE, jsonlBak);
+ writeRows(rows);
+ const mapPath = path.join(path.dirname(FILE), `recovery-${ts}.json`);
+ fs.writeFileSync(mapPath, JSON.stringify({ ts, file: FILE, jsonl_backup: jsonlBak, count: recovered.length, recovered }, null, 2));
+ console.log(`\nLinked ${recovered.length}. jsonl backup: ${jsonlBak} recovery map (rollback): ${mapPath}`);
+
+ if (SEND_EMAILS) {
+ for (const rec of recovered) {
+ const app = rows.find((a) => a.id === rec.app_id);
+ const { firstName } = firstLast(app);
+ // (a) designer activation letter (EXTERNAL send — gated)
+ const t = email.designerAccountReadyEmail({ firstName: firstName || app.email.split('@')[0] });
+ const m = await email.sendEmail({ to: app.email, subject: t.subject, html: t.html, source: 'trade-recovery-activation' });
+ console.log(` [email:designer] ${app.email} ok=${m.ok !== false} dryRun=${m.dryRun || false}`);
+ // office re-surface as now-approvable (INTERNAL inbox)
+ const ot = email.tradeApplicationEmail({ app, approveUrl: `${config.PUBLIC_URL || ''}/admin/trade`, rejectUrl: `${config.PUBLIC_URL || ''}/admin/trade`, adminUrl: `${config.PUBLIC_URL || ''}/admin/trade` });
+ const om = await email.sendEmail({ to: config.TRADE_NOTIFY_TO, subject: '[Now approvable] ' + ot.subject, html: ot.html, source: 'trade-recovery-office' });
+ console.log(` [email:office] ${config.TRADE_NOTIFY_TO} ok=${om.ok !== false} dryRun=${om.dryRun || false}`);
+ await sleep(DELAY_MS);
+ }
+ } else {
+ console.log(`No emails sent (--send-emails not set). Apps are LINKED and now approvable at /admin/trade.`);
+ }
+
+ if (AUTO_APPROVE) {
+ console.log(`\n--auto-approve (option b) requested: run approve() per recovered app. NOTE: this GRANTS trade pricing without human moderation.`);
+ for (const rec of recovered) {
+ const ap = await trade.approve(rec.app_id);
+ console.log(` [approve] ${rec.app_id} -> ${ap.ok ? ap.status : JSON.stringify(ap)}`);
+ await sleep(DELAY_MS);
+ }
+ }
+}
+
+main().catch((e) => { console.error('recover-stuck-apps error:', e.message); process.exit(1); });
← 5e609e8 TK-11185: add Gate 2 theme PUT + rollback scripts (cwd-indep
·
back to Dw Signup Fulfillment
·
TK-11190: harden recover-stuck-apps — fail-loud --apply DRY_ 90c2cb8 →