Sporalize-api / src /server.ts
Shoraky's picture
Stream session video ranges directly from storage
a66365f
Raw History Blame Contribute Delete
72.6 kB
import { activeSubscriptionFor } from "./subscriptions.js";
import express, { type NextFunction, type Request, type Response } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { z } from "zod";
import { authPublicOrigin, env } from "./config.js";
import { analysisRouter } from "./analysis.js";
import { actionReferences, analysisResults, generateSessionFeedback } from "./agent-session.js";
import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
import { registerProfileImageRoutes } from "./profile-images.js";
import { registerAdminRoutes } from "./admin.js";
import { colabWorkerRouter } from "./colab-worker.js";
import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";
import { singleFlight } from "./single-flight.js";
import { requestTiming } from "./request-timing.js";
import { readBatches } from "./read-batches.js";
import { readPages } from "./read-pages.js";
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", env.TRUST_PROXY_HOPS);
app.use(requestTiming);
app.use(helmet());
app.use(cors({
origin: env.FRONTEND_URL,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
}));
app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET,
(sessionId, userId) => generateSessionFeedback(supabaseAdmin, sessionId, userId)));
app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
app.use("/internal/colab-worker", colabWorkerRouter(env.ANALYSIS_PROXY_SECRET, env.COLAB_WORKER_SECRET));
app.use(cookieParser());
app.use((req, res, next) => {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
if (req.header("origin") !== new URL(env.FRONTEND_URL).origin) return res.sendStatus(403);
return next();
});
// Browser POST transport avoids HF edge preflight handling. Origin checks above
// still apply before restoring the method, and every route retains its auth gate.
app.use((req, res, next) => {
if (req.query._method === undefined) return next();
if (req.method !== "POST" || !req.path.startsWith("/api/") ||
typeof req.query._method !== "string" || !["PUT", "PATCH", "DELETE"].includes(req.query._method)) {
return res.status(400).json({ error: "Invalid request method." });
}
req.method = req.query._method;
const target = new URL(req.originalUrl, "http://localhost");
target.searchParams.delete("_method");
req.url = req.originalUrl = target.pathname + target.search;
return next();
});
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
const passwordResetRequestLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5, standardHeaders: "draft-8", legacyHeaders: false });
const passwordRecoveryLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 15, standardHeaders: "draft-8", legacyHeaders: false });
const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 3000, standardHeaders: "draft-8", legacyHeaders: false });
const analysisUploadLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 4, standardHeaders: "draft-8", legacyHeaders: false });
const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() }).strict();
const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
const passwordResetRequestSchema = z.object({ email: z.string().trim().toLowerCase().email().max(255) });
const passwordRecoveryVerifySchema = z.object({ tokenHash: z.string().trim().min(1).max(512) });
const passwordRecoverySchema = z.object({ newPassword: z.string().min(8).max(128) }).strict();
const profileRoles = ["football_player", "coach", "club_manager", "academy_manager", "scout", "performance_analyst", "sports_director", "other"] as const;
const profileSchema = z.object({
fname: z.string().trim().min(1).max(100),
lname: z.string().trim().min(1).max(100),
academy: z.string().trim().max(150).transform((value) => value || null).nullable(),
role: z.enum(profileRoles).nullable(),
}).strict();
const passwordSchema = z.object({ currentPassword: z.string().max(128).optional(), newPassword: z.string().min(8).max(128) });
const actionReviewSchema = z.object({
coachScore: z.number().int().min(0).max(10).nullable(),
feedback: z.array(z.string().trim().min(1).max(2000)).max(30),
}).strict();
const playerProfileSchema = z.object({
fname: z.string().trim().min(1).max(100).optional(),
lname: z.string().trim().max(100).nullable().optional(),
name: z.string().trim().max(200).optional(),
bdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
heightCm: z.string().regex(/^\d{2,3}(?:\.\d{1,2})?$/),
weightKg: z.string().regex(/^\d{1,3}(?:\.\d{1,2})?$/),
club: z.string().trim().max(150),
position: z.string().trim().min(1).max(50),
gender: z.enum(["male", "female"]),
}).strict().transform((input) => {
const legacyNameParts = (input.name ?? "").split(/\s+/).filter(Boolean);
const fname = input.fname ?? legacyNameParts.shift() ?? "";
const lname = input.lname ?? legacyNameParts.join(" ");
return { ...input, fname, lname };
}).refine((input) => Boolean(input.fname), { message: "First name is required." })
.refine((input) => validPlayerBirthDate(input.bdate), { path: ["bdate"], message: "Enter a valid birth date for a player aged 4 to 60." });
const playerCreateSchema = z.object({
fname: z.string().trim().min(1).max(100),
lname: z.string().trim().max(100).nullable().optional().default(null),
bdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
heightCm: z.string().regex(/^\d{2,3}(?:\.\d{1,2})?$/),
weightKg: z.string().regex(/^\d{1,3}(?:\.\d{1,2})?$/),
club: z.string().trim().max(150),
position: z.string().trim().min(1).max(50),
gender: z.enum(["male", "female"]),
}).strict().refine((input) => validPlayerBirthDate(input.bdate), { path: ["bdate"], message: "Enter a valid birth date for a player aged 4 to 60." });
function validPlayerBirthDate(value: string) {
const date = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value &&
Number(ageFromBirthday(value)) >= 4 && Number(ageFromBirthday(value)) <= 60;
}
function ageFromBirthday(birthday: string | null): string {
if (!birthday) return "";
const today = new Date();
const [year, month, day] = birthday.slice(0, 10).split("-").map(Number);
if (!year || !month || !day) return "";
const beforeBirthday = today.getUTCMonth() + 1 < month || (today.getUTCMonth() + 1 === month && today.getUTCDate() < day);
return String(Math.max(0, today.getUTCFullYear() - year - Number(beforeBirthday)));
}
registerProfileImageRoutes(app);
// Stored coach ratings use a 0–10 scale. Player dashboards use a 0–100 scale.
const meanPlayerScore = (values: number[]) => values.length
? values.reduce((sum, value) => sum + value, 0) / values.length * 10
: null;
const PLAYER_SKILL_CATEGORIES = [
{ axis: "Pass", aliases: ["pass", "passing"] },
{ axis: "Shoot", aliases: ["shoot", "shooting", "shot"] },
{ axis: "Receive", aliases: ["receive", "receiving", "reception"] },
{ axis: "Dribbling", aliases: ["dribble", "dribbling"] },
{ axis: "Pace", aliases: ["pace"] },
] as const;
function playerSkillRatings(
actions: Array<{ action_id: string; type: string }>,
scoreByAction: Map<string, number | null>,
) {
return PLAYER_SKILL_CATEGORIES.map(({ axis, aliases }) => {
const names = new Set<string>(aliases);
const scores = actions
.filter(({ type }) => names.has(type.trim().toLowerCase().replace(/[^a-z0-9]/g, "")))
.map(({ action_id }) => scoreByAction.get(action_id))
.filter((score): score is number => score != null && Number.isFinite(score));
return { axis, score: meanPlayerScore(scores), samples: scores.length };
});
}
export const accountRoleFor = singleFlight(async (userId: string) => {
const { data, error } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle();
if (error) throw new Error(`Unable to load account permissions: ${error.message}`);
return String(data?.account_role ?? "user").toLowerCase();
});
export async function userIsAdmin(userId: string) {
return await accountRoleFor(userId) === "admin";
}
export async function requireAdmin(req: Request, res: Response) {
const current = await authenticatedUser(req, res);
if (!current) {
res.status(401).json({ error: "Authentication required." });
return null;
}
if (!await userIsAdmin(current.user.id)) {
res.status(403).json({ error: "Administrator access is required." });
return null;
}
return current;
}
type ScoredAction = { action_id: string; session_id: string; type: string; annotation: { score: number | null } | null };
async function loadScoredActions(sessionIds: string[]) {
return readBatches<string, ScoredAction>(sessionIds, (batch) => readPages(async (from, to) => {
const { data, error } = await supabaseAdmin.from("actions")
.select("action_id,session_id,type,annotation:annotations(score)").in("session_id", batch)
.order("action_id").range(from, to);
if (error) throw new Error(`Unable to load action scores: ${error.message}`);
return (data ?? []) as unknown as ScoredAction[];
}));
}
async function requireActiveSubscription(req: Request, res: Response) {
const current = await authenticatedUser(req, res);
if (!current) {
res.status(401).json({ error: "Authentication required." });
return null;
}
if (await userIsAdmin(current.user.id)) {
return { ...current, isAdmin: true, subscription: { player_limit: null, session_limit: null } };
}
const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
if (!subscription) {
res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
return null;
}
return { ...current, subscription };
}
registerAdminRoutes(app);
async function enforcePlayerQuota(userId: string, limit: number | null, res: Response) {
const { data, error } = await supabaseAdmin.from("users_subscriptions")
.select("num_players").eq("user_id", userId).maybeSingle();
if (error) throw new Error(`Unable to check subscription usage: ${error.message}`);
const used = Number(data?.num_players ?? 0);
if (limit === null || used < limit) return true;
res.status(403).json({
error: "You have used all player slots included in this plan period. Renew or upgrade your plan to add another player.",
code: "quota_exceeded", resource: "players", used, limit,
});
return false;
}
app.get("/health", (_req, res) => res.json({ ok: true }));
app.get("/api/subscriptions", async (_req, res, next) => {
try {
const { data, error } = await supabaseAdmin.from("subscriptions")
.select("subscription_id,plan,price,player_limit,session_limit")
.order("price", { ascending: true });
if (error) throw new Error(`Unable to load subscription plans: ${error.message}`);
const plans = (data ?? []).map((row) => ({
id: row.plan.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-"),
subscriptionId: row.subscription_id,
name: row.plan,
monthlyPrice: Number(row.price),
playerLimit: row.player_limit == null ? null : Number(row.player_limit),
sessionLimit: row.session_limit == null ? null : Number(row.session_limit),
}));
return res.json({ plans });
} catch (error) { next(error); }
});
app.get("/api/subscription/access", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const admin = await userIsAdmin(current.user.id);
const subscription = admin ? null : await activeSubscriptionFor(current.user.id, { refresh: true });
return res.json({ active: admin || Boolean(subscription), subscription, isAdmin: admin });
} catch (error) { next(error); }
});
app.get("/api/plan-billing", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const userId = current.user.id;
const [isAdmin, assignmentResult] = await Promise.all([
userIsAdmin(userId),
supabaseAdmin.from("users_subscriptions")
.select("subscription_id,startdate,enddate,num_players,num_sessions,plan:subscriptions(subscription_id,plan,price,player_limit,session_limit)")
.eq("user_id", userId).maybeSingle(),
]);
if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
const assignment = assignmentResult.data;
let subscription = null;
if (!isAdmin && assignment?.subscription_id) {
const plan = assignment.plan as unknown as {
subscription_id: string; plan: string; price: number; player_limit: number | null; session_limit: number | null;
} | null;
if (plan) {
const today = new Date().toISOString().slice(0, 10);
subscription = {
subscriptionId: plan.subscription_id,
name: plan.plan,
price: Number(plan.price),
playerLimit: plan.player_limit == null ? null : Number(plan.player_limit),
sessionLimit: plan.session_limit == null ? null : Number(plan.session_limit),
startDate: assignment.startdate,
endDate: assignment.enddate,
status: today < assignment.startdate ? "scheduled" : today > assignment.enddate ? "expired" : "active",
};
}
}
let usage = { players: Number(assignment?.num_players ?? 0), sessions: Number(assignment?.num_sessions ?? 0) };
if (isAdmin) {
const [players, sessions] = await Promise.all([
supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }).eq("user_id", userId),
supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }).eq("user_id", userId),
]);
if (players.error) throw new Error(`Unable to load administrator player usage: ${players.error.message}`);
if (sessions.error) throw new Error(`Unable to load administrator session usage: ${sessions.error.message}`);
usage = { players: players.count ?? 0, sessions: sessions.count ?? 0 };
}
res.set("Cache-Control", "no-store");
return res.json({
subscription: isAdmin ? null : subscription,
usage,
billingHistoryAvailable: false,
isAdmin,
});
} catch (error) { next(error); }
});
app.get("/api/owned-players", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const admin = await userIsAdmin(current.user.id);
let query = supabaseAdmin.from("players")
.select("player_id,user_id,fname,lname,position,club,gender,image_path")
.order("fname", { ascending: true });
if (!admin) query = query.eq("user_id", current.user.id);
const { data, error } = await query;
if (error) throw new Error(`Unable to load players: ${error.message}`);
// Analysis player choices stay scoped to the signed-in user's own roster.
return res.json({ players: (data ?? []).map((player) => ({
playerId: player.player_id,
fname: player.fname ?? "",
lname: player.lname ?? "",
name: [player.fname, player.lname].filter(Boolean).join(" "),
isOwner: player.user_id === current.user.id,
canManage: admin || player.user_id === current.user.id,
position: player.position ?? "",
club: player.club ?? "",
gender: player.gender ?? null,
imagePath: player.image_path ?? null,
})) });
} catch (error) { next(error); }
});
app.post("/api/players", async (req, res, next) => {
try {
const current = await requireActiveSubscription(req, res);
if (!current) return;
if (!await enforcePlayerQuota(current.user.id, current.subscription.player_limit == null ? null : Number(current.subscription.player_limit), res)) return;
const input = playerCreateSchema.parse(req.body);
const height = Number(input.heightCm);
const weight = Number(input.weightKg);
if (height < 100 || height > 230 || weight < 20 || weight > 150) {
return res.status(400).json({ error: "Check the player's age, height and weight." });
}
const { data, error } = await supabaseAdmin.from("players").insert({
user_id: current.user.id,
fname: input.fname,
lname: input.lname?.trim() || null,
bdate: input.bdate,
height,
weight,
club: input.club || null,
position: input.position,
gender: input.gender,
}).select("player_id,fname,lname,position,club,gender,image_path").single();
if (error?.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
return res.status(403).json({ error: "You have used all player slots included in this plan period. Renew or upgrade your plan to add another player.", code: "quota_exceeded", resource: "players" });
}
if (error?.code === "P0001" && error.message.includes("subscription_required")) {
return res.status(402).json({ error: "An active subscription is required to add a player.", code: "subscription_required" });
}
if (error) throw new Error(`Unable to create player: ${error.message}`);
return res.status(201).json({ player: {
playerId: data.player_id,
fname: data.fname ?? "",
lname: data.lname ?? "",
name: [data.fname, data.lname].filter(Boolean).join(" "),
position: data.position ?? "",
club: data.club ?? "",
gender: data.gender ?? null,
imagePath: data.image_path ?? null,
} });
} catch (error) { next(error); }
});
app.put("/api/players/:playerId", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const id = z.string().uuid().safeParse(req.params.playerId);
if (!id.success) return res.status(404).json({ error: "Player not found." });
const isAdmin = await userIsAdmin(current.user.id);
const input = playerProfileSchema.parse(req.body);
const height = Number(input.heightCm);
const weight = Number(input.weightKg);
if (height < 100 || height > 230 || weight < 20 || weight > 150) {
return res.status(400).json({ error: "Check the player's age, height and weight." });
}
let updateQuery = supabaseAdmin.from("players").update({
fname: input.fname,
lname: input.lname?.trim() || null,
bdate: input.bdate,
height,
weight,
club: input.club || null,
position: input.position,
gender: input.gender,
}).eq("player_id", id.data);
if (!isAdmin) updateQuery = updateQuery.eq("user_id", current.user.id);
const { data, error } = await updateQuery
.select("player_id,fname,lname,bdate,height,weight,club,position,gender,image_path").maybeSingle();
if (error) throw new Error(`Unable to update player: ${error.message}`);
if (!data) return res.status(404).json({ error: "Player not found." });
return res.json({ ok: true, player: {
playerId: data.player_id, fname: data.fname ?? "", lname: data.lname ?? "",
name: [data.fname, data.lname].filter(Boolean).join(" "), bdate: data.bdate,
age: ageFromBirthday(data.bdate), heightCm: String(data.height ?? ""),
weightKg: String(data.weight ?? ""), club: data.club ?? "", position: data.position ?? "",
gender: data.gender ?? null, imagePath: data.image_path ?? null,
} });
} catch (error) { next(error); }
});
app.delete("/api/players/:playerId", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const playerId = z.string().uuid().safeParse(req.params.playerId);
if (!playerId.success) return res.status(404).json({ error: "Player not found." });
const isAdmin = await userIsAdmin(current.user.id);
let playerQuery = supabaseAdmin.from("players")
.select("player_id,user_id,image_path").eq("player_id", playerId.data);
if (!isAdmin) playerQuery = playerQuery.eq("user_id", current.user.id);
const { data: player, error: playerError } = await playerQuery.maybeSingle();
if (playerError) throw new Error(`Unable to load player for deletion: ${playerError.message}`);
if (!player) return res.status(404).json({ error: "Player not found." });
const mode = z.enum(["keep_sessions", "delete_sessions"]).safeParse(req.body?.mode);
if (!mode.success) return res.status(400).json({ error: "Choose whether to keep or delete this player's sessions." });
const { data: linkedSessions, error: sessionsError } = await supabaseAdmin.from("sessions")
.select("session_id,user_id,by_admin").eq("player_id", playerId.data);
if (sessionsError) throw new Error(`Unable to load player sessions: ${sessionsError.message}`);
if (mode.data === "delete_sessions") for (const session of linkedSessions ?? []) {
await deleteStoredSession(session.session_id, current.user.id, isAdmin);
}
let deleteQuery = supabaseAdmin.from("players").delete().eq("player_id", playerId.data);
if (!isAdmin) deleteQuery = deleteQuery.eq("user_id", current.user.id);
const { data: deleted, error: deleteError } = await deleteQuery.select("player_id").maybeSingle();
if (deleteError) throw new Error(`Unable to delete player: ${deleteError.message}`);
if (!deleted) return res.status(404).json({ error: "Player not found." });
if (isAdmin) {
const chargedSessions = mode.data === "delete_sessions"
? (linkedSessions ?? []).filter((session) => session.user_id === player.user_id && !session.by_admin).length
: 0;
await refundAdminUsage(player.user_id, 1, chargedSessions);
}
const imagePath = player.image_path;
if (imagePath?.startsWith(`players/${playerId.data}/`) && /^[0-9a-f-]{36}\.(jpg|png|webp)$/.test(imagePath.slice(`players/${playerId.data}/`.length))) {
const { error: imageError } = await supabaseAdmin.storage.from("profile-images").remove([imagePath]);
if (imageError) console.error("Player deleted but profile image cleanup failed", imageError);
}
return res.status(204).send();
} catch (error) { next(error); }
});
// Non-admin roster reads are scoped to the signed-in player's own roster.
app.get("/api/players-overview", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const isAdmin = await userIsAdmin(current.user.id);
// These reads share only the authenticated scope, so start them together.
const [rows, visibleSessions] = await Promise.all([
readPages(async (from, to) => {
let query = supabaseAdmin.from("players")
.select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
.order("fname").order("player_id").range(from, to);
if (!isAdmin) query = query.eq("user_id", current.user.id);
const { data, error } = await query;
if (error) throw new Error(`Unable to load players: ${error.message}`);
return data ?? [];
}),
readPages(async (from, to) => {
let query = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
.order("created_at", { ascending: false }).order("session_id").range(from, to);
if (!isAdmin) query = query.eq("user_id", current.user.id);
const { data, error } = await query;
if (error) throw new Error(`Unable to load player sessions: ${error.message}`);
return data ?? [];
}),
]);
const playerIds = new Set(rows.map((row) => row.player_id));
const sessions = visibleSessions.filter((row) => playerIds.has(row.player_id));
const sessionIds = (sessions ?? []).map((row) => row.session_id);
const actions = await loadScoredActions(sessionIds);
const scoresByAction = new Map(actions.map((row) => [row.action_id, row.annotation?.score == null ? null : Number(row.annotation.score)]));
const sessionsByPlayer = new Map<string, typeof sessions>();
for (const session of sessions ?? []) {
const group = sessionsByPlayer.get(session.player_id) ?? [];
group.push(session);
sessionsByPlayer.set(session.player_id, group);
}
const actionsBySession = new Map<string, typeof actions>();
for (const action of actions) {
const group = actionsBySession.get(action.session_id) ?? [];
group.push(action);
actionsBySession.set(action.session_id, group);
}
const players = (rows ?? []).map((row) => {
const playerSessions = sessionsByPlayer.get(row.player_id) ?? [];
const playerActions = playerSessions.flatMap((session) => actionsBySession.get(session.session_id) ?? []);
const scored = playerActions.map((action) => scoresByAction.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
const byAction = [...new Set(playerActions.map((action) => action.type))].map((type) => {
const matching = playerActions.filter((action) => action.type === type);
const values = matching.map((action) => scoresByAction.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
return { axis: type, score: meanPlayerScore(values), samples: values.length };
});
const playerScoreSessions = playerSessions.map((session) => {
const sessionActions = actionsBySession.get(session.session_id) ?? [];
const values = sessionActions.map((action) => scoresByAction.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
return { sessionId: session.session_id, createdAt: Date.parse(session.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
});
return {
playerId: row.player_id,
isOwner: row.user_id === current.user.id,
fname: row.fname ?? "",
lname: row.lname ?? "",
name: [row.fname, row.lname].filter(Boolean).join(" "),
age: ageFromBirthday(row.bdate),
bdate: row.bdate,
heightCm: row.height == null ? "" : String(row.height),
weightKg: row.weight == null ? "" : String(row.weight),
club: row.club ?? "", position: row.position ?? "", gender: row.gender ?? null,
imagePath: row.image_path ?? null,
createdAt: "", updatedAt: "", hasProfile: true,
sessionCount: playerSessions.length, actionCount: playerActions.length,
lastSessionAt: playerScoreSessions[0]?.createdAt ?? 0,
scores: {
playerId: row.player_id, rubricVersion: "database", totalActions: playerActions.length,
scoredActions: scored.length, byAction, byQuality: [],
overall: meanPlayerScore(scored),
sessions: playerScoreSessions,
},
};
});
return res.json({ players });
} catch (error) { next(error); }
});
app.get("/api/players-overview/:playerId", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const playerId = z.string().uuid().safeParse(req.params.playerId);
if (!playerId.success) return res.status(404).json({ error: "Player not found." });
const isAdmin = await userIsAdmin(current.user.id);
let playerQuery = supabaseAdmin.from("players").select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
.eq("player_id", playerId.data);
if (!isAdmin) playerQuery = playerQuery.eq("user_id", current.user.id);
const { data, error } = await playerQuery.maybeSingle();
if (error) throw new Error(`Unable to load player: ${error.message}`);
if (!data) return res.status(404).json({ error: "Player not found." });
const [overview, sessions] = await Promise.all([
readPages(async (from, to) => {
let query = supabaseAdmin.from("players").select("player_id").order("player_id").range(from, to);
if (!isAdmin) query = query.eq("user_id", current.user.id);
const { data, error } = await query;
if (error) throw new Error(`Unable to load players: ${error.message}`);
return data ?? [];
}),
readPages(async (from, to) => {
let query = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
.order("created_at", { ascending: false }).order("session_id").range(from, to);
if (!isAdmin) query = query.eq("user_id", current.user.id);
const { data, error } = await query;
if (error) throw new Error(`Unable to load sessions: ${error.message}`);
return data ?? [];
}),
]);
const actions = await loadScoredActions((sessions ?? []).map((row) => row.session_id));
const scoreMap = new Map(actions.map((row) => [row.action_id, row.annotation?.score == null ? null : Number(row.annotation.score)]));
const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
const ownSessionIds = new Set(ownSessions.map((session) => session.session_id));
const ownActions = (actions ?? []).filter((row) => ownSessionIds.has(row.session_id));
const ownScores = ownActions.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
const byAction = playerSkillRatings(ownActions, scoreMap);
const sessionPlayerById = new Map((sessions ?? []).map((row) => [row.session_id, row.player_id]));
const squadActions = (actions ?? []).filter((action) => {
const ownerId = sessionPlayerById.get(action.session_id);
return Boolean(ownerId) && ownerId !== playerId.data;
});
const squadByAction = playerSkillRatings(squadActions, scoreMap);
const player = data;
if (!player) return res.status(404).json({ error: "Player not found." });
const sessionsForPlayer = ownSessions.map((row) => {
const sessionActions = ownActions.filter((action) => action.session_id === row.session_id);
const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
});
const playerScores = new Map<string, number[]>();
for (const action of actions ?? []) {
const ownerId = sessionPlayerById.get(action.session_id);
const score = scoreMap.get(action.action_id);
if (ownerId && score != null && Number.isFinite(score)) {
const values = playerScores.get(ownerId) ?? [];
values.push(score);
playerScores.set(ownerId, values);
}
}
const rankedPlayers = [...playerScores.entries()]
.map(([id, values]) => ({ id, score: meanPlayerScore(values)! }))
.sort((a, b) => b.score - a.score);
const rankIndex = rankedPlayers.findIndex((entry) => entry.id === playerId.data);
const squadAverage = { byQuality: [], byAction: squadByAction };
return res.json({
playerId: player.player_id, fname: player.fname ?? "", lname: player.lname ?? "", name: [player.fname, player.lname].filter(Boolean).join(" "),
isOwner: player.user_id === current.user.id,
canManage: isAdmin || player.user_id === current.user.id,
age: ageFromBirthday(player.bdate),
bdate: player.bdate,
heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
club: player.club ?? "", position: player.position ?? "", gender: player.gender ?? null,
imagePath: player.image_path ?? null,
createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
squadAverage, rank: rankIndex < 0 ? null : rankIndex + 1, playerCount: overview?.length ?? 0,
});
} catch (error) { next(error); }
});
app.get("/api/sessions-overview", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const isAdmin = await userIsAdmin(current.user.id);
const sessions = await readPages(async (from, to) => {
let query = supabaseAdmin.from("sessions")
.select("session_id,player_id,user_id,name,path,created_at,status,by_admin,player:players(fname,lname,gender,image_path)")
.order("created_at", { ascending: false }).order("session_id").range(from, to);
if (!isAdmin) query = query.eq("user_id", current.user.id);
const { data, error } = await query;
if (error) throw new Error(`Unable to load sessions: ${error.message}`);
return data ?? [];
});
const actions = await readBatches(sessions.map((row) => row.session_id), (batch) => readPages(async (from, to) => {
const { data, error } = await supabaseAdmin.from("actions").select("action_id,session_id,type")
.in("session_id", batch).order("action_id").range(from, to);
if (error) throw new Error(`Unable to load session actions: ${error.message}`);
return data ?? [];
}));
const breakdownBySession = new Map<string, Record<string, number>>();
for (const action of actions ?? []) {
const breakdown = breakdownBySession.get(action.session_id) ?? {};
breakdown[action.type] = (breakdown[action.type] ?? 0) + 1;
breakdownBySession.set(action.session_id, breakdown);
}
return res.json({ sessions: (sessions ?? []).map((row) => {
const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
const player = row.player as unknown as { fname: string; lname: string | null; gender: string | null; image_path: string | null } | null;
return {
sessionId: row.session_id, isOwner: row.user_id === current.user.id, canDelete: isAdmin || row.user_id === current.user.id, isAdmin, byAdmin: row.by_admin, playerId: row.player_id ?? "", playerName: row.player_id ? [player?.fname, player?.lname].filter(Boolean).join(" ") : "Deleted player",
playerGender: row.player_id ? player?.gender ?? null : null,
playerImagePath: row.player_id ? player?.image_path ?? null : null,
name: row.name, createdAt: Date.parse(row.created_at) || 0,
actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
};
}) });
} catch (error) { next(error); }
});
app.get("/api/branding-stats", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const [allPlayers, ownPlayers, allSessions, ownSessions] = await Promise.all([
supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }),
supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }).eq("user_id", current.user.id),
supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }),
supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }).eq("user_id", current.user.id),
]);
const error = allPlayers.error ?? ownPlayers.error ?? allSessions.error ?? ownSessions.error;
if (error) throw new Error(`Unable to load application statistics: ${error.message}`);
return res.set("Cache-Control", "private, no-store").json({
players: { total: allPlayers.count ?? 0, mine: ownPlayers.count ?? 0 },
sessions: { total: allSessions.count ?? 0, mine: ownSessions.count ?? 0 },
});
} catch (error) { next(error); }
});
app.get("/api/sessions/:sessionId/reviews", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const sessionId = z.uuid().safeParse(req.params.sessionId);
if (!sessionId.success) return res.sendStatus(404);
const { data: session, error: sessionError } = await supabaseAdmin.from("sessions")
.select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
if (sessionError) throw new Error(`Unable to verify review session: ${sessionError.message}`);
if (!session) return res.sendStatus(404);
const ownsSession = session.user_id === current.user.id;
const isAdmin = await userIsAdmin(current.user.id);
if (!ownsSession && !isAdmin) return res.sendStatus(404);
const canReview = isAdmin || ownsSession;
const reviewUnavailableReason = null;
const { data: actions, error: actionsError } = await supabaseAdmin.from("actions")
.select("action_id").eq("session_id", sessionId.data);
if (actionsError) throw new Error(`Unable to load review actions: ${actionsError.message}`);
const actionIds = (actions ?? []).map((action) => action.action_id);
if (!actionIds.length) return res.json({ reviews: {}, canReview, reviewUnavailableReason });
const [{ data: annotations, error: annotationsError }, { data: notes, error: notesError }] = await Promise.all([
supabaseAdmin.from("annotations").select("action_id,score,user_id").in("action_id", actionIds),
supabaseAdmin.from("annotations_notes").select("action_id,note").in("action_id", actionIds),
]);
if (annotationsError) throw new Error(`Unable to load action reviews: ${annotationsError.message}`);
if (notesError) throw new Error(`Unable to load action notes: ${notesError.message}`);
const userIds = [...new Set((annotations ?? []).map((annotation) => annotation.user_id).filter((id): id is string => Boolean(id)))];
const { data: users, error: usersError } = userIds.length
? await supabaseAdmin.from("users").select("user_id,fname,lname").in("user_id", userIds)
: { data: [], error: null };
if (usersError) throw new Error(`Unable to load review authors: ${usersError.message}`);
const authorNames = new Map((users ?? []).map((user) => [user.user_id, [user.fname, user.lname].filter(Boolean).join(" ").trim()]));
const reviews: Record<string, { coachScore: number | null; feedback: string[]; feedbackAuthorName: string }> = Object.fromEntries(
actionIds.map((actionId) => [actionId, { coachScore: null, feedback: [], feedbackAuthorName: "Unknown user" }]),
);
for (const annotation of annotations ?? []) {
if (reviews[annotation.action_id]) {
reviews[annotation.action_id].coachScore = annotation.score == null ? null : Number(annotation.score);
reviews[annotation.action_id].feedbackAuthorName = annotation.user_id
? authorNames.get(annotation.user_id) || "Former user"
: "Former user";
}
}
for (const note of notes ?? []) reviews[note.action_id]?.feedback.push(note.note);
return res.json({ reviews, canReview, reviewUnavailableReason });
} catch (error) { next(error); }
});
app.get("/api/sessions/:sessionId/feedbacks", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const sessionId = z.uuid().safeParse(req.params.sessionId);
if (!sessionId.success) return res.sendStatus(404);
const { data: session, error: sessionError } = await supabaseAdmin.from("sessions")
.select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
if (sessionError) throw new Error(`Unable to verify feedback session: ${sessionError.message}`);
if (!session) return res.sendStatus(404);
if (session.user_id !== current.user.id && !await userIsAdmin(current.user.id)) return res.sendStatus(404);
const requestedActionId = req.query.actionId === undefined ? null : z.uuid().safeParse(req.query.actionId);
if (requestedActionId && !requestedActionId.success) return res.sendStatus(404);
let actionIds: string[] = [];
if (requestedActionId) {
const { data: action, error: actionError } = await supabaseAdmin.from("actions")
.select("action_id").eq("session_id", sessionId.data).eq("action_id", requestedActionId.data).maybeSingle();
if (actionError) throw new Error(`Unable to verify feedback action: ${actionError.message}`);
if (!action) return res.sendStatus(404);
actionIds = [action.action_id];
} else {
const { data: actions, error: actionsError } = await supabaseAdmin.from("actions")
.select("action_id").eq("session_id", sessionId.data);
if (actionsError) throw new Error(`Unable to load feedback actions: ${actionsError.message}`);
actionIds = (actions ?? []).map((action) => action.action_id);
}
const [sessionResult, actionResult] = await Promise.all([
requestedActionId
? Promise.resolve({ data: [], error: null })
: supabaseAdmin.from("sessions_feedbacks").select("feedback").eq("session_id", sessionId.data).order("feedback"),
actionIds.length
? supabaseAdmin.from("actions_feedbacks").select("action_id,feedback").in("action_id", actionIds).order("feedback")
: Promise.resolve({ data: [], error: null }),
]);
if (sessionResult.error) throw new Error(`Unable to load session feedback: ${sessionResult.error.message}`);
if (actionResult.error) throw new Error(`Unable to load action feedback: ${actionResult.error.message}`);
const actionFeedback: Record<string, string[]> = Object.fromEntries(actionIds.map((actionId) => [actionId, []]));
for (const row of actionResult.data ?? []) actionFeedback[row.action_id]?.push(row.feedback);
return res.json({ sessionFeedback: (sessionResult.data ?? []).map((row) => row.feedback), actionFeedback });
} catch (error) { next(error); }
});
app.post("/api/agent/chat", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const isAdmin = await userIsAdmin(current.user.id);
if (!isAdmin && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
return res.status(402).json({ error: "An active subscription is required to use the Agent.", code: "subscription_required" });
}
if (!env.AGENT_API_URL || !env.AGENT_API_KEY) {
return res.status(503).json({ error: "The coaching Agent is not configured." });
}
const input = z.object({
message: z.string().trim().min(1).max(6000),
history: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(6000) })).max(30).default([]),
sessionId: z.uuid().optional(), actionId: z.uuid().optional(), playerId: z.uuid().optional(),
}).strict().parse(req.body);
if (input.actionId && !input.sessionId) return res.status(400).json({ error: "A session is required for action context." });
const context: Record<string, unknown> = {};
if (input.sessionId) {
const { data: session, error } = await supabaseAdmin.from("sessions")
.select("session_id,user_id,name,player_id,players(fname,lname,bdate,gender,position,height,weight)")
.eq("session_id", input.sessionId).maybeSingle();
if (error) throw new Error(`Unable to verify chat session: ${error.message}`);
if (!session) return res.sendStatus(404);
if (session.user_id !== current.user.id && !isAdmin) return res.sendStatus(404);
context.session = { name: session.name, player: session.players };
const { data: sessionFeedback, error: sessionFeedbackError } = await supabaseAdmin.from("sessions_feedbacks")
.select("feedback").eq("session_id", input.sessionId).order("feedback");
if (sessionFeedbackError) throw new Error(`Unable to load chat feedback: ${sessionFeedbackError.message}`);
context.sessionFeedback = (sessionFeedback ?? []).map((row) => row.feedback);
if (!input.actionId) {
const { data: sessionActions, error: sessionActionsError } = await supabaseAdmin.from("actions")
.select("action_id,type,start_frame,end_frame,actions_feedbacks(feedback),annotations(score,annotations_notes(note))")
.eq("session_id", input.sessionId).limit(30);
if (sessionActionsError) throw new Error(`Unable to load session action context: ${sessionActionsError.message}`);
context.actions = sessionActions ?? [];
}
if (input.actionId) {
const { data: action, error: actionError } = await supabaseAdmin.from("actions")
.select("action_id,type,start_frame,end_frame,annotations(score,annotations_notes(note))").eq("session_id", input.sessionId).eq("action_id", input.actionId).maybeSingle();
if (actionError) throw new Error(`Unable to verify chat action: ${actionError.message}`);
if (!action) return res.sendStatus(404);
const { data: feedback, error: feedbackError } = await supabaseAdmin.from("actions_feedbacks")
.select("feedback").eq("action_id", input.actionId).order("feedback");
if (feedbackError) throw new Error(`Unable to load chat feedback: ${feedbackError.message}`);
const results = await analysisResults(input.sessionId, current.user.id, isAdmin);
const measured = results.actions.find((item) => item.id === input.actionId);
if (!measured) return res.status(404).json({ error: "Analysis results for this action are unavailable." });
const references = await actionReferences(supabaseAdmin, measured, action.type, results);
context.action = { ...action,
results: { metrics: measured.inActionMetrics ?? [], analytics: measured.rawAnalytics ?? {} },
references,
feedback: (feedback ?? []).map((row) => row.feedback) };
}
}
if (input.playerId && !input.sessionId) {
const { data: player, error } = await supabaseAdmin.from("players")
.select("user_id,fname,lname,bdate,height,weight,position,gender").eq("player_id", input.playerId).maybeSingle();
if (error) throw new Error(`Unable to load chat player context: ${error.message}`);
if (!player) return res.sendStatus(404);
if (player.user_id !== current.user.id && !isAdmin) return res.sendStatus(404);
context.player = player;
}
const agentHeaders: Record<string, string> = { "content-type": "application/json", "x-api-key": env.AGENT_API_KEY, "x-user-id": current.user.id };
if (env.HF_TOKEN) agentHeaders.authorization = `Bearer ${env.HF_TOKEN}`;
const upstream = await fetch(new URL("/api/chat", env.AGENT_API_URL), {
method: "POST", headers: agentHeaders,
body: JSON.stringify({ actions: [], context, message: input.message, history: input.history }),
signal: AbortSignal.timeout(95000),
});
const result = await upstream.json().catch(() => ({}));
if (!upstream.ok) return res.status(upstream.status).json({ error: typeof result.detail === "string" ? result.detail : "The coaching Agent could not answer." });
return res.json(result);
} catch (error) { next(error); }
});
app.put("/api/sessions/:sessionId/actions/:actionId/review", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const sessionId = z.uuid().safeParse(req.params.sessionId);
const actionId = z.uuid().safeParse(req.params.actionId);
const input = actionReviewSchema.safeParse(req.body);
if (!sessionId.success || !actionId.success || !input.success) {
return res.status(400).json({ error: "Check the action score and feedback points." });
}
const { data: session, error: sessionError } = await supabaseAdmin.from("sessions")
.select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
if (sessionError) throw new Error(`Unable to verify review session: ${sessionError.message}`);
if (!session) return res.sendStatus(404);
// An action has one annotation row and one notes collection. Its session
// owner or an administrator can replace that shared review.
const isAdmin = await userIsAdmin(current.user.id);
if (session.user_id !== current.user.id && !isAdmin) {
return res.status(403).json({ error: "Only the session owner or an administrator can save its action review." });
}
const { data: action, error: actionError } = await supabaseAdmin.from("actions")
.select("action_id").eq("action_id", actionId.data).eq("session_id", sessionId.data).maybeSingle();
if (actionError) throw new Error(`Unable to verify review action: ${actionError.message}`);
if (!action) return res.sendStatus(404);
const { error } = await supabaseAdmin.rpc("save_action_review", {
p_session_id: sessionId.data,
p_action_id: actionId.data,
p_user_id: current.user.id,
p_score: input.data.coachScore,
p_notes: [...new Set(input.data.feedback)],
});
if (error) {
console.error("Unable to save action review", { code: error.code, message: error.message });
throw new Error(`Unable to save action review: ${error.message}`);
}
const { data: author, error: authorError } = await supabaseAdmin.from("users")
.select("fname,lname").eq("user_id", current.user.id).maybeSingle();
if (authorError) throw new Error(`Unable to load review author: ${authorError.message}`);
const authorName = [author?.fname, author?.lname].filter(Boolean).join(" ").trim() || "Unknown user";
return res.json({ ok: true, authorName });
} catch (error) { next(error); }
});
// Session deletion is handled by the AI service through the proxy below so
// the session folder and database records are deleted together.
app.post("/api/auth/signup", authLimiter, async (req, res, next) => {
try {
const input = signupSchema.parse(req.body);
const passwordHash = await hashPassword(input.password);
const { data, error } = await supabaseAuth.auth.signUp({ email: input.email, password: input.password, options: { data: { fname: input.fname, lname: input.lname, academy: input.academy ?? null } } });
if (error) {
console.error("Supabase signup failed", { status: error.status, code: error.code, message: error.message });
return res.status(400).json({ error: env.NODE_ENV === "development" ? error.message : "Unable to create the account with these details." });
}
if (data.user?.identities?.length) await syncProfile(data.user, { ...input, passwordHash });
if (data.user && data.session) {
setSessionCookies(res, data.session);
clearPasswordRecoveryCookie(res);
return res.status(201).json({ user: userPayload(data.user), requiresEmailVerification: false });
}
return res.status(201).json({ user: data.user ? userPayload(data.user) : null, requiresEmailVerification: true });
} catch (error) { next(error); }
});
app.post("/api/auth/password/reset", passwordResetRequestLimiter, async (req, res, next) => {
try {
const input = passwordResetRequestSchema.parse(req.body);
const { error } = await supabaseAuth.auth.resetPasswordForEmail(input.email);
if (error) {
console.error("Supabase password reset request failed", { status: error.status, code: error.code });
return res.status(503).json({ error: "We couldn't send the reset email right now. Please try again later." });
}
// Keep the response identical whether or not the email belongs to an account.
return res.json({ ok: true });
} catch (error) { next(error); }
});
app.post("/api/auth/password/recovery/verify", passwordRecoveryLimiter, async (req, res, next) => {
try {
const input = passwordRecoveryVerifySchema.parse(req.body);
const { data, error } = await supabaseAuth.auth.verifyOtp({ token_hash: input.tokenHash, type: "recovery" });
if (error || !data.user || !data.session) {
return res.status(400).json({ error: "This reset link is invalid or has expired. Request a new one." });
}
setSessionCookies(res, data.session);
setPasswordRecoveryCookie(res, data.user.id);
res.set("Cache-Control", "no-store");
return res.json({ ok: true });
} catch (error) { next(error); }
});
app.post("/api/auth/password/recovery", passwordRecoveryLimiter, async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Your reset link has expired. Request a new one." });
if (!hasPasswordRecoveryCookie(req, current.user.id)) {
return res.status(403).json({ error: "Verify a password reset link before changing your password." });
}
const input = passwordRecoverySchema.parse(req.body);
const { error } = await supabaseAdmin.auth.admin.updateUserById(current.user.id, {
password: input.newPassword,
app_metadata: { ...current.user.app_metadata, has_password: true },
});
if (error) return res.status(400).json({ error: "Unable to update your password. Please try again." });
const passwordHash = await hashPassword(input.newPassword);
const { error: profileError } = await supabaseAdmin.from("users")
.update({ password_hash: passwordHash }).eq("user_id", current.user.id);
if (profileError) throw new Error(`Password updated but profile hash sync failed: ${profileError.message}`);
clearSessionCookies(res);
clearPasswordRecoveryCookie(res);
res.set("Cache-Control", "no-store");
return res.json({ ok: true });
} catch (error) { next(error); }
});
app.post("/api/auth/login", authLimiter, async (req, res, next) => {
try {
const input = loginSchema.parse(req.body);
const { data, error } = await supabaseAuth.auth.signInWithPassword(input);
if (error || !data.user || !data.session) return res.status(401).json({ error: "Invalid email or password." });
await syncProfile(data.user);
setSessionCookies(res, data.session);
clearPasswordRecoveryCookie(res);
const role = await accountRoleFor(data.user.id);
const subscription = role === "admin" ? null : await activeSubscriptionFor(data.user.id);
return res.json({ user: userPayload(data.user, role), access: { active: role === "admin" || Boolean(subscription), subscription } });
} catch (error) { next(error); }
});
app.get("/api/auth/google", authLimiter, async (req, res, next) => {
try {
const redirect = typeof req.query.redirect === "string" ? req.query.redirect : "/labs";
const { data, error } = await oauthClient(req, res).auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${authPublicOrigin}/api/auth/callback?next=${encodeURIComponent(redirect)}` } });
if (error || !data.url) return res.status(502).json({ error: "Google sign-in is currently unavailable." });
return res.redirect(data.url);
} catch (error) { next(error); }
});
app.get("/api/auth/callback", async (req, res, next) => {
try {
const code = typeof req.query.code === "string" ? req.query.code : null;
// Explicitly terminate any fragment from an unexpected implicit-flow
// response so access tokens cannot be inherited by this redirect URL.
if (!code) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed#`);
const { data, error } = await oauthClient(req, res).auth.exchangeCodeForSession(code);
if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed`);
await syncProfile(data.user);
setSessionCookies(res, data.session);
clearPasswordRecoveryCookie(res);
return res.redirect(safeRedirect(req.query.next));
} catch (error) { next(error); }
});
// Supabase confirmation emails can use {{ .TokenHash }} to deliver the one-time
// token directly to this server. Verify the email, then ask the user to sign in.
app.get("/api/auth/confirm", async (req, res, next) => {
try {
const tokenHash = typeof req.query.token_hash === "string" ? req.query.token_hash : "";
if (!tokenHash) return res.redirect(`${env.FRONTEND_URL}/login?error=email_confirmation_failed`);
const { data, error } = await supabaseAuth.auth.verifyOtp({ token_hash: tokenHash, type: "email" });
if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=email_confirmation_failed`);
await syncProfile(data.user);
return res.redirect(`${env.FRONTEND_URL}/login?verified=1`);
} catch (error) { next(error); }
});
app.post("/api/auth/logout", async (req, res, next) => {
try {
clearSessionCookies(res);
clearPasswordRecoveryCookie(res);
return res.status(204).send();
} catch (error) { next(error); }
});
app.get("/api/auth/me", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const role = await accountRoleFor(current.user.id);
const subscription = role === "admin" ? null : await activeSubscriptionFor(current.user.id);
res.set("Cache-Control", "no-store");
return res.json({ user: userPayload(current.user, role), access: { active: role === "admin" || Boolean(subscription), subscription } });
} catch (error) { next(error); }
});
app.get("/api/auth/profile", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const readProfile = () => supabaseAdmin.from("users")
.select("fname,lname,email,academy,role,image_path")
.eq("user_id", current.user.id)
.maybeSingle();
let { data, error } = await readProfile();
// Repair an absent profile only; normal reads must not write to the DB.
if (!error && !data) {
await syncProfile(current.user);
({ data, error } = await readProfile());
}
if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
const hasPassword = userHasPassword(current.user);
return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
} catch (error) { next(error); }
});
app.patch("/api/auth/profile", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const input = profileSchema.parse(req.body);
const profile = { ...input, academy: input.academy?.trim() || null };
const { data, error } = await supabaseAdmin.from("users")
.update(profile)
.eq("user_id", current.user.id)
.select("fname,lname,email,academy,role,image_path")
.single();
if (error || !data) throw new Error(`Unable to save user profile: ${error?.message ?? "Profile not found"}`);
// Keep auth metadata useful for integrations while the users row remains canonical.
const { error: metadataError } = await supabaseAdmin.auth.admin.updateUserById(current.user.id, {
user_metadata: { ...current.user.user_metadata, ...profile },
});
if (metadataError) console.error("Unable to sync profile metadata", { code: metadataError.code, message: metadataError.message });
return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword: userHasPassword(current.user) } });
} catch (error) { next(error); }
});
app.post("/api/auth/password", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const input = passwordSchema.parse(req.body);
const hasPassword = userHasPassword(current.user);
if (hasPassword) {
if (!input.currentPassword) return res.status(400).json({ error: "Enter your current password." });
const { error: verifyError } = await supabaseAuth.auth.signInWithPassword({ email: current.user.email ?? "", password: input.currentPassword });
if (verifyError) return res.status(400).json({ error: "Your current password is incorrect." });
}
const { error } = await supabaseAdmin.auth.admin.updateUserById(current.user.id, {
password: input.newPassword,
app_metadata: { ...current.user.app_metadata, has_password: true },
});
if (error) return res.status(400).json({ error: "Unable to update the password. Please try again." });
const passwordHash = await hashPassword(input.newPassword);
const { error: profileError } = await supabaseAdmin.from("users").update({ password_hash: passwordHash }).eq("user_id", current.user.id);
if (profileError) throw new Error(`Password updated but profile hash sync failed: ${profileError.message}`);
return res.json({ ok: true });
} catch (error) { next(error); }
});
app.get("/api/auth/account/impact", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const [{ count: players, error: playersError }, { count: sessions, error: sessionsError }] = await Promise.all([
supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }).eq("user_id", current.user.id),
supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }).eq("user_id", current.user.id),
]);
if (playersError || sessionsError) throw new Error(`Unable to load account data counts: ${(playersError ?? sessionsError)?.message}`);
return res.set("Cache-Control", "private, no-store").json({ players: players ?? 0, sessions: sessions ?? 0 });
} catch (error) { next(error); }
});
app.delete("/api/auth/account", async (req, res, next) => {
try {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
if (await userIsAdmin(current.user.id)) return res.status(403).json({ error: "Admin accounts cannot delete themselves." });
if (req.body?.confirmation !== "DELETE") return res.status(400).json({ error: "Confirm account deletion by typing DELETE." });
const mode = z.enum(["keep_data", "delete_data"]).safeParse(req.body?.mode);
if (!mode.success) return res.status(400).json({ error: "Choose whether to keep or delete your players and sessions." });
const { data: imageRow, error: imageReadError } = await supabaseAdmin.from("users")
.select("image_path").eq("user_id", current.user.id).maybeSingle();
if (imageReadError) throw new Error(`Unable to load account image: ${imageReadError.message}`);
if (mode.data === "delete_data") {
const { data: players, error: playersError } = await supabaseAdmin.from("players")
.select("player_id,image_path").eq("user_id", current.user.id);
if (playersError) throw new Error(`Unable to load account players: ${playersError.message}`);
const playerIds = (players ?? []).map((player) => player.player_id);
const direct = await supabaseAdmin.from("sessions").select("session_id").eq("user_id", current.user.id);
if (direct.error) throw new Error(`Unable to load account sessions: ${direct.error.message}`);
const linked = playerIds.length
? await supabaseAdmin.from("sessions").select("session_id").in("player_id", playerIds)
: { data: [], error: null };
if (linked.error) throw new Error(`Unable to load player sessions: ${linked.error.message}`);
const sessionIds = [...new Set([...(direct.data ?? []), ...(linked.data ?? [])].map((row) => row.session_id))];
for (const sessionId of sessionIds) await deleteStoredSession(sessionId, current.user.id, false);
if (playerIds.length) {
const { error: playerDeleteError } = await supabaseAdmin.from("players").delete().in("player_id", playerIds);
if (playerDeleteError) throw new Error(`Unable to delete account players: ${playerDeleteError.message}`);
}
const imagePaths = (players ?? []).map((player) => player.image_path).filter((path): path is string => Boolean(path));
if (imagePaths.length) {
const { error: playerImageError } = await supabaseAdmin.storage.from("profile-images").remove(imagePaths);
if (playerImageError) throw new Error(`Unable to remove player images: ${playerImageError.message}`);
}
}
const { error: subscriptionError } = await supabaseAdmin.from("users_subscriptions").delete().eq("user_id", current.user.id);
if (subscriptionError) throw new Error(`Unable to remove account subscription: ${subscriptionError.message}`);
const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(current.user.id);
if (authError) return res.status(500).json({ error: "Unable to delete your account. Please try again." });
const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", current.user.id);
if (profileError) throw new Error(`Authentication account was deleted but profile cleanup failed: ${profileError.message}`);
clearSessionCookies(res);
clearPasswordRecoveryCookie(res);
if (imageRow?.image_path?.startsWith(`users/${current.user.id}/`)) {
const { error: imageError } = await supabaseAdmin.storage.from("profile-images").remove([imageRow.image_path]);
if (imageError) console.error("Account deleted but image cleanup failed", imageError);
}
return res.status(204).send();
} catch (error) { next(error); }
});
async function proxyAnalysisRequest(req: Request, res: Response) {
const current = await authenticatedUser(req, res);
if (!current) return res.status(401).json({ error: "Authentication required." });
const resourcePath = req.path;
const isAdmin = await userIsAdmin(current.user.id);
const exactSessionDelete = req.method === "DELETE"
? resourcePath.match(/^\/sessions\/([0-9a-f-]{36})$/i)
: null;
let deletedSessionQuota: { user_id: string | null; by_admin: boolean } | null = null;
if (isAdmin && exactSessionDelete) {
const { data, error } = await supabaseAdmin.from("sessions").select("user_id,by_admin")
.eq("session_id", exactSessionDelete[1]).maybeSingle();
if (error) throw new Error(`Unable to load session before deletion: ${error.message}`);
if (!data) return res.sendStatus(404);
deletedSessionQuota = data;
}
const sessionMatch = resourcePath.match(/^\/(?:sessions\/)?([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:\/|$)/i);
if (sessionMatch && !isAdmin) {
const { data: session, error } = await supabaseAdmin.from("sessions")
.select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
if (error) throw new Error(`Unable to verify session ownership: ${error.message}`);
if (!session) return res.status(404).json({ error: "Session not found." });
}
// Archived sessions and results remain available after expiry. Only starting
// a new analysis is gated here; the internal begin-analysis route repeats
// this check before creating its player/session rows.
if (req.method === "POST" && req.path === "/analyze") {
const active = isAdmin || await activeSubscriptionFor(current.user.id, { refresh: true });
if (!active) return res.status(402).json({ error: "An active subscription is required to create or change Labs data.", code: "subscription_required" });
}
const target = new URL(req.originalUrl, env.ANALYSIS_API_URL);
const headers = new Headers();
headers.set("x-sporalize-proxy-secret", env.ANALYSIS_PROXY_SECRET);
// Derived from the verified cookie session; never trust a browser-supplied ID.
headers.set("x-sporalize-user-id", current.user.id);
if (isAdmin) headers.set("x-sporalize-admin", "true");
headers.set("x-forwarded-host", new URL(env.BACKEND_URL).host);
headers.set("x-forwarded-proto", new URL(env.BACKEND_URL).protocol.replace(":", ""));
for (const name of ["accept", "content-type", "range", "if-range"]) {
const value = req.header(name);
if (value) headers.set(name, value);
}
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const isJson = Boolean(req.is(["application/json", "text/plain"]));
if (isJson) headers.set("content-type", "application/json");
const body = !hasBody ? undefined : isJson
? JSON.stringify(req.body ?? {})
: req as unknown as BodyInit;
const init: RequestInit & { duplex?: "half" } = {
method: req.method,
headers,
body,
...(body && typeof body !== "string" ? { duplex: "half" as const } : {}),
};
const upstream = await fetch(target, init);
if (upstream.ok && deletedSessionQuota) {
await refundAdminUsage(deletedSessionQuota.user_id, 0, deletedSessionQuota.by_admin ? 0 : 1);
}
res.status(upstream.status);
res.set("Cross-Origin-Resource-Policy", "cross-origin");
for (const name of ["content-type", "content-length", "content-disposition", "cache-control", "accept-ranges", "content-range", "etag", "last-modified"]) {
const value = upstream.headers.get(name);
if (value) res.set(name, value);
}
if (!upstream.body) return res.end();
await pipeline(Readable.fromWeb(upstream.body as import("node:stream/web").ReadableStream), res);
}
const analysisProxy = (req: Request, res: Response, next: NextFunction) => {
void proxyAnalysisRequest(req, res).catch((error: unknown) => {
if (res.headersSent) return res.destroy(error instanceof Error ? error : undefined);
console.error("Analysis API proxy failed", error);
return res.status(502).json({ error: "The analysis service is unavailable." });
});
};
// All analysis API traffic and stored media pass through this subscription
// gate. Auth and subscription status endpoints remain available without a plan.
app.use("/api", (req, res, next) => {
if (req.originalUrl.startsWith("/api/auth/") || req.originalUrl === "/api/subscriptions" || req.originalUrl === "/api/subscription/access" || req.originalUrl === "/api/public-config") return next();
const limiter = req.method === "POST" && req.path === "/analyze" ? analysisUploadLimiter : apiLimiter;
return limiter(req, res, (error?: unknown) => error ? next(error) : analysisProxy(req, res, next));
});
app.use("/storage", apiLimiter, analysisProxy);
app.use((_req, res) => res.status(404).json({ error: "Route not found." }));
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
if (error instanceof z.ZodError) return res.status(400).json({ error: "Please check the submitted fields." });
if (typeof error === "object" && error !== null && "type" in error && error.type === "entity.too.large") {
return res.status(413).json({ error: "Profile images must be 4 MB or smaller." });
}
return res.status(500).json({ error: "Something went wrong. Please try again." });
});
app.listen(env.PORT, () => console.log(`Sporalize API listening on http://localhost:${env.PORT}`));