← back to NationalPaperHangers

scripts/notify-interest-queue.js

173 lines

#!/usr/bin/env node
// One-shot worker: scan installer_interest for un-notified rows where the
// installer is now ready to take bookings (claimed + paid + has availability),
// send the "they're live" notification, mark notified_at + notify_msg_id.
//
// Designed to run on a 15-minute launchd timer. Each row is independently
// audited via lib/compliance, so a partial failure (one bad email, one
// suppression hit) doesn't block the rest of the queue.
//
// Usage:
//   node scripts/notify-interest-queue.js              # production run
//   node scripts/notify-interest-queue.js --dry-run    # log what would send, no DB write, no email
//   node scripts/notify-interest-queue.js --limit 10   # cap iteration count

const path = require('node:path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });

const db = require('../lib/db');
const { sendEmail } = require('../lib/email');
const compliance = require('../lib/compliance');

const DRY_RUN = process.argv.includes('--dry-run');
const LIMIT = (() => {
  const i = process.argv.indexOf('--limit');
  return i >= 0 ? parseInt(process.argv[i + 1], 10) || 100 : 100;
})();

const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://nationalpaperhangers.com').replace(/\/+$/, '');
const CAMPAIGN = 'installer_now_live';

function buildEmail({ installer, unsubscribeUrl }) {
  const subject = `${installer.business_name} just opened bookings on National Paper Hangers`;
  const bookUrl = `${PUBLIC_URL}/installer/${encodeURIComponent(installer.slug)}/book`;
  const profileUrl = `${PUBLIC_URL}/installer/${encodeURIComponent(installer.slug)}`;
  const cityState = [installer.city, installer.state].filter(Boolean).join(', ');

  const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
  <p style="font-size:13px;letter-spacing:0.08em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">Studio now live</p>
  <h1 style="font-size:26px;font-weight:400;margin:0 0 16px;line-height:1.2">${installer.business_name} is on National Paper Hangers</h1>
  <p style="font-size:15px;line-height:1.6;margin:0 0 20px">You asked us to let you know when ${installer.business_name}${cityState ? ' in ' + cityState : ''} opened up for direct booking. They've claimed their listing and configured their calendar — you can book a visit now.</p>
  <p style="margin:24px 0">
    <a href="${bookUrl}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:14px 24px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Book a visit →</a>
  </p>
  <p style="font-size:13px;line-height:1.6;color:#666;margin:0 0 8px">Or browse their work first at <a href="${profileUrl}" style="color:#666">${profileUrl.replace(/^https?:\/\//, '')}</a>.</p>
${compliance.complianceFooter({ campaign: CAMPAIGN, unsubscribeUrl })}
</div>`;

  const text = `${installer.business_name} just opened bookings on National Paper Hangers.\n\nYou asked us to notify you. Book a visit: ${bookUrl}\n\nProfile: ${profileUrl}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
  return { subject, html, text };
}

(async () => {
  // Pre-flight compliance gate. Throws if MAILING_ADDRESS / SESSION_SECRET /
  // PUBLIC_URL / suppression tables aren't ready. Don't enter the loop on a
  // misconfigured host.
  if (!DRY_RUN) {
    await compliance.assertSendCompliance({ campaign: CAMPAIGN });
  }

  // The "ready" filter mirrors the calendarEnabled gate in routes/public.js
  // (claim_status in self/claimed AND tier in pro/signature/enterprise AND
  //  ≥1 active installer_availability window). Aggregating availability count
  // keeps it to a single query.
  const queue = await db.query(`
    SELECT ii.id           AS interest_id,
           ii.email,
           ii.installer_id,
           i.slug,
           i.business_name,
           i.city, i.state
      FROM installer_interest ii
      JOIN installers i ON i.id = ii.installer_id
     WHERE ii.notified_at IS NULL
       AND i.claim_status IN ('self','claimed')
       AND i.tier IN ('pro','signature','enterprise')
       AND EXISTS (
         SELECT 1 FROM installer_availability a
          WHERE a.installer_id = i.id AND a.active = true
       )
     ORDER BY ii.created_at ASC
     LIMIT $1
  `, [LIMIT]);

  console.log(`[notify-queue] ${queue.rowCount} candidate(s)${DRY_RUN ? ' (dry run)' : ''}`);
  if (queue.rowCount === 0) return;

  let sent = 0, suppressed = 0, errors = 0;

  for (const row of queue.rows) {
    const recipient = String(row.email).trim().toLowerCase();

    // 1. Suppression check — recipient may have unsubscribed since capture.
    const supressed = await compliance.isSuppressed({ channel: 'email', identifier: recipient });
    if (supressed) {
      suppressed++;
      console.log(`  · ${recipient} → SUPPRESSED (skipping, marking notified to clear queue)`);
      if (!DRY_RUN) {
        await db.query(
          `UPDATE installer_interest SET notified_at = now(), notify_msg_id = 'suppressed' WHERE id = $1`,
          [row.interest_id]
        );
        await compliance.recordAudit({
          channel: 'email', campaign: CAMPAIGN, recipient,
          installerId: row.installer_id,
          decision: 'skipped', reason: 'suppression',
          subject: null, messageId: null, payload: { interest_id: row.interest_id }
        });
      }
      continue;
    }

    // 2. Mint a 1-click unsubscribe token + build the email
    const unsubToken = DRY_RUN
      ? 'dry-run-token'
      : await compliance.mintUnsubscribeToken({
          channel: 'email', identifier: recipient,
          campaign: CAMPAIGN, installerId: row.installer_id
        });
    const unsubscribeUrl = `${PUBLIC_URL}/unsubscribe?t=${unsubToken}`;
    const { subject, html, text } = buildEmail({ installer: row, unsubscribeUrl });

    if (DRY_RUN) {
      console.log(`  · ${recipient} → would send "${subject}" (slug=${row.slug})`);
      sent++;
      continue;
    }

    // 3. Send
    const result = await sendEmail({
      to: recipient,
      subject, html, text,
      extraHeaders: compliance.listUnsubscribeHeader(unsubscribeUrl)
    });

    // 4. Record outcome — both audit row and the queue row.
    if (result && result.ok) {
      sent++;
      await db.query(
        `UPDATE installer_interest SET notified_at = now(), notify_msg_id = $2 WHERE id = $1`,
        [row.interest_id, result.messageId || 'sent-no-id']
      );
      await compliance.recordAudit({
        channel: 'email', campaign: CAMPAIGN, recipient,
        installerId: row.installer_id,
        decision: 'sent', reason: null,
        subject, messageId: result.messageId || null,
        payload: { interest_id: row.interest_id }
      });
      console.log(`  · ${recipient} → sent (msg ${(result.messageId || '').slice(0, 28)}…)`);
    } else {
      errors++;
      await compliance.recordAudit({
        channel: 'email', campaign: CAMPAIGN, recipient,
        installerId: row.installer_id,
        decision: 'errored', reason: (result && result.error) || 'unknown',
        subject, messageId: null,
        payload: { interest_id: row.interest_id, smtp_code: result && result.code }
      });
      console.error(`  · ${recipient} → ERROR ${(result && result.code) || ''} ${(result && result.error || '').slice(0, 80)}`);
      // Don't update notified_at — let the next run retry. Stripe-style backoff
      // is overkill here; we run on a 15-min cadence so worst-case retry latency is bounded.
    }
  }

  console.log(`[notify-queue] done — sent=${sent} suppressed=${suppressed} errors=${errors}`);
})().catch(err => {
  console.error('[notify-queue] FATAL:', err.message);
  process.exit(1);
}).finally(() => {
  // pg pool cleanup so the process exits cleanly under launchd.
  setTimeout(() => process.exit(0), 200);
});