← 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 }));
}