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