← back to Govarbitrage

src/lib/send-digest.ts

89 lines

import { prisma } from "@/lib/db";
import { findHotDeals, type HotDeal } from "@/lib/hot-deals";
import { sendNewsletterEmail, newsletterSendMode } from "@/lib/newsletter";
import { persistDigestSnapshot, currentSlot } from "@/lib/digest-snapshot";

// Digest send-to-list. Renders the same honesty-gated Top-10 the paid alerts
// use and fans it out to every CONFIRMED subscriber, each with their own
// one-click unsubscribe link (CAN-SPAM). All sends go through
// sendNewsletterEmail(), which is TEST-logged by default and throws on an
// unapproved live flip — so calling this is safe until Steve approves live
// transport. Nothing schedules this yet; the launchd/cron wiring is part of
// the gated go-live (see pending-approval memo).

const fmtUsd = (n: number) => `$${Math.round(n).toLocaleString("en-US")}`;

export function renderDigestText(deals: HotDeal[], unsubscribeUrl: string): string {
  const lines: string[] = [
    "GovArbitrage — Top-10 Deals Digest",
    new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }),
    "",
  ];

  deals.forEach((d, i) => {
    const where = [d.listing.locationCity, d.listing.locationState].filter(Boolean).join(", ");
    lines.push(
      `${i + 1}. ${d.listing.title}`,
      `   Current bid ${fmtUsd(d.currentBid)} · rec. max bid ${fmtUsd(d.recMax)} · ` +
        `est. resale ${fmtUsd(d.expectedSaleLow)} (${d.confidence} confidence)`,
      `   Conservative ROI ${(d.roiConservative * 100).toFixed(0)}%${d.roiCapped ? " (capped)" : ""} · ${d.disclaimer}`,
      ...(d.listing.sourceUrl ? [`   ${d.listing.sourceUrl}`] : []),
      ...(where ? [`   Location: ${where}`] : []),
      ""
    );
  });

  if (!deals.length) lines.push("No deals cleared the hot-deal gate this run.", "");

  lines.push(
    "—",
    "Every figure above is confidence-discounted and capped, never inflated.",
    "Estimates are heuristic — always verify comps before bidding.",
    "",
    `Unsubscribe (one click, effective immediately): ${unsubscribeUrl}`
  );
  return lines.join("\n");
}

export interface DigestSendResult {
  mode: "test" | "live";
  subscribers: number;
  sent: number;
  errors: number;
  dealCount: number;
}

export async function sendDigestToSubscribers(opts: { baseUrl?: string; limit?: number } = {}): Promise<DigestSendResult> {
  const baseUrl = opts.baseUrl || process.env.NEWSLETTER_BASE_URL;
  // Never let a live send fabricate localhost unsubscribe links — a broken
  // unsubscribe URL in a real email is a CAN-SPAM problem, not a dev nit.
  if (!baseUrl || (newsletterSendMode() === "live" && !baseUrl.startsWith("https://"))) {
    throw new Error("sendDigestToSubscribers needs a public NEWSLETTER_BASE_URL (https) — refusing to build localhost links.");
  }
  const deals = await findHotDeals({ limit: opts.limit ?? 10 });

  // Freeze this edition for the public /deals archive — even if every send
  // below fails, the archive records what the digest showed at send time.
  await persistDigestSnapshot(currentSlot(), deals);

  const subscribers = await prisma.subscriber.findMany({ where: { status: "CONFIRMED" } });

  let sent = 0;
  let errors = 0;
  for (const sub of subscribers) {
    const unsubscribeUrl = `${baseUrl}/api/newsletter/unsubscribe?token=${sub.unsubscribeToken}`;
    try {
      await sendNewsletterEmail({
        to: sub.email,
        subject: `Top-10 gov-surplus deals — ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}`,
        text: renderDigestText(deals, unsubscribeUrl),
      });
      sent++;
    } catch {
      errors++;
    }
  }

  return { mode: newsletterSendMode(), subscribers: subscribers.length, sent, errors, dealCount: deals.length };
}