← back to Dw Signup Fulfillment

scripts/tk11411-resend.js

123 lines

'use strict';
// TK-11411 — re-send the mail lost during the George 401 outage (2026-09-10).
//
// George rejected every send with "Invalid credentials" for the duration of the
// outage. Two cohorts lost different letters:
//   RETAIL  — signed up for free samples, never got "confirm your email", so they
//             never clicked, so they never got the verified-sample tag, so they
//             cannot claim samples. Remedy: re-run the SAME verification start the
//             /claim route uses (lib/verify.startVerification) — mints a fresh
//             token and sends the letter.
//   TRADE   — their approval genuinely LANDED (Shopify carries trade+trade_approved
//             AND the local row says approved) but the "you're approved" letter
//             failed, so they were never told. Remedy: send that letter only. Do
//             NOT re-run trade.approve() — they are already approved and it would
//             re-tag and re-assign a rep.
//
// SAFE BY DESIGN:
//   * DRY-RUN by default. --apply is required to send, and it refuses while
//     config.DRY_RUN is on (a bare `node ...` inherits DRY_RUN=1 and would
//     otherwise silently "succeed" without sending).
//   * COHORT IS DERIVED AT RUN TIME, never hardcoded: a customer that already has
//     verified-sample is SKIPPED, so anyone who recovered on their own is not
//     re-mailed.
//   * IDEMPOTENT via data/tk11411-resend-ledger.jsonl — an address already recorded
//     as sent is skipped on every later run.
//   * Ledger is append-only and written AFTER a confirmed real send.
const fs = require('fs');
const path = require('path');
const config = require('../lib/config');
const shopify = require('../lib/shopify');
const email = require('../lib/email');
const verify = require('../lib/verify');

const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const LEDGER = path.join(__dirname, '..', 'data', 'tk11411-resend-ledger.jsonl');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// The addresses observed with source=retail-verify / designer-welcome /
// trade-application 401s in the outage logs. Classification is NOT taken from
// here — it is derived below from live Shopify + the local store.
const COHORT = ['j.dmce@post.harvard.edu', 'juliewendl@aol.com', 'kristopher.woodcock@gmail.com',
  'lchandlee@aol.com', 'lizbeth@luxupholstery.com', 'margaret@designmadesimple.design',
  'noelle@silkandslate.com', 'office@leddyinteriors.com', 'sdkett20@gmail.com',
  'sheldonlycan@gmail.com', 'skl@liocowine.com', 'team@michellewalshdesigns.com',
  'tori@bellewoodgroup.com', 'yanchapaxi@auxarchitecture.com'];

function alreadySent(addr) {
  try {
    return fs.readFileSync(LEDGER, 'utf8').split('\n').filter(Boolean)
      .some((l) => { try { return JSON.parse(l).email === addr; } catch { return false; } });
  } catch { return false; }
}
function record(row) { fs.appendFileSync(LEDGER, JSON.stringify(row) + '\n'); }

function tradeRows() {
  try {
    return fs.readFileSync(path.join(__dirname, '..', 'data', 'trade-applications.jsonl'), 'utf8')
      .split('\n').filter(Boolean).map((l) => JSON.parse(l));
  } catch { return []; }
}

