d10bb4cce2
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>
252 lines
9.6 KiB
TypeScript
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);
|
|
}
|
|
}
|