Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+73
View File
@@ -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.');
}
}
+23
View File
@@ -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: [] });
}
}
+23
View File
@@ -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 });
}
}
+44
View File
@@ -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: [] });
}
}
+20
View File
@@ -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: [] });
}
}
+35
View File
@@ -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: [] });
}
}
+50
View File
@@ -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 });
}
+56
View File
@@ -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 212 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.');
}
}
+53
View File
@@ -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: [] });
}
}
+43
View File
@@ -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 });
}
+27
View File
@@ -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: [] });
}
}
+157
View File
@@ -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 });
}
}
+46
View File
@@ -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' };
}
+55
View File
@@ -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 ?? [] });
}
+37
View File
@@ -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',
});
}
+36
View File
@@ -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 });
}
+100
View File
@@ -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 });
}