← 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 };
}