← back to NationalPaperHangers

scripts/send-claim-invitations.js

216 lines

#!/usr/bin/env node
// Gated send script for the "claim your listing" outbound campaign.
//
// Default mode: DRY-RUN. Prints exactly what would be sent + what's blocked.
// Use --commit to actually send via George.
//
// CRITICAL — fail-closed prerequisites enforced via lib/compliance.js:
//   1. MAILING_ADDRESS env (real, deliverable street address)
//   2. PUBLIC_URL (https in prod, non-localhost in dev)
//   3. SESSION_SECRET (or UNSUBSCRIBE_SIGNING_SECRET)
//   4. comms_suppression / comms_send_audit / unsubscribe_tokens tables
//
// The script ALSO checks Steve's standing rule from MEMORY.md: never use
// Anthropic API key — irrelevant here, no LLM calls.
//
// PER-RECIPIENT:
//   - Suppression scrub against comms_suppression
//   - Audit row in comms_send_audit (decision: sent | blocked_*)
//   - List-Unsubscribe header on every send
//   - In-body compliance footer with mailing address + unsubscribe link
//
// EMAIL SOURCING:
//   The email address is NOT pulled from the installer row (DATA_POLICY §3
//   forbids storing it). It must be supplied via --email on the CLI for a
//   single targeted send, or a CSV via --csv when Steve has authorized the
//   data-collection carve-out for the campaign. The dream-team's verdict is
//   to use IG DM as the primary channel — email is the fallback that needs
//   explicit per-send authorization.
//
// USAGE:
//   node scripts/send-claim-invitations.js --slug=studio-slug --email=info@studio.com
//   node scripts/send-claim-invitations.js --slug=studio-slug --email=info@studio.com --commit
//   node scripts/send-claim-invitations.js --csv=./outreach-batch.csv --commit
//
// Each row of the CSV must be: slug,email   (no header, no quotes).

require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');
const email = require('../lib/email');
const compliance = require('../lib/compliance');

const ARGS = process.argv.slice(2);
const COMMIT = ARGS.includes('--commit');
const arg = (k, def = null) => {
  const a = ARGS.find(x => x.startsWith(`--${k}=`));
  return a ? a.split('=').slice(1).join('=') : def;
};

const CAMPAIGN = 'claim_invite';

