← back to Dw Signup Fulfillment
scripts/recover-stuck-apps.js
264 lines
'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.
//
// FAIL-LOUD: `--apply` REFUSES to run while config.DRY_RUN is on (exit 1) — a bare
// `node recover-stuck-apps.js --apply` inherits DRY_RUN=1 and would stamp fake stub ids
// into the jsonl (the TK-11190 footgun). Real runs MUST set DRY_RUN=0.
//
// Usage:
// node scripts/recover-stuck-apps.js # dry-run, probes create-vs-reuse
// DRY_RUN=0 node scripts/recover-stuck-apps.js --apply --limit 25 # link 25, NO emails
// DRY_RUN=0 node scripts/recover-stuck-apps.js --apply --limit 25 --send-emails # link + email
// DRY_RUN=0 node scripts/recover-stuck-apps.js --send-only --limit 25 # email already-LINKED apps (idempotent)
// node scripts/recover-stuck-apps.js --send-only --limit 25 # DRY preview of the sends (no send, no flag)
// DRY_RUN=0 node scripts/recover-stuck-apps.js --rollback data/recovery-<ts>.json # delete 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 SEND_ONLY = has('--send-only'); // email already-LINKED apps that weren't emailed yet
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' : '')); }
// TK-11285 preflight: this script is a SECOND, out-of-process writer of the same
// jsonl the live service writes. Its read->write window spans every Shopify call and
// George send in the batch (minutes), and a naive writeRows(snapshot) silently
// destroys anything the service did meanwhile: a trade application submitted during
// the window vanishes, and a COMPLETED approval reverts to pending even though the
// customer was already tagged trade_approved and emailed. lib/trade.js's decision
// lock cannot see us - it only coordinates inside the service process.
//
// So never write a stale snapshot. Re-read at write time and merge ONLY the fields
// this script owns onto the fresh rows (same shape as trade.checkpointApproval).
// Rows we never touched are preserved verbatim; rows the service has since decided
// are left alone and reported.
// LINK_FIELDS describe work we did against Shopify for a row we believed was still
// pending; if the service has since decided that row, they are dropped.
// RECEIPT_FIELDS only record mail WE already sent - they never conflict with a
// decision, and dropping them would make a later run re-send a duplicate letter.
const LINK_FIELDS = ['shopify_customer_id', 'link_status', 'link_via', 'link_created',
'link_error', 'recovered_at'];
const RECEIPT_FIELDS = ['recovery_emailed', 'recovery_emailed_at'];
function commitRows(touched) {
const fresh = readRows();
const index = new Map(fresh.map((r, i) => [r.id, i]));
const skipped = [];
for (const t of touched) {
const i = index.get(t.id);
if (i == null) { skipped.push({ id: t.id, reason: 'row_disappeared' }); continue; }
const current = fresh[i];
const patch = {};
// Receipts are always safe to record: they state what we already sent.
for (const f of RECEIPT_FIELDS) if (t[f] !== undefined) patch[f] = t[f];
// The service owns the decision. If it decided this row while we were awaiting,
// keep the receipts but drop our linkage rather than overwrite its outcome.
if (current.status !== 'pending' && t.status === 'pending') {
skipped.push({ id: t.id, reason: `service_decided_${current.status}_link_dropped` });
fresh[i] = { ...current, ...patch };
continue;
}
for (const f of LINK_FIELDS) {
if (t[f] === undefined) continue;
// Never clobber a customer id the service resolved itself.
if (f === 'shopify_customer_id' && current[f] && String(current[f]) !== String(t[f])) {
skipped.push({ id: t.id, reason: 'customer_id_conflict' });
// delete, never `= undefined`: an undefined value still spreads and would
// erase the id the service resolved.
delete patch.shopify_customer_id;
continue;
}
patch[f] = t[f];
}
fresh[i] = { ...current, ...patch };
}
writeRows(fresh);
if (skipped.length) {
console.log(` [merge] ${skipped.length} row(s) left to the service: ` +
skipped.map((x) => `${x.id}:${x.reason}`).join(', '));
}
return { written: fresh.length, skipped };
}
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}"`);
}
// Send the two option-(a) letters for one LINKED app. Sets recovery_emailed ONLY on a
// REAL send (a DRY_RUN preview returns {dryRun:true} and must NOT consume the flag, or a
// later real run would skip the app). Returns true iff the flag was set (real send).
async function sendForApp(app) {
const { firstName } = firstLast(app);
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' });
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' });
const realSend = (m.ok !== false && !m.dryRun);
console.log(` [email:designer] ${app.email} ok=${m.ok !== false} dryRun=${m.dryRun || false} [email:office] ${config.TRADE_NOTIFY_TO} dryRun=${om.dryRun || false}`);
if (realSend) { app.recovery_emailed = true; app.recovery_emailed_at = new Date().toISOString(); }
else console.log(` (DRY preview — recovery_emailed NOT set; a real DRY_RUN=0 run will still send)`);
return realSend;
}
// --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 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}`);
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); }
if (sent > 0) { commitRows(targets); console.log(`\nSent ${sent}; recovery_emailed stamped + jsonl updated (merged onto a fresh read).`); }
else console.log(`\nNo real sends (DRY_RUN preview) — jsonl untouched, flags preserved for a real run.`);
}
async function main() {
if (ROLLBACK) return rollback();
if (SEND_ONLY) return sendOnly();
// FAIL-LOUD (TK-11190 footgun): a standalone `node recover-stuck-apps.js --apply` inherits
// DRY_RUN=1 (config reads it only from process.env; pm2 env injection doesn't reach a bare
// node invocation). The APPLY branch would then stamp FAKE synthetic customer ids into the
// jsonl (writeRows is unconditional). Refuse BEFORE any Shopify call or write.
if (APPLY && config.DRY_RUN) {
console.error('REFUSING --apply while DRY_RUN is on: this would stamp fake dry-run stub customer ids into the jsonl. Re-run with DRY_RUN=0 (e.g. `DRY_RUN=0 node scripts/recover-stuck-apps.js --apply`).');
process.exit(1);
}
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);
commitRows(batch);
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) {
let sent = 0;
for (const rec of recovered) {
const app = rows.find((a) => a.id === rec.app_id);
if (await sendForApp(app)) sent++;
await sleep(DELAY_MS);
}
if (sent > 0) { commitRows(recovered.map((rec) => rows.find((a) => a.id === rec.app_id)).filter(Boolean)); console.log(` emailed ${sent}; recovery_emailed stamped + jsonl updated (merged onto a fresh read).`); }
} 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);
}
}
}
if (require.main === module) {
main().catch((e) => { console.error('recover-stuck-apps error:', e.message); process.exit(1); });
} else {
module.exports = { commitRows, readRows, writeRows, LINK_FIELDS, RECEIPT_FIELDS };
}