Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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']);
|
||||
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',
|
||||
]);
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ScanBody {
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
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 : 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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user