Files
vyndr/web/src/app/api/scan/route.ts
T

165 lines
5.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;
if (user && sb) {
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.');
}
}