(async () => {
  if (APPLY && config.DRY_RUN) {
    console.error('REFUSING --apply while DRY_RUN is on: nothing would actually send. Re-run with DRY_RUN=0.');
    process.exit(1);
  }
  // FAIL LOUD, ONCE, BEFORE ANY SEND. PUBLIC_URL lives in ecosystem.config.js, which pm2
  // injects into the SERVICE — a bare `node scripts/...` does NOT inherit it, so verify.js
  // refuses every retail send with no_public_url. Without this pre-flight that surfaces as
  // 11 identical per-customer failures after the trade cohort has already been mailed,
  // i.e. a half-completed run. Same class as the DRY_RUN footgun documented at the top of
  // scripts/recover-stuck-apps.js — which I had read, and repeated anyway.
  if (APPLY && !config.PUBLIC_URL) {
    console.error('REFUSING --apply: PUBLIC_URL is unset, so every retail verify letter would');
    console.error('carry a dead localhost link. Run with the value the service uses, e.g.');
    console.error("  PUBLIC_URL=$(node -e \"console.log(require('./ecosystem.config.js').apps[0].env.PUBLIC_URL)\") DRY_RUN=0 node scripts/tk11411-resend.js --apply");
    process.exit(1);
  }
  console.log(`TK-11411 resend  mode=${APPLY ? 'APPLY' : 'DRY-RUN'}  DRY_RUN(config)=${config.DRY_RUN}`);
  const rows = tradeRows();
  let sent = 0, skipped = 0;

  for (const addr of COHORT) {
    if (alreadySent(addr)) { console.log(`  [skip] ${addr} — already re-sent (ledger)`); skipped++; continue; }

    const id = await shopify.findCustomerByEmail(addr);
    if (!id) { console.log(`  [skip] ${addr} — no Shopify customer`); skipped++; continue; }
    const custId = String(id).replace(/\D/g, '');
    const res = await shopify.getCustomer(custId);
    const c = (res && res.json && (res.json.customer || (res.json.data && res.json.data.customer))) || {};
    const tags = Array.isArray(c.tags) ? c.tags.join(',') : (c.tags || '');

    if (/verified-sample/.test(tags)) { console.log(`  [skip] ${addr} — already verified-sample (recovered on its own)`); skipped++; continue; }

    // TRADE: approved on BOTH sides, only the letter was lost.
    const approvedRow = rows.find((r) => String(r.email).toLowerCase() === addr && r.status === 'approved');
    const isTrade = /(^|,)\s*trade_approved\s*(,|$)/.test(tags) && !!approvedRow;

    if (isTrade) {
      const repName = (approvedRow.assigned_rep && approvedRow.assigned_rep.name) || 'your DW rep';
      const tpl = email.tradeApprovedEmail({ applicant: approvedRow, repName });
      if (!APPLY) { console.log(`  [dry] ${addr} — TRADE: would re-send "${tpl.subject}"`); continue; }
      const m = await email.sendEmail({ to: addr, subject: tpl.subject, html: tpl.html, source: 'tk11411-trade-approved-resend' });
      const ok = m && m.ok === true && !m.dryRun;
      console.log(`  [${ok ? 'sent' : 'FAIL'}] ${addr} — TRADE approval letter${ok ? '' : ' :: ' + JSON.stringify(m && (m.error || m.status))}`);
      if (ok) { record({ ts: new Date().toISOString(), email: addr, cohort: 'trade', subject: tpl.subject }); sent++; }
      await sleep(400);
      continue;
    }

    // RETAIL: re-run the same verification start /claim uses.
    if (!APPLY) { console.log(`  [dry] ${addr} — RETAIL: would re-send the confirm-your-email verify letter`); continue; }
    const r = await verify.startVerification({ email: addr, customerId: custId, firstName: c.first_name || addr.split('@')[0] });
    const ok = r && r.ok !== false && !r.dryRun;
    console.log(`  [${ok ? 'sent' : 'FAIL'}] ${addr} — RETAIL verify letter${ok ? '' : ' :: ' + JSON.stringify(r && (r.reason || r.status))}`);
    if (ok) { record({ ts: new Date().toISOString(), email: addr, cohort: 'retail' }); sent++; }
    await sleep(400);
  }
  console.log(`\n${APPLY ? 'Sent' : 'Would send'}: ${APPLY ? sent : COHORT.length - skipped}  ·  skipped: ${skipped}`);
  if (!APPLY) console.log('Dry-run only. Re-run with DRY_RUN=0 --apply to actually send.');
})().catch((e) => { console.error('tk11411-resend error:', e.message); process.exit(1); });