Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { createPaymentLink, TIER_PRICING, type NexaPayTier } from '@/services/nexapay';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const VALID_TIERS = new Set<NexaPayTier>(['analyst', 'desk']);
|
||||
|
||||
async function resolveTier(req: NextRequest): Promise<NexaPayTier | null> {
|
||||
const url = new URL(req.url);
|
||||
const queryTier = url.searchParams.get('tier');
|
||||
if (queryTier && VALID_TIERS.has(queryTier as NexaPayTier)) return queryTier as NexaPayTier;
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = (await req.json().catch(() => ({}))) as { tier?: string };
|
||||
if (body.tier && VALID_TIERS.has(body.tier as NexaPayTier)) return body.tier as NexaPayTier;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(req);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(req);
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to upgrade.');
|
||||
|
||||
const tier = await resolveTier(req);
|
||||
if (!tier) return jsonError(400, 'Pick a valid tier (analyst or desk).');
|
||||
|
||||
// Founder pricing eligibility — first 100 paid users overall
|
||||
let founderEligible = false;
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (sb) {
|
||||
const { count } = await sb
|
||||
.from('user_profiles')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('founder_pricing', true);
|
||||
founderEligible = (count ?? 0) < 100;
|
||||
}
|
||||
|
||||
const pricing = TIER_PRICING[tier];
|
||||
const amount = founderEligible ? pricing.founder : pricing.regular;
|
||||
|
||||
try {
|
||||
const link = await createPaymentLink({
|
||||
userId: user.id,
|
||||
tier,
|
||||
amount,
|
||||
description: `${pricing.label}${founderEligible ? ' (Founder)' : ''}`,
|
||||
founderPricing: founderEligible,
|
||||
});
|
||||
|
||||
// For GET (used by Pricing CTA links), redirect directly.
|
||||
if (req.method === 'GET') {
|
||||
return NextResponse.redirect(link.url, { status: 303 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: link.url, expires_at: link.expires_at, founder_pricing: founderEligible });
|
||||
} catch (err) {
|
||||
console.error('[checkout] NexaPay link failed', err);
|
||||
return jsonError(502, 'Payment processor is unreachable. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ props: [] });
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ props: unknown[] }>(
|
||||
`game:props:${id}`,
|
||||
'mixed',
|
||||
'game_props',
|
||||
`/api/games/${encodeURIComponent(id)}/props`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json({ props: Array.isArray(data?.props) ? data.props : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ props: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ error: 'Missing game id.' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const game = await cachedBackendJson<Record<string, unknown>>(
|
||||
`game:detail:${id}`,
|
||||
'mixed',
|
||||
'game_detail',
|
||||
`/api/games/${encodeURIComponent(id)}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json(game);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Game not found.' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
ab_grade_count?: number;
|
||||
injury_note?: string;
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
if (!VALID_SPORTS.has(sport)) {
|
||||
return NextResponse.json({ error: 'Unknown sport.', games: [] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const games = await cachedBackendJson<Game[]>(
|
||||
todayKey(sport, 'games'),
|
||||
sport,
|
||||
'games',
|
||||
`/api/games/tonight?sport=${sport}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ games: Array.isArray(games) ? games : [] },
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' } },
|
||||
);
|
||||
} catch {
|
||||
// The slate genuinely may be empty (off-day). Return empty list so the UI
|
||||
// shows the branded empty state instead of an error.
|
||||
return NextResponse.json({ games: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// The page itself shows the blurred preview for non-Desk users, so we
|
||||
// still return a small set of signals (so the timeline scaffolding
|
||||
// looks alive behind the blur). For Desk users, return full feed.
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const limit = user.tier === 'desk' ? 50 : 8;
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ signals: unknown[] }>(
|
||||
`intelligence:feed:${limit}`,
|
||||
'mixed',
|
||||
'intelligence_feed',
|
||||
`/api/intelligence/feed?limit=${limit}`,
|
||||
60,
|
||||
);
|
||||
return NextResponse.json({ signals: Array.isArray(data?.signals) ? data.signals : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ signals: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 600;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await cachedBackendJson<{ buckets: unknown[] }>(
|
||||
'ledger:accuracy:rolling',
|
||||
'mixed',
|
||||
'ledger_accuracy',
|
||||
'/api/ledger/accuracy',
|
||||
600,
|
||||
);
|
||||
return NextResponse.json({ buckets: Array.isArray(data?.buckets) ? data.buckets : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ buckets: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_TIERS = new Set(['A', 'B', 'C', 'D']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || '').toUpperCase();
|
||||
const tier = (req.nextUrl.searchParams.get('tier') || '').toUpperCase();
|
||||
const limit = Math.min(60, Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 30)));
|
||||
|
||||
if (sport && !VALID_SPORTS.has(sport)) return NextResponse.json({ entries: [] });
|
||||
if (tier && !VALID_TIERS.has(tier)) return NextResponse.json({ entries: [] });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (sport) params.set('sport', sport);
|
||||
if (tier) params.set('tier', tier);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ entries: unknown[] }>(
|
||||
todayKey(sport || 'all', `ledger:${tier || 'all'}:${limit}`),
|
||||
sport || 'mixed',
|
||||
'ledger',
|
||||
`/api/ledger?${params}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json({ entries: Array.isArray(data?.entries) ? data.entries : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ entries: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Body {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player?: string;
|
||||
stat?: string;
|
||||
line?: number;
|
||||
direction?: 'over' | 'under';
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_DIRS = new Set(['over', 'under']);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
!body.sport || !VALID_SPORTS.has(body.sport) ||
|
||||
!body.player || typeof body.player !== 'string' ||
|
||||
!body.stat ||
|
||||
typeof body.line !== 'number' || !Number.isFinite(body.line) ||
|
||||
!body.direction || !VALID_DIRS.has(body.direction)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Missing or invalid leg fields.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ ok: true, persisted: false });
|
||||
|
||||
// RPC increments scan or parlay counter atomically.
|
||||
await 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: 0,
|
||||
p_parlay_delta: 1,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Leg {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
}
|
||||
|
||||
interface Body {
|
||||
legs: Leg[];
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to grade parlays.');
|
||||
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.legs) || body.legs.length < 2 || body.legs.length > 12) {
|
||||
return jsonError(400, 'Send 2–12 legs.');
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/scan/parlay`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
|
||||
},
|
||||
body: JSON.stringify({ legs: body.legs }),
|
||||
});
|
||||
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (data as { error?: string }).error || 'The engine hit a wall on this parlay.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return jsonError(502, 'The engine hit a wall on this parlay.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const NBA_SERVICE = process.env.NBA_SERVICE_URL || process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000';
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
const q = (req.nextUrl.searchParams.get('q') || '').trim();
|
||||
const gameId = req.nextUrl.searchParams.get('game_id') || '';
|
||||
|
||||
if (q.length < 2) return NextResponse.json({ players: [] });
|
||||
|
||||
try {
|
||||
// NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
|
||||
const url =
|
||||
sport === 'MLB'
|
||||
? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
|
||||
: `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
|
||||
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return NextResponse.json({ players: [] });
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
|
||||
? (data as { results: unknown[] }).results
|
||||
: Array.isArray((data as { players?: unknown[] }).players)
|
||||
? (data as { players: unknown[] }).players
|
||||
: [];
|
||||
|
||||
const players: Player[] = rawPlayers.slice(0, 12).map((p) => {
|
||||
const obj = (p && typeof p === 'object' ? p : {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(obj.id ?? obj.player_id ?? obj.full_name ?? Math.random()),
|
||||
full_name: String(obj.full_name ?? obj.name ?? ''),
|
||||
team: typeof obj.team === 'string' ? obj.team : undefined,
|
||||
position: typeof obj.position === 'string' ? obj.position : undefined,
|
||||
};
|
||||
}).filter((p) => p.full_name);
|
||||
|
||||
return NextResponse.json({ players });
|
||||
} catch {
|
||||
return NextResponse.json({ players: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60;
|
||||
|
||||
interface LiveProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
confidence: number;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
graded_at: string;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<LiveProp[] | { error: string }>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/props/live`, {
|
||||
next: { revalidate: 60 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Backend unavailable — return empty list, the UI shows the fallback line.
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LiveProp[];
|
||||
if (!Array.isArray(data)) {
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
return NextResponse.json(data.slice(0, 24), { headers: cacheHeaders() });
|
||||
} catch {
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
}
|
||||
|
||||
function cacheHeaders() {
|
||||
return { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60;
|
||||
|
||||
interface ParlayedProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
parlay_count: number;
|
||||
scan_count: number;
|
||||
grade?: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ props: [] });
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const { data } = await sb
|
||||
.from('parlay_leg_frequency')
|
||||
.select('player_name, stat, line, over_under, sport, parlay_count, scan_count')
|
||||
.eq('game_date', today)
|
||||
.order('parlay_count', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (!data) return NextResponse.json({ props: [] });
|
||||
|
||||
const props: ParlayedProp[] = data.map((row) => ({
|
||||
player: row.player_name,
|
||||
stat: row.stat,
|
||||
line: Number(row.line),
|
||||
direction: row.over_under as 'over' | 'under',
|
||||
sport: row.sport as 'NBA' | 'MLB' | 'WNBA',
|
||||
parlay_count: row.parlay_count,
|
||||
scan_count: row.scan_count,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ props });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 120;
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
if (!VALID_SPORTS.has(sport)) {
|
||||
return NextResponse.json({ error: 'Unknown sport.', props: [] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ props: unknown[] }>(
|
||||
todayKey(sport, 'top_graded'),
|
||||
sport,
|
||||
'top_graded',
|
||||
`/api/props/top-graded?sport=${sport}`,
|
||||
120,
|
||||
);
|
||||
return NextResponse.json({ props: Array.isArray(data?.props) ? data.props : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ props: [] });
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(): Promise<NextResponse<{ count: number }>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/stats/parlays-graded`, {
|
||||
next: { revalidate: 30 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return NextResponse.json({ count: 0 });
|
||||
const data = (await res.json()) as { count?: number };
|
||||
return NextResponse.json({ count: Number(data.count || 0) });
|
||||
} catch {
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface PublicStats {
|
||||
parlays_graded: number;
|
||||
kill_conditions_caught: number;
|
||||
a_grade_accuracy: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
const FALLBACK: PublicStats = {
|
||||
parlays_graded: 0,
|
||||
kill_conditions_caught: 0,
|
||||
a_grade_accuracy: 0,
|
||||
last_updated: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(): Promise<NextResponse<PublicStats>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/stats/public`, {
|
||||
next: { revalidate: 30 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return NextResponse.json(FALLBACK, { headers: cache() });
|
||||
const data = (await res.json()) as Partial<PublicStats>;
|
||||
return NextResponse.json(
|
||||
{
|
||||
parlays_graded: Number(data.parlays_graded || 0),
|
||||
kill_conditions_caught: Number(data.kill_conditions_caught || 0),
|
||||
a_grade_accuracy: Number(data.a_grade_accuracy || 0),
|
||||
last_updated: data.last_updated || new Date().toISOString(),
|
||||
},
|
||||
{ headers: cache() },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(FALLBACK, { headers: cache() });
|
||||
}
|
||||
}
|
||||
|
||||
function cache() {
|
||||
return { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=120' };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('user_profiles')
|
||||
.select('id, email, tier, scan_count, scan_reset_date, subscription_status, subscription_end, founder_pricing, cancel_at_period_end')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return jsonError(500, error.message);
|
||||
}
|
||||
return NextResponse.json(data ?? { id: user.id, email: user.email, tier: 'free' });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
let body: { age_verified?: boolean; cancel_at_period_end?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON body.');
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
if (typeof body.age_verified === 'boolean') update.age_verified = body.age_verified;
|
||||
if (typeof body.cancel_at_period_end === 'boolean') update.cancel_at_period_end = body.cancel_at_period_end;
|
||||
|
||||
if (Object.keys(update).length === 0) return jsonError(400, 'Nothing to update.');
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('user_profiles')
|
||||
.update(update)
|
||||
.eq('id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return jsonError(500, error.message);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ scans: [] });
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('scan_history')
|
||||
.select('id, sport, player_name, stat, line, direction, grade, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) return jsonError(500, error.message);
|
||||
return NextResponse.json({ scans: data ?? [] });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const { data: profile } = await sb
|
||||
.from('user_profiles')
|
||||
.select('tier, scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const tier = (profile?.tier as 'free' | 'analyst' | 'desk') ?? 'free';
|
||||
const usedThisMonth = isSameMonth(profile?.scan_reset_date) ? (profile?.scan_count ?? 0) : 0;
|
||||
const remaining = tier === 'free' ? Math.max(0, FREE_LIMIT - usedThisMonth) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
used_this_month: usedThisMonth,
|
||||
remaining,
|
||||
limit: tier === 'free' ? FREE_LIMIT : null,
|
||||
reset_date: monthKey(),
|
||||
period: 'monthly',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ALLOWED_LISTS = new Set(['merch', 'ledger-book', 'The Line', 'The Edge', 'The Correlation', 'The System', 'general']);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: { email?: string; list?: string; honeypot?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
// Honeypot — silently accept then drop
|
||||
if (body.honeypot) return NextResponse.json({ ok: true });
|
||||
|
||||
if (!body.email || !EMAIL_RE.test(body.email)) {
|
||||
return jsonError(400, 'Enter a valid email.');
|
||||
}
|
||||
const list = body.list && ALLOWED_LISTS.has(body.list) ? body.list : 'general';
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (sb) {
|
||||
await sb
|
||||
.from('waitlist_signups')
|
||||
.insert({ email: body.email.toLowerCase(), list, source: 'web' })
|
||||
.select()
|
||||
.maybeSingle();
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, list });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { verifyWebhookSignature, type NexaPayWebhookEvent, type NexaPayTier } from '@/services/nexapay';
|
||||
import { sendPaymentReceipt } from '@/services/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
// We need the raw body to verify the HMAC signature.
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawBody = await req.text();
|
||||
const signature = req.headers.get('x-nexapay-signature');
|
||||
|
||||
if (!verifyWebhookSignature(rawBody, signature)) {
|
||||
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
let event: NexaPayWebhookEvent;
|
||||
try {
|
||||
event = JSON.parse(rawBody) as NexaPayWebhookEvent;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) {
|
||||
console.error('[nexapay webhook] Supabase service role not configured');
|
||||
return NextResponse.json({ error: 'misconfigured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const userId = event.data.metadata?.userId;
|
||||
const tier = event.data.metadata?.tier as NexaPayTier | undefined;
|
||||
const founderPricing = event.data.metadata?.founderPricing === 'true';
|
||||
|
||||
if (!userId || !tier) {
|
||||
return NextResponse.json({ ok: true, ignored: 'missing metadata' });
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment.succeeded': {
|
||||
const subscription_end = new Date();
|
||||
subscription_end.setUTCDate(subscription_end.getUTCDate() + 30);
|
||||
|
||||
const { error } = await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
tier,
|
||||
subscription_status: 'active',
|
||||
subscription_start: new Date().toISOString(),
|
||||
subscription_end: subscription_end.toISOString(),
|
||||
cancel_at_period_end: false,
|
||||
founder_pricing: founderPricing,
|
||||
nexapay_customer_id: event.data.customer_id ?? null,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('[nexapay webhook] update failed', error);
|
||||
return NextResponse.json({ error: 'update_failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget receipt email. Don't block the webhook ACK.
|
||||
const { data: profileRow } = await sb
|
||||
.from('user_profiles')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (profileRow?.email) {
|
||||
void sendPaymentReceipt(profileRow.email, {
|
||||
tier,
|
||||
amount: `$${(event.data.amount / 100).toFixed(2)}`,
|
||||
renewsAt: subscription_end.toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.failed': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({ subscription_status: 'grace_period' })
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.refunded':
|
||||
case 'subscription.canceled': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
subscription_status: 'canceled',
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user