[object Object]

← back to Ventura Claw Leads

yolo tick 29: weekly-digest worker — Monday-morning summary email per claimed business

826d347a0339eccb391e96251aae0a8f77de9189 · 2026-05-07 03:55:41 -0700 · Steve Abrams

scripts/weekly-digest.js:
- Pulls claimed-with-email businesses
- Skip when msgs_7d=0 AND views_7d=0 (nothing to summarize, don't spam silent listings)
- assertSendCompliance pre-flight (CAN-SPAM gate fail-closed)
- isSuppressed scrub per recipient
- mintUnsubscribeToken + List-Unsubscribe headers (RFC 2369/8058)
- complianceFooter with mailing address
- Audit each send to comms_send_audit (decision='sent' / 'failed' /
  reason='suppression')

Email body:
  - 3-stat header row (msgs_7d / views_7d / unread, brass-italic)
  - 'This week's new messages' table (top 5 names + emails)
  - 'Open dashboard' CTA
  - Live-profile link in muted footer
  - Compliance footer + unsubscribe link

DEFAULT MODE: --dry-run (no actual emails). Pass --live to send.

Cron NOT registered yet — Steve's call when to flip it on:
  0 8 * * 1   /usr/bin/node /root/Projects/ventura-claw-leads/scripts/weekly-digest.js --live >> /var/log/vcl-weekly-digest.log 2>&1

Tested local --dry-run with 2 claimed businesses (1 active, 1 quiet):
  · active → 'Your Ventura Claw week — 2 new messages, 13 profile views'
  · quiet → silent skip
  Output: sent=1 suppressed=0 skipped_no_activity=1 errors=0

46/46 tests still pass.

Files touched

Diff

commit 826d347a0339eccb391e96251aae0a8f77de9189
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 7 03:55:41 2026 -0700

    yolo tick 29: weekly-digest worker — Monday-morning summary email per claimed business
    
    scripts/weekly-digest.js:
    - Pulls claimed-with-email businesses
    - Skip when msgs_7d=0 AND views_7d=0 (nothing to summarize, don't spam silent listings)
    - assertSendCompliance pre-flight (CAN-SPAM gate fail-closed)
    - isSuppressed scrub per recipient
    - mintUnsubscribeToken + List-Unsubscribe headers (RFC 2369/8058)
    - complianceFooter with mailing address
    - Audit each send to comms_send_audit (decision='sent' / 'failed' /
      reason='suppression')
    
    Email body:
      - 3-stat header row (msgs_7d / views_7d / unread, brass-italic)
      - 'This week's new messages' table (top 5 names + emails)
      - 'Open dashboard' CTA
      - Live-profile link in muted footer
      - Compliance footer + unsubscribe link
    
    DEFAULT MODE: --dry-run (no actual emails). Pass --live to send.
    
    Cron NOT registered yet — Steve's call when to flip it on:
      0 8 * * 1   /usr/bin/node /root/Projects/ventura-claw-leads/scripts/weekly-digest.js --live >> /var/log/vcl-weekly-digest.log 2>&1
    
    Tested local --dry-run with 2 claimed businesses (1 active, 1 quiet):
      · active → 'Your Ventura Claw week — 2 new messages, 13 profile views'
      · quiet → silent skip
      Output: sent=1 suppressed=0 skipped_no_activity=1 errors=0
    
    46/46 tests still pass.
---
 scripts/weekly-digest.js | 186 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 186 insertions(+)

diff --git a/scripts/weekly-digest.js b/scripts/weekly-digest.js
new file mode 100644
index 0000000..f799a40
--- /dev/null
+++ b/scripts/weekly-digest.js
@@ -0,0 +1,186 @@
+#!/usr/bin/env node
+// Weekly digest — Monday-morning summary email per claimed business with
+// activity in the past 7 days. Sent via Purelymail SMTP, audited to
+// comms_send_audit, suppression-scrubbed, RFC 8058 unsubscribe headers.
+//
+// Default mode: --dry-run (no email, just log). Pass --live to actually send.
+// Cron suggestion (after first manual --live verification):
+//   0 8 * * 1   /usr/bin/node /root/Projects/ventura-claw-leads/scripts/weekly-digest.js --live >> /var/log/vcl-weekly-digest.log 2>&1
+//
+// Skip list:
+//   - Businesses with no email on file
+//   - Businesses with zero activity in the past 7 days (nothing to summarize)
+//   - Recipients on the suppression list (consumer or business unsubscribed)
+
+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 LIVE = process.argv.includes('--live');
+const DRY = !LIVE || process.argv.includes('--dry-run');
+const LIMIT = (() => {
+  const i = process.argv.indexOf('--limit');
+  return i >= 0 ? parseInt(process.argv[i + 1], 10) || 1000 : 1000;
+})();
+
+const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+const CAMPAIGN = 'weekly_digest';
+
+function escapeHtml(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
+
+function buildEmail({ business, stats, leads, unsubscribeUrl }) {
+  const subject = `Your Ventura Claw week — ${stats.msgs_7d} new message${stats.msgs_7d === 1 ? '' : 's'}, ${stats.views_7d} profile view${stats.views_7d === 1 ? '' : 's'}`;
+  const profileUrl = `${PUBLIC_URL}/business/${encodeURIComponent(business.slug)}`;
+  const dashboardUrl = `${PUBLIC_URL}/admin`;
+
+  const leadRows = leads.slice(0, 5).map(l => `
+    <tr>
+      <td style="padding:8px 0;color:#666;width:90px;font-size:12px">${new Date(l.created_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' })}</td>
+      <td style="padding:8px 0;font-size:13px"><strong>${escapeHtml(l.consumer_name)}</strong> — <a href="mailto:${escapeHtml(l.consumer_email)}" style="color:#0e0e0e">${escapeHtml(l.consumer_email)}</a></td>
+    </tr>`).join('');
+
+  const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+  <p style="font-size:11px;letter-spacing:0.16em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">This week on Ventura Claw</p>
+  <h1 style="font-size:24px;font-weight:400;margin:0 0 16px;line-height:1.2">${escapeHtml(business.business_name)}</h1>
+  <p style="font-size:14px;line-height:1.55;color:#666;margin:0 0 24px">Quick recap of ${escapeHtml(business.business_name)}'s last 7 days on Ventura Claw — what came in, who messaged, how the listing performed.</p>
+
+  <table style="width:100%;border-collapse:collapse;margin:0 0 28px;border-top:1px solid #ddd;border-bottom:1px solid #ddd">
+    <tr>
+      <td style="padding:14px 8px;width:33%;vertical-align:top;border-right:1px solid #eee">
+        <p style="font-family:Georgia,serif;font-size:32px;color:#b8860b;margin:0;line-height:1">${stats.msgs_7d}</p>
+        <p style="font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#888;margin:4px 0 0">new messages</p>
+      </td>
+      <td style="padding:14px 8px;width:33%;vertical-align:top;border-right:1px solid #eee">
+        <p style="font-family:Georgia,serif;font-size:32px;color:#b8860b;margin:0;line-height:1">${stats.views_7d}</p>
+        <p style="font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#888;margin:4px 0 0">profile views</p>
+      </td>
+      <td style="padding:14px 8px;width:33%;vertical-align:top">
+        <p style="font-family:Georgia,serif;font-size:32px;color:${stats.msgs_unread > 0 ? '#dc2626' : '#888'};margin:0;line-height:1">${stats.msgs_unread}</p>
+        <p style="font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#888;margin:4px 0 0">unread${stats.msgs_unread > 0 ? ' — triage' : ''}</p>
+      </td>
+    </tr>
+  </table>
+
+  ${leads.length > 0 ? `
+  <h2 style="font-family:Georgia,serif;font-size:18px;font-weight:500;margin:0 0 12px">This week's new messages</h2>
+  <table style="width:100%;border-collapse:collapse;margin:0 0 24px">${leadRows}</table>
+  ${leads.length > 5 ? `<p style="font-size:12px;color:#888;margin:0 0 24px">… and ${leads.length - 5} more.</p>` : ''}
+  ` : ''}
+
+  <p style="margin:24px 0">
+    <a href="${dashboardUrl}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:12px 22px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Open dashboard →</a>
+  </p>
+
+  <p style="font-size:13px;color:#666;line-height:1.55;margin:24px 0 0">Your live profile: <a href="${profileUrl}" style="color:#666">${profileUrl.replace(/^https?:\/\//, '')}</a></p>
+${compliance.complianceFooter({ campaign: CAMPAIGN, unsubscribeUrl })}
+</div>`;
+
+  const text = `Your Ventura Claw week — ${business.business_name}
+
+${stats.msgs_7d} new messages · ${stats.views_7d} profile views · ${stats.msgs_unread} unread
+
+Open dashboard: ${dashboardUrl}
+Live profile: ${profileUrl}
+---
+Unsubscribe: ${unsubscribeUrl}`;
+  return { subject, html, text };
+}
+
+(async () => {
+  if (!DRY) await compliance.assertSendCompliance({ campaign: CAMPAIGN });
+
+  // Pull every claimed business with email + at least some activity in past 7 days.
+  const candidates = await db.many(`
+    SELECT
+      b.id, b.slug, b.business_name, b.email,
+      (SELECT COUNT(*)::int FROM business_interest
+        WHERE business_id = b.id AND created_at > now() - interval '7 days') AS msgs_7d,
+      (SELECT COUNT(*)::int FROM business_interest
+        WHERE business_id = b.id AND read_at IS NULL) AS msgs_unread,
+      (SELECT COALESCE(SUM(n), 0)::int FROM business_views
+        WHERE business_id = b.id AND day > CURRENT_DATE - interval '7 days') AS views_7d
+    FROM businesses b
+    WHERE b.status = 'active'
+      AND b.claim_status IN ('self','claimed')
+      AND b.email IS NOT NULL AND b.email != ''
+    ORDER BY b.id ASC
+    LIMIT $1
+  `, [LIMIT]);
+
+  console.log(`[weekly-digest] ${candidates.length} claimed business(es) with email${DRY ? ' (dry run)' : ''}`);
+
+  let sent = 0, skipped_no_activity = 0, suppressed = 0, errors = 0;
+  for (const biz of candidates) {
+    if (biz.msgs_7d === 0 && biz.views_7d === 0) {
+      skipped_no_activity++;
+      continue;  // Nothing to report — silent skip, don't spam.
+    }
+    const recipient = String(biz.email).trim().toLowerCase();
+    const supr = await compliance.isSuppressed({ channel: 'email', identifier: recipient });
+    if (supr) {
+      suppressed++;
+      console.log(`  · ${recipient} → SUPPRESSED, skipping`);
+      continue;
+    }
+
+    const recentLeads = biz.msgs_7d > 0 ? await db.many(`
+      SELECT consumer_name, consumer_email, created_at
+        FROM business_interest
+       WHERE business_id = $1 AND created_at > now() - interval '7 days'
+       ORDER BY created_at DESC LIMIT 10
+    `, [biz.id]) : [];
+
+    const unsubToken = DRY
+      ? 'dry-run-token'
+      : await compliance.mintUnsubscribeToken({
+          channel: 'email', identifier: recipient,
+          campaign: CAMPAIGN, businessId: biz.id
+        });
+    const unsubscribeUrl = `${PUBLIC_URL}/unsubscribe?t=${unsubToken}`;
+    const { subject, html, text } = buildEmail({
+      business: { slug: biz.slug, business_name: biz.business_name },
+      stats: { msgs_7d: biz.msgs_7d, views_7d: biz.views_7d, msgs_unread: biz.msgs_unread },
+      leads: recentLeads,
+      unsubscribeUrl
+    });
+
+    if (DRY) {
+      console.log(`  · ${recipient} → would send: "${subject}" (${biz.msgs_7d}m / ${biz.views_7d}v / ${biz.msgs_unread} unread)`);
+      sent++;
+      continue;
+    }
+
+    const result = await sendEmail({
+      to: recipient,
+      subject, html, text,
+      extraHeaders: compliance.listUnsubscribeHeader(unsubscribeUrl)
+    });
+    if (result && result.ok) {
+      sent++;
+      await compliance.recordAudit({
+        channel: 'email', campaign: CAMPAIGN, recipient, businessId: biz.id,
+        decision: 'sent', subject, messageId: result.messageId || null,
+        payload: { msgs_7d: biz.msgs_7d, views_7d: biz.views_7d, msgs_unread: biz.msgs_unread }
+      });
+      console.log(`  · ${recipient} → sent (msg ${(result.messageId || '').slice(0, 28)}…)`);
+    } else {
+      errors++;
+      await compliance.recordAudit({
+        channel: 'email', campaign: CAMPAIGN, recipient, businessId: biz.id,
+        decision: 'failed', reason: (result && result.error) || 'unknown',
+        subject, messageId: null,
+        payload: { msgs_7d: biz.msgs_7d, smtp_code: result && result.code }
+      });
+      console.error(`  · ${recipient} → ERROR ${(result && result.code) || ''}`);
+    }
+  }
+  console.log(`[weekly-digest] done — sent=${sent} suppressed=${suppressed} skipped_no_activity=${skipped_no_activity} errors=${errors}`);
+})().catch(err => {
+  console.error('[weekly-digest] FATAL:', err.message);
+  process.exit(1);
+}).finally(() => {
+  setTimeout(() => process.exit(0), 200);
+});

← 91c32a2 yolo tick 28: home 'Recently joined' rail (conditional ≥3 cl  ·  back to Ventura Claw Leads  ·  feat(share): per-business share-count w/ session-dedup track a52f7bd →