function escapeHtml(s) {
  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

async function loadCsv(csvPath) {
  const abs = path.resolve(csvPath);
  const txt = fs.readFileSync(abs, 'utf8');
  return txt.split(/\r?\n/).filter(Boolean).map(line => {
    const [slug, addr] = line.split(',').map(s => (s || '').trim());
    return { slug, email: (addr || '').toLowerCase() };
  });
}

function buildClaimInviteHtml({ installer, claimUrl, unsubscribeUrl }) {
  const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
  <h1 style="font-size:22px;margin:0 0 12px">Claim your listing on National Paper Hangers</h1>
  <p style="font-size:15px;line-height:1.6">Hello,</p>
  <p style="font-size:15px;line-height:1.6">
    We've listed <strong>${escapeHtml(installer.business_name)}</strong> in the National Paper Hangers
    public directory based on your <a href="https://wallcoveringinstallers.org" style="color:#0e0e0e">WIA</a>
    accreditation. Trade buyers (designers, hospitality operators, architects) are finding studios on NPH
    when they need a luxury wallcovering installer outside Los Angeles.
  </p>
  <p style="font-size:15px;line-height:1.6">
    If you'd like to take control of the listing — update your bio, add portfolio images, set availability,
    and accept self-booked consultations — claim it in 2 minutes:
  </p>
  <p style="text-align:center;margin:24px 0">
    <a href="${claimUrl}" style="display:inline-block;padding:14px 22px;background:#0e0e0e;color:#fff;text-decoration:none">Claim ${escapeHtml(installer.business_name)}</a>
  </p>
  <p style="font-size:13px;color:#666;line-height:1.6">
    The basic listing is free. Pro ($39/mo) and Signature ($149/mo) add live calendar booking and a Verified badge.
    No obligation — your listing stays visible either way.
  </p>
  <p style="font-size:13px;color:#666;line-height:1.6">
    Questions? Reply to this email — it goes to a person, not a queue.
  </p>
  ${compliance.complianceFooter({ campaign: CAMPAIGN, unsubscribeUrl })}
</div>`;
  return html;
}

async function sendOne({ slug, recipientEmail }) {
  if (!slug || !recipientEmail) {
    return { ok: false, reason: 'missing_slug_or_email' };
  }
  recipientEmail = recipientEmail.toLowerCase();

  const installer = await db.one(
    `SELECT id, slug, business_name, website, claim_status FROM installers WHERE slug = $1`,
    [slug]
  );
  if (!installer) {
    return { ok: false, reason: 'installer_not_found' };
  }
  if (installer.claim_status === 'claimed') {
    await compliance.recordAudit({
      channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
      installerId: installer.id, decision: 'blocked_already_claimed', reason: 'claim_status=claimed'
    });
    return { ok: false, reason: 'already_claimed' };
  }

  const suppressed = await compliance.isSuppressed({ channel: 'email', identifier: recipientEmail });
  if (suppressed) {
    await compliance.recordAudit({
      channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
      installerId: installer.id, decision: 'blocked_suppression', reason: 'recipient on comms_suppression'
    });
    return { ok: false, reason: 'suppressed' };
  }

  const publicUrl = process.env.PUBLIC_URL.replace(/\/+$/, '');
  const claimUrl = `${publicUrl}/installer/${installer.slug}/claim`;
  const unsubToken = await compliance.mintUnsubscribeToken({
    channel: 'email', identifier: recipientEmail, campaign: CAMPAIGN, installerId: installer.id
  });
  const unsubscribeUrl = `${publicUrl}/unsubscribe?token=${encodeURIComponent(unsubToken)}`;
  const subject = `Claim your listing — ${installer.business_name}`;
  const html = buildClaimInviteHtml({ installer, claimUrl, unsubscribeUrl });
  const headers = compliance.listUnsubscribeHeader(unsubscribeUrl);

  if (!COMMIT) {
    console.log(`[DRY-RUN] would send to ${recipientEmail} re: ${installer.business_name}`);
    console.log(`         claim:        ${claimUrl}`);
    console.log(`         unsubscribe:  ${unsubscribeUrl}`);
    return { ok: true, dryRun: true };
  }

  try {
    const result = await email.sendEmail({
      to: recipientEmail, subject, html, extraHeaders: headers
    });
    await compliance.recordAudit({
      channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
      installerId: installer.id,
      decision: result.ok ? 'sent' : 'failed',
      reason: result.ok ? null : (result.error || 'send_failed'),
      subject,
      messageId: result.id || result.messageId || null,
      payload: { claimUrl, unsubscribeUrl }
    });
    return { ok: result.ok, reason: result.ok ? 'sent' : 'send_failed' };
  } catch (err) {
    await compliance.recordAudit({
      channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
      installerId: installer.id, decision: 'failed', reason: err.message, subject
    });
    return { ok: false, reason: 'exception:' + err.message };
  }
}

async function main() {
  console.log(`[claim-invite] mode=${COMMIT ? 'COMMIT' : 'DRY-RUN'}`);

  // Pre-flight gate. Throws ComplianceError if anything is missing.
  try {
    await compliance.assertSendCompliance({ campaign: CAMPAIGN });
    console.log('[claim-invite] compliance gate: PASS');
  } catch (err) {
    console.error(`[claim-invite] compliance gate: FAIL — ${err.code}`);
    console.error(`               ${err.message}`);
    process.exit(2);
  }

  const csvPath = arg('csv');
  const oneSlug = arg('slug');
  const oneEmail = arg('email');

  let batch = [];
  if (csvPath) {
    batch = await loadCsv(csvPath);
    console.log(`[claim-invite] loaded ${batch.length} recipients from ${csvPath}`);
  } else if (oneSlug && oneEmail) {
    batch = [{ slug: oneSlug, email: oneEmail.toLowerCase() }];
  } else {
    console.error('Usage: --csv=path.csv  OR  --slug=… --email=…  (add --commit to actually send)');
    process.exit(1);
  }

  let sent = 0, blocked = 0, failed = 0, dry = 0;
  for (const row of batch) {
    const r = await sendOne({ slug: row.slug, recipientEmail: row.email });
    if (r.dryRun) dry++;
    else if (r.ok) sent++;
    else if (r.reason === 'suppressed' || r.reason === 'already_claimed') blocked++;
    else failed++;
    // Throttle so we never blast — 2s between sends
    if (COMMIT) await new Promise(r => setTimeout(r, 2000));
  }

  console.log(`[claim-invite] done · sent=${sent} blocked=${blocked} failed=${failed} dry-run=${dry}`);

  // Steve's standing rule: alert on >10% failure rate
  const total = sent + failed;
  if (total > 0 && (failed / total) > 0.10) {
    console.warn(`[claim-invite] FAILURE-RATE-WARNING: ${(failed/total*100).toFixed(0)}% — review comms_send_audit`);
  }

  await db.pool.end();
}

main().catch(err => { console.error(err); process.exit(1); });