Files
vyndr/web/src/app/api/scan/route.ts
T
builtbykev d10bb4cce2 Session 59: Addendum + work-order 1.6 + Phase 2 + Phase 3 (2352 tests)
Overnight sprint for the Saturday 10 AM ET deploy gate — day one of the
public ledger record locks against freshly posted lines.

Task A — ledger team/opponent (migration 020, applied at 0 rows):
  populated in both write paths from the real feed; opponent only when the
  player's team matches a game participant (never guessed). Roadmap: Phase
  4.5 WNBA ESPN-boxscore settlement (due ~Jul 24) + Phase 5 per-tier
  calibration logged.

Task B — work-order 1.6 CLOSED (canonical player keys):
  - searchPlayer resolves via nameKey; the old matcher deleted accents
    ("Sanchez" with acute -> "snchez") and substring-guessed onto the WRONG
    player (the mismatched last-10 bug). Ambiguous -> null, never guess.
  - Slate JOIN INVARIANT: a graded prop whose player's real team isn't in
    the game is dropped (TB player can't render under MIL@PIT) — locked by
    tests that fail the suite on regression.
  - grades:{sport} TTL 2h -> 6h (expired between 5h cron gaps — the real
    cause of /team "No active props" for slate players).

Task C — Phase 2 slate UX: tabs are THE filter (URL ?sport=, deep-linkable,
  duplicate legacy tablist removed); cards cap at 6 graded props sorted
  A+->F with ALL N READS in-place expander; waiting states show the real
  next pipeline run ("Grades post ~6:00 PM ET").

Task D — Phase 3 mobile P0: root cause of vanished 390px nav was HIDE_ON
  including '/' (landing had zero navigation) — fixed; html/body overflow-x
  contained; GAME LINES collapses to best-line summary + "N BOOKS" expander
  below 640px; venue drops before time/pitchers ever truncate.

Live verification: raw ESPN today STILL returns the Jun 13 NYK@SA Finals
game without a date pin; the pinned fetch returns 0 games, 0 off-date.

Backend 2327 -> 2352 tests (202 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:24:47 -04:00

252 lines
9.6 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
import { getServiceRoleSupabase } from '@/lib/supabase';
import { rateLimitCheck, rateLimitKey, rateLimitResponse } from '@/middleware/rateLimit';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const FREE_LIMIT = 5; // reads per calendar month
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01';
const isSameMonth = (date: string | null | undefined) =>
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA', 'Soccer']);
const VALID_DIRECTIONS = new Set(['over', 'under']);
const VALID_NBA_STATS = new Set(['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers']);
const VALID_MLB_STATS = new Set([
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
]);
const VALID_SOCCER_STATS = new Set([
'goals', 'assists', 'shots_on_target', 'shots', 'tackles',
'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
]);
export const dynamic = 'force-dynamic';
interface ScanBody {
sport: 'NBA' | 'MLB' | 'WNBA' | 'Soccer';
player: string;
stat: string;
line: number;
direction: 'over' | 'under';
book?: string;
}
export async function POST(req: NextRequest) {
let body: ScanBody;
try {
body = (await req.json()) as ScanBody;
} catch {
return jsonError(400, 'Invalid JSON body.');
}
if (!VALID_SPORTS.has(body.sport)) return jsonError(400, 'Unknown sport.');
if (!VALID_DIRECTIONS.has(body.direction)) return jsonError(400, 'Direction must be over or under.');
if (typeof body.player !== 'string' || body.player.length === 0 || body.player.length > 80) {
return jsonError(400, 'Player name is required.');
}
if (typeof body.line !== 'number' || !Number.isFinite(body.line) || body.line < 0 || body.line > 500) {
return jsonError(400, 'Line must be a number between 0 and 500.');
}
const validStats =
body.sport === 'MLB' ? VALID_MLB_STATS :
body.sport === 'Soccer' ? VALID_SOCCER_STATS :
VALID_NBA_STATS;
if (!validStats.has(body.stat)) {
return jsonError(400, `Stat "${body.stat}" not supported for ${body.sport}.`);
}
const user = await getUserFromRequest(req);
const sb = getServiceRoleSupabase();
// Per-minute rate limit (different limit per tier)
const rl = rateLimitCheck(rateLimitKey(req), user?.tier ?? 'free');
if (!rl.ok) return rateLimitResponse(rl.retryAfter);
// Throttle free tier (monthly cap)
if (user && user.tier === 'free' && sb) {
const { data: profile } = await sb
.from('user_profiles')
.select('scan_count, scan_reset_date')
.eq('id', user.id)
.maybeSingle();
const usedThisMonth = isSameMonth(profile?.scan_reset_date) ? (profile?.scan_count ?? 0) : 0;
if (usedThisMonth >= FREE_LIMIT) {
return NextResponse.json(
{
error: "You've used your 5 free reads this month. Unlock unlimited reads and full intelligence — Founder Access, $14.99/mo.",
scans_remaining: 0,
upgrade: { tier: 'analyst', price: 14.99 },
},
{ status: 402 },
);
}
}
// Forward to backend grading engine
try {
const upstream = await fetch(`${BACKEND_URL}/api/analyze/prop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
},
body: JSON.stringify({
sport: body.sport,
player: body.player,
stat_type: body.stat,
line: body.line,
direction: body.direction,
book: body.book ?? 'draftkings',
}),
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return NextResponse.json(
{ error: data?.error || 'The engine hit a wall. Try that read again.' },
{ status: upstream.status },
);
}
let scansRemaining: number | null = null;
// Session 58 (work-order 1.5) — a refused read (no projection) writes
// NOTHING: no scan_history, no ledger row. No hollow rows anywhere.
const refused = data?.insufficient_data === true || !data?.grade;
if (user && sb && !refused) {
// Phase 1 — persist the read to the ledger (authenticated users only;
// anonymous scans are never written: a null user_id row would pollute
// the PUBLIC model record, which is pipeline-only). The line/book are
// the REAL book values the slate pre-filled; locked odds are enriched
// from the cached odds feed when the prop matches. Fire-and-forget —
// the scan response never waits on the ledger.
void writeLedgerEntry(sb, user.id, body, data);
}
if (user && sb && !refused) {
void sb.rpc('increment_parlay_leg_frequency', {
p_player: body.player,
p_stat: body.stat,
p_line: body.line,
p_dir: body.direction,
p_sport: body.sport,
p_scan_delta: 1,
p_parlay_delta: 0,
});
void sb.from('scan_history').insert({
user_id: user.id,
sport: body.sport,
player_name: body.player,
stat: body.stat,
line: body.line,
direction: body.direction,
grade: data.grade,
projection: data.projection,
confidence: data.confidence,
factors: data.factors ?? null,
});
if (user.tier === 'free') {
const thisMonth = monthKey();
const { data: current } = await sb
.from('user_profiles')
.select('scan_count, scan_reset_date')
.eq('id', user.id)
.maybeSingle();
const next = (isSameMonth(current?.scan_reset_date) ? (current?.scan_count ?? 0) : 0) + 1;
await sb
.from('user_profiles')
.update({ scan_count: next, scan_reset_date: thisMonth })
.eq('id', user.id);
scansRemaining = Math.max(0, FREE_LIMIT - next);
}
}
return NextResponse.json({ ...data, scans_remaining: scansRemaining, tier: user?.tier ?? 'free' });
} catch (err) {
console.error('[scan] backend call failed', err);
return jsonError(502, 'The engine hit a wall. Try that read again.');
}
}
/**
* Session 58 (Phase 1) — persist a completed user scan to ledger_entries.
*
* DATA SEMANTICS: `line`/`book` are the real book values the user scanned
* (the slate pre-fills them from the odds feed). `locked_odds` attaches ONLY
* when the cache-only snapshot carries the SAME line for this prop — odds
* from a different line would be a fabrication, so absent beats wrong.
* Upsert on the dedupe constraint: a double-tap never duplicates.
*/
async function writeLedgerEntry(
sb: NonNullable<ReturnType<typeof getServiceRoleSupabase>>,
userId: string,
body: ScanBody,
data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number },
) {
try {
const { nameKey, normalizeName } = await import('@/lib/playerName');
const sport = body.sport.toLowerCase();
const playerKey = nameKey(body.player);
const gameDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
// Cache-only snapshot read (never triggers an odds fetch → no quota).
let lockedOdds: string | null = null;
let team: string | null = null;
try {
const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
}).then((r) => (r.ok ? r.json() : null));
interface SnapGrade { player?: string; player_name?: string; stat_type?: string; stat?: string; team?: string | null; gradedAt?: { line?: number; odds?: number | string | null } }
const grades: SnapGrade[] = snap?.grades || [];
// Team match needs only player identity; odds additionally need the SAME line.
const samePlayer = grades.filter(
(g) => nameKey(g.player || g.player_name || '') === playerKey
&& String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase(),
);
team = samePlayer.find((g) => g.team)?.team ?? null;
const match = samePlayer.find((g) => g.gradedAt && Number(g.gradedAt.line) === Number(body.line));
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
} catch { /* absent beats wrong */ }
await sb.from('ledger_entries').upsert(
{
user_id: userId,
player_key: playerKey,
player_name: normalizeName(body.player).display || body.player,
sport,
stat: body.stat.toLowerCase(),
line: body.line,
side: body.direction,
locked_odds: lockedOdds,
book: body.book ?? 'draftkings',
// Session 59 — team from the snapshot's stats resolve (real feed);
// opponent stays null on manual scans (no game context — never guessed).
team,
opponent: null,
grade: data.grade,
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
confidence: typeof data.confidence === 'number' ? data.confidence : null,
model_value: typeof data.projection === 'number' ? data.projection : null,
graded_at: new Date().toISOString(),
game_id: `manual:${sport}:${gameDate}:${playerKey}`,
game_date: gameDate,
},
{ onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true },
);
} catch (err) {
console.warn('[scan] ledger write failed', err);
}
}