← back to Govarbitrage
scripts/hot-deal-alert.ts
161 lines
// 🔥 HOT DEAL alerter. Finds NEW listings that pass the hot-deal gate (buy-side
// headroom + proven sell-side demand), emails them via George, and stamps
// hotAlertedAt so each deal alerts exactly once. Meant to run right after every
// import refresh. $0 (local DB + internal George send to Steve).
//
// Env (from .env): DATABASE_URL, GEORGE_URL, GEORGE_BASIC_AUTH,
// HOT_TO (default steve@designerwallcoverings.com), DIGEST_FROM_ACCOUNT,
// APP_URL, HOT_MAX (max deals per email, default 12),
// HOT_DRY_RUN=1 (print instead of send), plus the HOT_MIN_* thresholds.
//
// Run: npx tsx scripts/hot-deal-alert.ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
function loadEnv() {
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
try {
const raw = readFileSync(join(root, ".env"), "utf8");
for (const line of raw.split("\n")) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
if (!m) continue;
const key = m[1];
let val = m[2].trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = val;
}
} catch {
/* rely on ambient env */
}
}
loadEnv();
const { prisma } = await import("../src/lib/db");
const { findHotDeals } = await import("../src/lib/hot-deals");
const { formatMoney, formatPercent, countdown } = await import("../src/lib/utils");
const HOT_TO = process.env.HOT_TO || process.env.DIGEST_TO || "steve@designerwallcoverings.com";
const FROM_ACCOUNT = process.env.DIGEST_FROM_ACCOUNT || "steve-office";
const APP_URL = process.env.APP_URL || "http://localhost:3737";
const HOT_MAX = Number(process.env.HOT_MAX || 12);
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
}
type Deal = Awaited<ReturnType<typeof findHotDeals>>[number];
function renderDeal(d: Deal, i: number): string {
const l = d.listing;
const closes = l.closingAt ? countdown(l.closingAt) : "Make Offer (no deadline)";
const loc = [l.locationCity, l.locationState].filter(Boolean).join(", ") || "—";
// Honesty Gate: confidence badge + conservative figures (never the raw hype).
const confColor = d.confidence === "HIGH" ? "#166534" : d.confidence === "MEDIUM" ? "#b45309" : "#64748b";
const confBg = d.confidence === "HIGH" ? "#dcfce7" : d.confidence === "MEDIUM" ? "#fef3c7" : "#f1f5f9";
const confBadge = `<span style="background:${confBg};color:${confColor};padding:1px 6px;border-radius:4px;font-weight:700;font-size:11px">${d.confidence} confidence</span>`;
const roiText = `${formatPercent(d.roiConservative)}${d.roiCapped ? "+" : ""}`;
const sellProof =
d.buyerLeadCount > 0
? `<span style="background:#dcfce7;color:#166534;padding:1px 6px;border-radius:4px;font-weight:700">${d.buyerLeadCount} buyer lead${d.buyerLeadCount > 1 ? "s" : ""}${d.buyerLeadTop ? ` · top offer ${formatMoney(d.buyerLeadTop)}` : ""}</span>`
: `<span style="color:#166534">${d.comparableCount} comps · ${formatPercent(d.probSale)} sell prob</span>`;
return `<div style="border:1px solid #fecaca;border-left:4px solid #ef4444;border-radius:8px;margin:0 0 14px;padding:12px">
<div style="font-size:15px;font-weight:700">🔥 #${i + 1} · <a href="${APP_URL}/listings/${l.id}" style="color:#b91c1c;text-decoration:none">${escapeHtml(l.title)}</a></div>
<div style="color:#64748b;font-size:12px;margin:2px 0 8px">
${l.source.replace(/_/g, " ")} · #${escapeHtml(l.sourceAuctionId)} · ${loc} · closes ${closes}
${l.sourceUrl ? ` · <a href="${escapeHtml(l.sourceUrl)}" style="color:#2563eb">source ↗</a>` : ""}
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr>
<td style="padding:3px 8px 3px 0;color:#166534;font-weight:700;width:50%">SELL side — can you flip it?</td>
<td style="padding:3px 8px 3px 0;color:#b91c1c;font-weight:700">BUY side — is there room?</td>
</tr>
<tr valign="top">
<td style="padding-right:8px">
<div>Est. resale (conservative): <b>${formatMoney(d.expectedSaleLow)}</b> ${confBadge}</div>
<div>Demand score: <b>${Math.round(d.demandScore)}/100</b></div>
<div>Sell proof: ${sellProof}</div>
</td>
<td>
<div>Current bid: <b>${formatMoney(d.currentBid)}</b></div>
<div>Recommended max bid: <b>${formatMoney(d.recMax)}</b> <span style="color:#64748b">(${formatPercent(d.headroom)} headroom)</span></div>
<div>Est. ROI: <b>${roiText}</b> <span style="color:#64748b">(low-side)</span></div>
</td>
</tr>
</table>
<div style="font-size:11px;color:#94a3b8;margin-top:6px">⚠︎ ${escapeHtml(d.disclaimer)}</div>
<div style="font-size:11px;color:#64748b;margin-top:3px">${escapeHtml(d.score.explanation)}</div>
</div>`;
}
function buildHtml(deals: Deal[]): string {
const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:820px">
<h2 style="margin:0 0 4px;color:#b91c1c">🔥 ${deals.length} HOT DEAL${deals.length > 1 ? "S" : ""} — curated, verified-demand, early</h2>
<div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · each cleared the demand + bid-headroom gate; figures are conservative heuristic estimates (confidence-labeled) — always verify comps before bidding · act before close</div>
${deals.map(renderDeal).join("")}
<p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
<p style="color:#94a3b8;font-size:11px">HOT gate: score≥${process.env.HOT_MIN_SCORE || 75}, arbitrage≥${process.env.HOT_MIN_ARBITRAGE || 70}, demand≥${process.env.HOT_MIN_DEMAND || 50}, net≥$${process.env.HOT_MIN_NET || 150}, ROI≥${Math.round(Number(process.env.HOT_MIN_ROI || 0.5) * 100)}%, bid headroom≥${Math.round(Number(process.env.HOT_MIN_HEADROOM_PCT || 0.15) * 100)}%. Verify comps before bidding.</p>
</div>`;
}
async function sendViaGeorge(subject: string, html: string) {
const base = process.env.GEORGE_URL;
const auth = process.env.GEORGE_BASIC_AUTH;
if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
const res = await fetch(`${base}/api/send`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
body: JSON.stringify({ account: FROM_ACCOUNT, to: HOT_TO, subject, body: html }),
});
const text = await res.text();
if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
return text;
}
async function main() {
const deals = await findHotDeals({ onlyUnalerted: true, limit: HOT_MAX });
if (deals.length === 0) {
console.log(`[${new Date().toISOString()}] No new hot deals. No alert sent.`);
await prisma.$disconnect();
return;
}
const subject = `🔥 ${deals.length} HOT DEAL${deals.length > 1 ? "S" : ""} on GovArbitrage`;
const html = buildHtml(deals);
if (process.env.HOT_DRY_RUN === "1") {
const { writeFileSync, mkdirSync } = await import("node:fs");
mkdirSync("logs", { recursive: true });
writeFileSync("logs/hot-deal-preview.html", html);
console.log(`[DRY RUN] to=${HOT_TO} subject="${subject}" (${deals.length} deals)`);
deals.forEach((d, i) =>
console.log(
` ${i + 1}. ${d.listing.title.slice(0, 44)} — bid ${Math.round(d.currentBid)}→max ${Math.round(d.recMax)}, net ${Math.round(d.netProfit)}, ROI ${Math.round(d.roi * 100)}%`,
),
);
console.log("Preview: logs/hot-deal-preview.html (NOT marking alerted in dry-run)");
await prisma.$disconnect();
return;
}
const result = await sendViaGeorge(subject, html);
// Stamp each alerted deal so it never re-alerts.
const ids = deals.map((d) => d.listing.id);
await prisma.listing.updateMany({
where: { id: { in: ids } },
data: { hotAlertedAt: new Date() },
});
console.log(`Sent "${subject}" to ${HOT_TO}; marked ${ids.length} alerted. ${result.slice(0, 120)}`);
await prisma.$disconnect();
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(`[hot-deal-alert] ${new Date().toISOString()} ERROR:`, e.message);
process.exit(1);
});