165 lines
5.6 KiB
TypeScript
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.');
|
|
}
|
|
}
|