← back to Govarbitrage

src/lib/digest-snapshot.ts

135 lines

import { prisma } from "@/lib/db";
import { findHotDeals, type HotDeal, type Confidence } from "@/lib/hot-deals";
import type { DigestSlot, Prisma } from "@prisma/client";

// Freezes each digest run into a DigestSnapshot so the public /deals archive
// renders a stable, indexable record instead of re-querying live listings
// that expire. Only PUBLIC-SAFE display fields are serialized — exactly what
// the free-tier digest email already shows, never internal cost math.

export interface SnapshotDeal {
  rank: number;
  title: string;
  source: string;
  locationCity: string | null;
  locationState: string | null;
  currentBid: number;
  recMax: number;
  expectedSaleLow: number;
  confidence: Confidence;
  roiConservative: number;
  roiCapped: boolean;
  disclaimer: string;
  closingAt: string | null;
  sourceUrl: string | null;
}

export function digestDateString(d = new Date()): string {
  // en-CA renders YYYY-MM-DD; PT is the digest's home timezone (6am/5pm sends).
  return d.toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" });
}

export function currentSlot(d = new Date()): DigestSlot {
  const hour = Number(
    d.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", hour12: false }),
  );
  return hour < 12 ? "AM" : "PM";
}

export function toSnapshotDeals(deals: HotDeal[]): SnapshotDeal[] {
  return deals.map((d, i) => ({
    rank: i + 1,
    title: d.listing.title,
    source: d.listing.source,
    locationCity: d.listing.locationCity,
    locationState: d.listing.locationState,
    currentBid: d.currentBid,
    recMax: d.recMax,
    expectedSaleLow: d.expectedSaleLow,
    confidence: d.confidence,
    roiConservative: d.roiConservative,
    roiCapped: d.roiCapped,
    disclaimer: d.disclaimer,
    closingAt: d.closingAt ? new Date(d.closingAt).toISOString() : null,
    sourceUrl: d.listing.sourceUrl,
  }));
}

/** Persist a snapshot from already-fetched deals (used at digest send time). */
export async function persistDigestSnapshot(slot: DigestSlot, deals: HotDeal[], date = digestDateString()) {
  const snapshotDeals = toSnapshotDeals(deals);
  const dealsJson = snapshotDeals as unknown as Prisma.InputJsonValue;
  return prisma.digestSnapshot.upsert({
    where: { date_slot: { date, slot } },
    create: { date, slot, dealsJson, dealCount: snapshotDeals.length },
    update: { dealsJson, dealCount: snapshotDeals.length },
  });
}

/** Standalone capture: run the honesty-gated Top-10 and freeze it. */
export async function captureDigestSnapshot(slot: DigestSlot = currentSlot()) {
  const deals = await findHotDeals({ limit: 10 });
  return persistDigestSnapshot(slot, deals);
}

// ── read side for the /deals archive pages ─────────────────────────────────

export interface DigestEdition {
  date: string;
  slot: DigestSlot;
  dealCount: number;
  createdAt: Date;
  deals: SnapshotDeal[];
}

function parseDeals(dealsJson: Prisma.JsonValue): SnapshotDeal[] {
  return Array.isArray(dealsJson) ? (dealsJson as unknown as SnapshotDeal[]) : [];
}

function toEdition(r: { date: string; slot: DigestSlot; dealCount: number; createdAt: Date; dealsJson: Prisma.JsonValue }): DigestEdition {
  return {
    date: r.date,
    slot: r.slot,
    dealCount: r.dealCount,
    createdAt: r.createdAt,
    deals: parseDeals(r.dealsJson),
  };
}

// Fabricated seed editions never reach the public archive. Schema-level flag,
// not a delete-before-launch convention — opt in locally with SHOW_SEED_DIGESTS=1.
function seedFilter(): { isSeed?: false } {
  return process.env.SHOW_SEED_DIGESTS === "1" ? {} : { isSeed: false };
}

export async function listDigestEditions(limit = 60): Promise<DigestEdition[]> {
  const rows = await prisma.digestSnapshot.findMany({
    where: seedFilter(),
    orderBy: [{ date: "desc" }, { slot: "asc" }],
    take: limit,
  });
  return rows.map(toEdition);
}

export async function getDigestEditionsForDate(date: string): Promise<DigestEdition[]> {
  const rows = await prisma.digestSnapshot.findMany({
    where: { date, ...seedFilter() },
    orderBy: { slot: "asc" },
  });
  return rows.map(toEdition);
}

export async function listDigestDates(): Promise<{ date: string; lastModified: Date }[]> {
  const rows = await prisma.digestSnapshot.findMany({
    where: seedFilter(),
    select: { date: true, createdAt: true },
    orderBy: { date: "desc" },
  });
  const byDate = new Map<string, Date>();
  for (const r of rows) {
    const prev = byDate.get(r.date);
    if (!prev || r.createdAt > prev) byDate.set(r.date, r.createdAt);
  }
  return [...byDate.entries()].map(([date, lastModified]) => ({ date, lastModified }));
}