TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Pure alert evaluation (no I/O). The runner loads the state an alert needs, calls `evaluateAlert`,3 * and persists notifications/e-mails. Kept pure so the rules are unit-testable.4 */56export interface AlertRow {7 id: string;8 userId: string;9 alertType: string;10 targetType: string; // asset | category | index11 targetId: string;12 threshold: number | null;13 active: boolean;14 lastTriggeredAt: Date | null;15 cooldownMinutes: number;16 name: string | null;17 channel: string;18}1920export interface AssetState {21 title: string;22 slug: string;23 rivUsd: number | null;24 rivConfidence: number | null;25 rivSampleSize: number;26 athUsd: number | null;27 latestSaleUsd: number | null;28 latestSaleAt: Date | null;29 sales30d: number;30 /** average sales per 30 days over the prior 90 days */31 baselineSales30d: number | null;32 newListings: Array<{ id: string; priceUsd: number | null; sourceId: string; firstSeenAt: Date }>;33 newAuctionLots: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string }>;34 endingLots: Array<{ id: string; title: string; endsAt: Date; auctionHouse: string }>;35 /** live lots whose buyer-pays bid is ≥ 10 % below the variant RIV (assessment verdict 'deal') */36 belowRivLots?: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string; allInBidUsd: number; rivUsd: number; bidVsRiv: number; feeBasis: string | null }>;37 newRecordSale: { priceUsd: number; saleDate: Date; sourceId: string } | null;38 populationChange: { grader: string; from: number; to: number; date: string } | null;39}4041export interface CategoryState {42 name: string;43 slug: string;44 change1d: number | null;45 newRecordSale: { assetTitle: string; assetSlug: string; priceUsd: number; saleDate: Date } | null;46 radarFindings: Array<{ assetTitle: string; assetSlug: string; kind: string; score: number }>;47 newAuctionLots: number;48 endingLots: number;49 sales30d: number;50 baselineSales30d: number | null;51}5253export interface IndexState {54 ticker: string;55 name: string;56 change1d: number | null;57 value: number | null;58}5960export interface Trigger {61 title: string;62 body: string;63 href: string;64 facts: Array<[string, string]>;65 kind: 'alert';66}6768const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v);69const pct = (v: number) => `${v > 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;7071export function inCooldown(alert: AlertRow, now: Date): boolean {72 if (!alert.lastTriggeredAt) return false;73 return now.getTime() - alert.lastTriggeredAt.getTime() < alert.cooldownMinutes * 60_000;74}7576export function evaluateAssetAlert(alert: AlertRow, s: AssetState, now = new Date()): Trigger | null {77 if (!alert.active || inCooldown(alert, now)) return null;78 const href = `/asset/${s.slug}`;79 const title = alert.name ?? s.title;80 switch (alert.alertType) {81 case 'price_below':82 if (alert.threshold !== null && s.rivUsd !== null && s.rivUsd <= alert.threshold) return { kind: 'alert', title: `${title}: RIV below ${usd(alert.threshold)}`, body: `RareIndex Valuation is now ${usd(s.rivUsd)} (${s.rivSampleSize} sales).`, href, facts: [['RIV', usd(s.rivUsd)], ['Threshold', usd(alert.threshold)]] };83 return null;84 case 'price_above':85 if (alert.threshold !== null && s.rivUsd !== null && s.rivUsd >= alert.threshold) return { kind: 'alert', title: `${title}: RIV above ${usd(alert.threshold)}`, body: `RareIndex Valuation is now ${usd(s.rivUsd)} (${s.rivSampleSize} sales).`, href, facts: [['RIV', usd(s.rivUsd)], ['Threshold', usd(alert.threshold)]] };86 return null;87 case 'new_listing': {88 if (!s.newListings.length) return null;89 const cheapest = s.newListings.filter((l) => l.priceUsd !== null).sort((a, b) => (a.priceUsd ?? 0) - (b.priceUsd ?? 0))[0];90 return { kind: 'alert', title: `${title}: ${s.newListings.length} new listing${s.newListings.length === 1 ? '' : 's'}`, body: cheapest?.priceUsd ? `Lowest new ask ${usd(cheapest.priceUsd)} on ${cheapest.sourceId}${s.rivUsd ? ` · RIV ${usd(s.rivUsd)}` : ''}.` : 'New listings observed.', href: `${href}?tab=listings`, facts: [['New listings', String(s.newListings.length)], ...(cheapest?.priceUsd ? [['Lowest ask', usd(cheapest.priceUsd)] as [string, string]] : []), ...(s.rivUsd ? [['RIV', usd(s.rivUsd)] as [string, string]] : [])] };91 }92 case 'new_auction': {93 if (!s.newAuctionLots.length) return null;94 const l = s.newAuctionLots[0]!;95 return { kind: 'alert', title: `${title}: new auction lot at ${l.auctionHouse}`, body: `${l.title}${l.endsAt ? ` · ends ${l.endsAt.toUTCString()}` : ''}`, href: `${href}?tab=listings`, facts: [['Lots', String(s.newAuctionLots.length)]] };96 }97 case 'auction_ending': {98 const soon = s.endingLots.filter((l) => l.endsAt.getTime() - now.getTime() <= 24 * 3600_000 && l.endsAt.getTime() > now.getTime());99 if (!soon.length) return null;100 const l = soon.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())[0]!;101 return { kind: 'alert', title: `${title}: auction ends ${Math.max(1, Math.round((l.endsAt.getTime() - now.getTime()) / 3600_000))}h from now`, body: `${l.title} at ${l.auctionHouse}.`, href: `${href}?tab=listings`, facts: [['Ends', l.endsAt.toUTCString()]] };102 }103 case 'auction_below_riv': {104 const lots = (s.belowRivLots ?? []).filter((l) => !l.endsAt || l.endsAt.getTime() > now.getTime());105 if (!lots.length) return null;106 const l = lots.sort((a, b) => a.bidVsRiv - b.bidVsRiv)[0]!;107 return { kind: 'alert', title: `${title}: auction bid ${pct(l.bidVsRiv)} vs RIV (all-in)`, body: `${l.title} at ${l.auctionHouse}: current bid + buyer premium ≈ ${usd(l.allInBidUsd)} against a RIV of ${usd(l.rivUsd)}${l.feeBasis?.startsWith('added_') ? ' (premium estimated; taxes and shipping excluded)' : ''}. Analytical data, not advice.`, href: `${href}?tab=listings`, facts: [['All-in bid', usd(l.allInBidUsd)], ['RIV', usd(l.rivUsd)], ['Gap', pct(l.bidVsRiv)], ...(l.endsAt ? [['Ends', l.endsAt.toUTCString()] as [string, string]] : [])] };108 }109 case 'record_sale':110 if (!s.newRecordSale) return null;111 return { kind: 'alert', title: `${title}: new record sale ${usd(s.newRecordSale.priceUsd)}`, body: `Highest verified sale to date, on ${s.newRecordSale.sourceId} (${s.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `${href}?tab=sales`, facts: [['Record', usd(s.newRecordSale.priceUsd)], ...(s.athUsd ? [['Previous high', usd(s.athUsd)] as [string, string]] : [])] };112 case 'unusual_volume': {113 if (alert.threshold === null || s.baselineSales30d === null || s.baselineSales30d <= 0) return null;114 const ratio = s.sales30d / s.baselineSales30d - 1;115 if (ratio * 100 < alert.threshold || s.sales30d < 5) return null;116 return { kind: 'alert', title: `${title}: sales volume ${pct(ratio)} vs 90-day average`, body: `${s.sales30d} sales in 30 days against a baseline of ${s.baselineSales30d.toFixed(1)}.`, href: `${href}?tab=sales`, facts: [['Sales 30d', String(s.sales30d)], ['Baseline', s.baselineSales30d.toFixed(1)]] };117 }118 case 'population_update':119 if (!s.populationChange) return null;120 return { kind: 'alert', title: `${title}: ${s.populationChange.grader.toUpperCase()} population ${s.populationChange.from} → ${s.populationChange.to}`, body: `Population report dated ${s.populationChange.date}.`, href: `${href}?tab=population`, facts: [['Change', `${s.populationChange.to - s.populationChange.from > 0 ? '+' : ''}${s.populationChange.to - s.populationChange.from}`]] };121 default:122 return null;123 }124}125126export function evaluateCategoryAlert(alert: AlertRow, c: CategoryState, now = new Date()): Trigger | null {127 if (!alert.active || inCooldown(alert, now)) return null;128 const href = `/markets/${c.slug}`;129 const title = alert.name ?? c.name;130 switch (alert.alertType) {131 case 'market_move':132 if (alert.threshold === null || c.change1d === null || Math.abs(c.change1d) * 100 < alert.threshold) return null;133 return { kind: 'alert', title: `${title} moved ${pct(c.change1d)} today`, body: `Daily move beyond your ${alert.threshold}% threshold.`, href, facts: [['1d', pct(c.change1d)]] };134 case 'record_sale':135 if (!c.newRecordSale) return null;136 return { kind: 'alert', title: `${title}: record sale ${usd(c.newRecordSale.priceUsd)}`, body: `${c.newRecordSale.assetTitle} (${c.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `/asset/${c.newRecordSale.assetSlug}`, facts: [['Price', usd(c.newRecordSale.priceUsd)]] };137 case 'rare_item': {138 if (!c.radarFindings.length) return null;139 const f = c.radarFindings.sort((a, b) => b.score - a.score)[0]!;140 return { kind: 'alert', title: `${title}: rare item on the radar`, body: `${f.assetTitle} — ${f.kind.replace(/_/g, ' ')}.`, href: `/asset/${f.assetSlug}`, facts: [['Findings', String(c.radarFindings.length)]] };141 }142 case 'new_auction':143 if (!c.newAuctionLots) return null;144 return { kind: 'alert', title: `${title}: ${c.newAuctionLots} new auction lot${c.newAuctionLots === 1 ? '' : 's'}`, body: 'New lots catalogued in the last cycle.', href: `/auctions?category=${c.slug}`, facts: [['Lots', String(c.newAuctionLots)]] };145 case 'auction_ending':146 if (!c.endingLots) return null;147 return { kind: 'alert', title: `${title}: ${c.endingLots} lot${c.endingLots === 1 ? '' : 's'} ending within 24h`, body: 'Check the auction calendar.', href: `/auctions/calendar?category=${c.slug}`, facts: [['Ending', String(c.endingLots)]] };148 case 'unusual_volume': {149 if (alert.threshold === null || c.baselineSales30d === null || c.baselineSales30d <= 0) return null;150 const ratio = c.sales30d / c.baselineSales30d - 1;151 if (ratio * 100 < alert.threshold) return null;152 return { kind: 'alert', title: `${title}: volume ${pct(ratio)} vs 90-day average`, body: `${c.sales30d} sales in 30 days.`, href, facts: [['Sales 30d', String(c.sales30d)]] };153 }154 default:155 return null;156 }157}158159export function evaluateIndexAlert(alert: AlertRow, i: IndexState, now = new Date()): Trigger | null {160 if (!alert.active || inCooldown(alert, now)) return null;161 if (alert.alertType !== 'market_move' || alert.threshold === null || i.change1d === null) return null;162 if (Math.abs(i.change1d) * 100 < alert.threshold) return null;163 return { kind: 'alert', title: `${i.ticker} moved ${pct(i.change1d)} today`, body: `${i.name} at ${i.value?.toFixed(1) ?? '—'}.`, href: `/rareindex/${i.ticker}`, facts: [['1d', pct(i.change1d)]] };164}165166/** Quiet hours check (UTC hours). Returns true when e-mail should be held. */167export function inQuietHours(prefs: { quietStart?: number | null; quietEnd?: number | null }, now = new Date()): boolean {168 const s = prefs.quietStart;169 const e = prefs.quietEnd;170 if (s === null || s === undefined || e === null || e === undefined) return false;171 const h = now.getUTCHours();172 return s <= e ? h >= s && h < e : h >= s || h < e;173}174175/** Price target hit check. */176export function targetHit(direction: 'above' | 'below', targetUsd: number, rivUsd: number | null): boolean {177 if (rivUsd === null) return false;178 return direction === 'above' ? rivUsd >= targetUsd : rivUsd <= targetUsd;179}180