Shoraky commited on
Commit
99cf4c0
·
1 Parent(s): 10ace60

Enforce subscription period quotas

Browse files
Files changed (5) hide show
  1. app.py +9 -1
  2. src/analysis.ts +26 -5
  3. src/profile-images.ts +8 -3
  4. src/server.ts +150 -24
  5. src/subscriptions.ts +1 -1
app.py CHANGED
@@ -129,7 +129,15 @@ async def backend_error(request: Request, error: httpx.HTTPStatusError):
129
  status = error.response.status_code
130
  logger.error("Analysis backend request failed: path=%s status=%s response=%s",
131
  request.url.path, status, error.response.text[:500])
132
- return JSONResponse({"detail": "Backend request failed"}, status_code=status if status < 500 else 502)
 
 
 
 
 
 
 
 
133
 
134
 
135
  @app.middleware("http")
 
129
  status = error.response.status_code
130
  logger.error("Analysis backend request failed: path=%s status=%s response=%s",
131
  request.url.path, status, error.response.text[:500])
132
+ try:
133
+ backend_body = error.response.json()
134
+ except ValueError:
135
+ backend_body = {}
136
+ detail = backend_body.get("error") or backend_body.get("detail") or "Backend request failed"
137
+ payload = {"detail": detail}
138
+ if backend_body.get("code"):
139
+ payload["code"] = backend_body["code"]
140
+ return JSONResponse(payload, status_code=status if status < 500 else 502)
141
 
142
 
143
  @app.middleware("http")
src/analysis.ts CHANGED
@@ -9,7 +9,7 @@ export function stableId(value: string) {
9
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
10
  }
11
 
12
- const beginSchema = z.object({ clientId: z.uuid(), playerId: z.string().trim().min(1).max(100), name: z.string().trim().max(150) });
13
  const reportSchema = z.object({
14
  index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
15
  start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
@@ -46,18 +46,39 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
46
  try {
47
  const input = beginSchema.parse(req.body);
48
  const userId = res.locals.userId as string;
49
- if (!await activeSubscriptionFor(userId, { refresh: true })) {
 
50
  return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
51
  }
52
- const playerId = z.uuid().safeParse(input.playerId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const { data, error } = await db.rpc("begin_analysis", {
54
  p_session_id: input.clientId, p_user_id: userId,
55
- p_player_id: playerId.success ? playerId.data : stableId(`${userId}:player:${input.playerId}`),
56
- p_reference: playerId.success ? null : input.playerId,
57
  p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
58
  });
59
  if (error) {
60
  console.error("Unable to prepare analysis session", { code: error.code, message: error.message });
 
 
 
 
 
 
61
  return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to prepare analysis session" });
62
  }
63
  return res.json(data);
 
9
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
10
  }
11
 
12
+ const beginSchema = z.object({ clientId: z.uuid(), playerId: z.uuid(), name: z.string().trim().max(150) });
13
  const reportSchema = z.object({
14
  index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
15
  start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
 
46
  try {
47
  const input = beginSchema.parse(req.body);
48
  const userId = res.locals.userId as string;
49
+ const subscription = await activeSubscriptionFor(userId, { refresh: true });
50
+ if (!subscription) {
51
  return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
52
  }
53
+ const { data: usage, error: usageError } = await db.from("users_subscriptions")
54
+ .select("num_sessions_month").eq("user_id", userId).maybeSingle();
55
+ if (usageError) return res.status(503).json({ error: "Unable to check your session quota." });
56
+ const usedSessions = Number(usage?.num_sessions_month ?? 0);
57
+ const sessionLimit = subscription.monthly_session_limit == null ? null : Number(subscription.monthly_session_limit);
58
+ if (sessionLimit !== null && usedSessions >= sessionLimit) {
59
+ return res.status(403).json({
60
+ error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.",
61
+ code: "quota_exceeded", resource: "sessions", used: usedSessions, limit: sessionLimit,
62
+ });
63
+ }
64
+ const { data: player, error: playerError } = await db.from("players")
65
+ .select("player_id").eq("player_id", input.playerId).maybeSingle();
66
+ if (playerError) return res.status(503).json({ error: "Unable to verify the selected player." });
67
+ if (!player) return res.status(404).json({ error: "Choose a player from the available roster before starting analysis." });
68
  const { data, error } = await db.rpc("begin_analysis", {
69
  p_session_id: input.clientId, p_user_id: userId,
70
+ p_player_id: input.playerId,
71
+ p_reference: null,
72
  p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
73
  });
74
  if (error) {
75
  console.error("Unable to prepare analysis session", { code: error.code, message: error.message });
76
+ if (error.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
77
+ return res.status(403).json({ error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.", code: "quota_exceeded", resource: "sessions" });
78
+ }
79
+ if (error.code === "P0001" && error.message.includes("subscription_required")) {
80
+ return res.status(402).json({ error: "An active subscription is required to start an analysis.", code: "subscription_required" });
81
+ }
82
  return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to prepare analysis session" });
83
  }
84
  return res.json(data);
src/profile-images.ts CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
  import express, { type Express, type Request, type Response } from "express";
3
  import { z } from "zod";
4
  import { authenticatedUser } from "./auth.js";
 
5
  import { supabaseAdmin } from "./supabase.js";
6
 
7
  const BUCKET = "profile-images";
@@ -120,8 +121,12 @@ export function registerProfileImageRoutes(app: Express) {
120
  if (!current) return res.status(401).json({ error: "Authentication required." });
121
  const id = playerIdSchema.safeParse(req.params.playerId);
122
  if (!id.success) return res.status(404).json({ error: "Player not found." });
123
- const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).maybeSingle();
124
  if (error) throw new Error(`Unable to load player image: ${error.message}`);
 
 
 
 
125
  return await sendImage(res, safePreviousPath("players", id.data, data?.image_path));
126
  } catch (error) { next(error); }
127
  });
@@ -132,7 +137,7 @@ export function registerProfileImageRoutes(app: Express) {
132
  if (!current) return res.status(401).json({ error: "Authentication required." });
133
  const id = playerIdSchema.safeParse(req.params.playerId);
134
  if (!id.success) return res.status(404).json({ error: "Player not found." });
135
- const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).maybeSingle();
136
  if (error || !data) return res.status(404).json({ error: "Player not found." });
137
  return await uploadImage(req, res, "players", id.data, data.image_path);
138
  } catch (error) { next(error); }
@@ -144,7 +149,7 @@ export function registerProfileImageRoutes(app: Express) {
144
  if (!current) return res.status(401).json({ error: "Authentication required." });
145
  const id = playerIdSchema.safeParse(req.params.playerId);
146
  if (!id.success) return res.status(404).json({ error: "Player not found." });
147
- const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).maybeSingle();
148
  if (error || !data) return res.status(404).json({ error: "Player not found." });
149
  return await deleteImage(res, "players", id.data, data.image_path);
150
  } catch (error) { next(error); }
 
2
  import express, { type Express, type Request, type Response } from "express";
3
  import { z } from "zod";
4
  import { authenticatedUser } from "./auth.js";
5
+ import { activeSubscriptionFor } from "./subscriptions.js";
6
  import { supabaseAdmin } from "./supabase.js";
7
 
8
  const BUCKET = "profile-images";
 
121
  if (!current) return res.status(401).json({ error: "Authentication required." });
122
  const id = playerIdSchema.safeParse(req.params.playerId);
123
  if (!id.success) return res.status(404).json({ error: "Player not found." });
124
+ const { data, error } = await supabaseAdmin.from("players").select("user_id,image_path").eq("player_id", id.data).maybeSingle();
125
  if (error) throw new Error(`Unable to load player image: ${error.message}`);
126
+ if (!data) return res.status(404).json({ error: "Player not found." });
127
+ if (data.user_id !== current.user.id && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
128
+ return res.status(403).json({ error: "An active subscription is required to view shared player profiles.", code: "subscription_required" });
129
+ }
130
  return await sendImage(res, safePreviousPath("players", id.data, data?.image_path));
131
  } catch (error) { next(error); }
132
  });
 
137
  if (!current) return res.status(401).json({ error: "Authentication required." });
138
  const id = playerIdSchema.safeParse(req.params.playerId);
139
  if (!id.success) return res.status(404).json({ error: "Player not found." });
140
+ const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).eq("user_id", current.user.id).maybeSingle();
141
  if (error || !data) return res.status(404).json({ error: "Player not found." });
142
  return await uploadImage(req, res, "players", id.data, data.image_path);
143
  } catch (error) { next(error); }
 
149
  if (!current) return res.status(401).json({ error: "Authentication required." });
150
  const id = playerIdSchema.safeParse(req.params.playerId);
151
  if (!id.success) return res.status(404).json({ error: "Player not found." });
152
+ const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).eq("user_id", current.user.id).maybeSingle();
153
  if (error || !data) return res.status(404).json({ error: "Player not found." });
154
  return await deleteImage(res, "players", id.data, data.image_path);
155
  } catch (error) { next(error); }
src/server.ts CHANGED
@@ -114,11 +114,25 @@ async function requireActiveSubscription(req: Request, res: Response) {
114
  res.status(401).json({ error: "Authentication required." });
115
  return null;
116
  }
117
- if (!await activeSubscriptionFor(current.user.id, { refresh: true })) {
 
118
  res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
119
  return null;
120
  }
121
- return current;
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
 
124
  app.get("/health", (_req, res) => res.json({ ok: true }));
@@ -126,7 +140,7 @@ app.get("/health", (_req, res) => res.json({ ok: true }));
126
  app.get("/api/subscriptions", async (_req, res, next) => {
127
  try {
128
  const { data, error } = await supabaseAdmin.from("subscriptions")
129
- .select("subscription_id,plan,price")
130
  .order("price", { ascending: true });
131
  if (error) throw new Error(`Unable to load subscription plans: ${error.message}`);
132
  const plans = (data ?? []).map((row) => ({
@@ -134,6 +148,8 @@ app.get("/api/subscriptions", async (_req, res, next) => {
134
  subscriptionId: row.subscription_id,
135
  name: row.plan,
136
  monthlyPrice: Number(row.price),
 
 
137
  }));
138
  return res.json({ plans });
139
  } catch (error) { next(error); }
@@ -154,13 +170,13 @@ app.get("/api/plan-billing", async (req, res, next) => {
154
  if (!current) return res.status(401).json({ error: "Authentication required." });
155
  const userId = current.user.id;
156
  const assignmentResult = await supabaseAdmin.from("users_subscriptions")
157
- .select("subscription_id,startdate,enddate,num_players,num_sessions").eq("user_id", userId).maybeSingle();
158
  if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
159
  const assignment = assignmentResult.data;
160
  let subscription = null;
161
  if (assignment?.subscription_id) {
162
  const { data: plan, error } = await supabaseAdmin.from("subscriptions")
163
- .select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
164
  if (error) throw new Error(`Unable to load plan: ${error.message}`);
165
  if (plan) {
166
  const today = new Date().toISOString().slice(0, 10);
@@ -168,6 +184,8 @@ app.get("/api/plan-billing", async (req, res, next) => {
168
  subscriptionId: plan.subscription_id,
169
  name: plan.plan,
170
  price: Number(plan.price),
 
 
171
  startDate: assignment.startdate,
172
  endDate: assignment.enddate,
173
  status: today < assignment.startdate ? "scheduled" : today > assignment.enddate ? "expired" : "active",
@@ -175,7 +193,80 @@ app.get("/api/plan-billing", async (req, res, next) => {
175
  }
176
  }
177
  res.set("Cache-Control", "no-store");
178
- return res.json({ subscription, usage: { players: assignment?.num_players ?? 0, sessions: assignment?.num_sessions ?? 0 }, billingHistoryAvailable: false });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  } catch (error) { next(error); }
180
  });
181
 
@@ -193,7 +284,7 @@ app.put("/api/players/:playerId", async (req, res, next) => {
193
  return res.status(400).json({ error: "Check the player's age, height and weight." });
194
  }
195
  const { data: existing, error: readError } = await supabaseAdmin.from("players")
196
- .select("player_id,bdate").eq("player_id", id.data).maybeSingle();
197
  if (readError) throw new Error(`Unable to load player: ${readError.message}`);
198
  if (!existing) return res.status(404).json({ error: "Player not found." });
199
  const [fname, ...lastParts] = input.name.split(/\s+/);
@@ -215,29 +306,32 @@ app.put("/api/players/:playerId", async (req, res, next) => {
215
  club: input.club || null,
216
  position: input.position || null,
217
  gender: input.gender || null,
218
- }).eq("player_id", id.data);
219
  if (error) throw new Error(`Unable to update player: ${error.message}`);
220
  return res.json({ ok: true });
221
  } catch (error) { next(error); }
222
  });
223
 
224
- // Players and sessions are currently a shared workspace: all signed-in users
225
- // can read the common catalogue. Revisit these read scopes if per-user data is
226
- // introduced later. The service-role client bypasses RLS, so keep this explicit.
227
  app.get("/api/players-overview", async (req, res, next) => {
228
  try {
229
  const current = await authenticatedUser(req, res);
230
  if (!current) return res.status(401).json({ error: "Authentication required." });
231
 
232
- const { data: rows, error } = await supabaseAdmin.from("players")
 
233
  .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
234
  .order("fname", { ascending: true });
 
 
235
  if (error) throw new Error(`Unable to load players: ${error.message}`);
236
 
237
  const playerIds = (rows ?? []).map((row) => row.player_id);
 
 
 
238
  const { data: sessions, error: sessionsError } = playerIds.length
239
- ? await supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at")
240
- .in("player_id", playerIds).order("created_at", { ascending: false })
241
  : { data: [], error: null };
242
  if (sessionsError) throw new Error(`Unable to load player sessions: ${sessionsError.message}`);
243
 
@@ -273,6 +367,7 @@ app.get("/api/players-overview", async (req, res, next) => {
273
  });
274
  return {
275
  playerId: row.player_id,
 
276
  name: [row.fname, row.lname].filter(Boolean).join(" "),
277
  age: ageFromBirthday(row.bdate),
278
  heightCm: row.height == null ? "" : String(row.height),
@@ -301,17 +396,23 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
301
  if (!current) return res.status(401).json({ error: "Authentication required." });
302
  const playerId = z.string().uuid().safeParse(req.params.playerId);
303
  if (!playerId.success) return res.status(404).json({ error: "Player not found." });
304
- const { data, error } = await supabaseAdmin.from("players").select("player_id")
305
- .eq("player_id", playerId.data).maybeSingle();
 
 
 
306
  if (error) throw new Error(`Unable to load player: ${error.message}`);
307
  if (!data) return res.status(404).json({ error: "Player not found." });
308
 
309
- const { data: overview, error: overviewError } = await supabaseAdmin.from("players")
310
  .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
311
  .order("fname", { ascending: true });
 
 
312
  if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
313
- const { data: sessions, error: sessionsError } = await supabaseAdmin.from("sessions")
314
- .select("session_id,player_id,name,created_at");
 
315
  if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
316
  const { data: actions, error: actionsError } = (sessions ?? []).length
317
  ? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
@@ -346,7 +447,9 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
346
  const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
347
  return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
348
  });
349
- const { data: peers, error: peersError } = await supabaseAdmin.from("players").select("player_id");
 
 
350
  if (peersError) throw new Error(`Unable to load roster: ${peersError.message}`);
351
  const playerScores = new Map<string, number[]>();
352
  for (const action of actions ?? []) {
@@ -361,6 +464,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
361
  const squadAverage = { byQuality: [], byAction: squadByAction };
362
  return res.json({
363
  playerId: player.player_id, name: [player.fname, player.lname].filter(Boolean).join(" "),
 
364
  age: ageFromBirthday(player.bdate),
365
  heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
366
  club: player.club ?? "", position: player.position ?? "", gender: player.gender ?? null,
@@ -378,9 +482,12 @@ app.get("/api/sessions-overview", async (req, res, next) => {
378
  const current = await authenticatedUser(req, res);
379
  if (!current) return res.status(401).json({ error: "Authentication required." });
380
 
381
- const { data: sessions, error } = await supabaseAdmin.from("sessions")
382
- .select("session_id,player_id,name,path,created_at,status")
 
383
  .order("created_at", { ascending: false });
 
 
384
  if (error) throw new Error(`Unable to load sessions: ${error.message}`);
385
  const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
386
  const { data: players, error: playersError } = playerIds.length
@@ -403,7 +510,7 @@ app.get("/api/sessions-overview", async (req, res, next) => {
403
  return res.json({ sessions: (sessions ?? []).map((row) => {
404
  const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
405
  return {
406
- sessionId: row.session_id, playerId: row.player_id ?? "", playerName: row.player_id ? playerNames.get(row.player_id) ?? "" : "",
407
  playerGender: row.player_id ? playerImages.get(row.player_id)?.gender ?? null : null,
408
  playerImagePath: row.player_id ? playerImages.get(row.player_id)?.imagePath ?? null : null,
409
  name: row.name, createdAt: Date.parse(row.created_at) || 0,
@@ -422,9 +529,12 @@ app.get("/api/sessions/:sessionId/reviews", async (req, res, next) => {
422
  if (!sessionId.success) return res.sendStatus(404);
423
 
424
  const { data: session, error: sessionError } = await supabaseAdmin.from("sessions")
425
- .select("session_id").eq("session_id", sessionId.data).maybeSingle();
426
  if (sessionError) throw new Error(`Unable to verify review session: ${sessionError.message}`);
427
  if (!session) return res.sendStatus(404);
 
 
 
428
 
429
  const { data: actions, error: actionsError } = await supabaseAdmin.from("actions")
430
  .select("action_id").eq("session_id", sessionId.data);
@@ -729,6 +839,22 @@ app.delete("/api/auth/account", async (req, res, next) => {
729
  async function proxyAnalysisRequest(req: Request, res: Response) {
730
  const current = await authenticatedUser(req, res);
731
  if (!current) return res.status(401).json({ error: "Authentication required." });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  // Archived sessions and results remain available after expiry. Only starting
733
  // a new analysis is gated here; the internal begin-analysis route repeats
734
  // this check before creating its player/session rows.
 
114
  res.status(401).json({ error: "Authentication required." });
115
  return null;
116
  }
117
+ const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
118
+ if (!subscription) {
119
  res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
120
  return null;
121
  }
122
+ return { ...current, subscription };
123
+ }
124
+
125
+ async function enforcePlayerQuota(userId: string, limit: number | null, res: Response) {
126
+ const { data, error } = await supabaseAdmin.from("users_subscriptions")
127
+ .select("num_players").eq("user_id", userId).maybeSingle();
128
+ if (error) throw new Error(`Unable to check subscription usage: ${error.message}`);
129
+ const used = Number(data?.num_players ?? 0);
130
+ if (limit === null || used < limit) return true;
131
+ res.status(403).json({
132
+ error: "You have used all player slots included in this plan period. Renew or upgrade your plan to add another player.",
133
+ code: "quota_exceeded", resource: "players", used, limit,
134
+ });
135
+ return false;
136
  }
137
 
138
  app.get("/health", (_req, res) => res.json({ ok: true }));
 
140
  app.get("/api/subscriptions", async (_req, res, next) => {
141
  try {
142
  const { data, error } = await supabaseAdmin.from("subscriptions")
143
+ .select("subscription_id,plan,price,player_limit,monthly_session_limit")
144
  .order("price", { ascending: true });
145
  if (error) throw new Error(`Unable to load subscription plans: ${error.message}`);
146
  const plans = (data ?? []).map((row) => ({
 
148
  subscriptionId: row.subscription_id,
149
  name: row.plan,
150
  monthlyPrice: Number(row.price),
151
+ playerLimit: row.player_limit == null ? null : Number(row.player_limit),
152
+ monthlySessions: row.monthly_session_limit == null ? null : Number(row.monthly_session_limit),
153
  }));
154
  return res.json({ plans });
155
  } catch (error) { next(error); }
 
170
  if (!current) return res.status(401).json({ error: "Authentication required." });
171
  const userId = current.user.id;
172
  const assignmentResult = await supabaseAdmin.from("users_subscriptions")
173
+ .select("subscription_id,startdate,enddate,num_players,num_sessions,num_sessions_month").eq("user_id", userId).maybeSingle();
174
  if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
175
  const assignment = assignmentResult.data;
176
  let subscription = null;
177
  if (assignment?.subscription_id) {
178
  const { data: plan, error } = await supabaseAdmin.from("subscriptions")
179
+ .select("subscription_id,plan,price,player_limit,monthly_session_limit").eq("subscription_id", assignment.subscription_id).maybeSingle();
180
  if (error) throw new Error(`Unable to load plan: ${error.message}`);
181
  if (plan) {
182
  const today = new Date().toISOString().slice(0, 10);
 
184
  subscriptionId: plan.subscription_id,
185
  name: plan.plan,
186
  price: Number(plan.price),
187
+ playerLimit: plan.player_limit == null ? null : Number(plan.player_limit),
188
+ monthlySessions: plan.monthly_session_limit == null ? null : Number(plan.monthly_session_limit),
189
  startDate: assignment.startdate,
190
  endDate: assignment.enddate,
191
  status: today < assignment.startdate ? "scheduled" : today > assignment.enddate ? "expired" : "active",
 
193
  }
194
  }
195
  res.set("Cache-Control", "no-store");
196
+ const monthlySessionUsage = Number(assignment?.num_sessions_month ?? 0);
197
+ return res.json({ subscription, usage: { players: assignment?.num_players ?? 0, sessions: assignment?.num_sessions ?? 0, monthlySessions: monthlySessionUsage }, billingHistoryAvailable: false });
198
+ } catch (error) { next(error); }
199
+ });
200
+
201
+ app.get("/api/owned-players", async (req, res, next) => {
202
+ try {
203
+ const current = await authenticatedUser(req, res);
204
+ if (!current) return res.status(401).json({ error: "Authentication required." });
205
+ const subscribed = Boolean(await activeSubscriptionFor(current.user.id, { refresh: true }));
206
+ const { data, error } = await supabaseAdmin.from("players")
207
+ .select("player_id,user_id,fname,lname,position,club,gender,image_path")
208
+ .order("fname", { ascending: true });
209
+ if (error) throw new Error(`Unable to load players: ${error.message}`);
210
+ const visiblePlayers = subscribed ? (data ?? []) : (data ?? []).filter((player) => player.user_id === current.user.id);
211
+ return res.json({ players: visiblePlayers.map((player) => ({
212
+ playerId: player.player_id,
213
+ name: [player.fname, player.lname].filter(Boolean).join(" "),
214
+ isOwner: player.user_id === current.user.id,
215
+ position: player.position ?? "",
216
+ club: player.club ?? "",
217
+ gender: player.gender ?? null,
218
+ imagePath: player.image_path ?? null,
219
+ })) });
220
+ } catch (error) { next(error); }
221
+ });
222
+
223
+ app.post("/api/players", async (req, res, next) => {
224
+ try {
225
+ const current = await requireActiveSubscription(req, res);
226
+ if (!current) return;
227
+ if (!await enforcePlayerQuota(current.user.id, current.subscription.player_limit == null ? null : Number(current.subscription.player_limit), res)) return;
228
+ const input = playerProfileSchema.parse(req.body);
229
+ const age = input.age ? Number(input.age) : null;
230
+ const height = input.heightCm ? Number(input.heightCm) : null;
231
+ const weight = input.weightKg ? Number(input.weightKg) : null;
232
+ if ((age !== null && (age < 4 || age > 60)) || (height !== null && (height < 100 || height > 230)) || (weight !== null && (weight < 20 || weight > 150))) {
233
+ return res.status(400).json({ error: "Check the player's age, height and weight." });
234
+ }
235
+ const [fname, ...lastParts] = input.name.split(/\s+/);
236
+ const lname = lastParts.join(" ");
237
+ if (fname.length > 100 || lname.length > 100) return res.status(400).json({ error: "Player first and last names must be 100 characters or fewer." });
238
+ let birthday: string | null = null;
239
+ if (age !== null) {
240
+ const date = new Date();
241
+ date.setUTCFullYear(date.getUTCFullYear() - age);
242
+ birthday = date.toISOString().slice(0, 10);
243
+ }
244
+ const { data, error } = await supabaseAdmin.from("players").insert({
245
+ user_id: current.user.id,
246
+ fname,
247
+ lname,
248
+ bdate: birthday,
249
+ height,
250
+ weight,
251
+ club: input.club || null,
252
+ position: input.position || null,
253
+ gender: input.gender || null,
254
+ }).select("player_id,fname,lname,position,club,gender,image_path").single();
255
+ if (error?.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
256
+ 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" });
257
+ }
258
+ if (error?.code === "P0001" && error.message.includes("subscription_required")) {
259
+ return res.status(402).json({ error: "An active subscription is required to add a player.", code: "subscription_required" });
260
+ }
261
+ if (error) throw new Error(`Unable to create player: ${error.message}`);
262
+ return res.status(201).json({ player: {
263
+ playerId: data.player_id,
264
+ name: [data.fname, data.lname].filter(Boolean).join(" "),
265
+ position: data.position ?? "",
266
+ club: data.club ?? "",
267
+ gender: data.gender ?? null,
268
+ imagePath: data.image_path ?? null,
269
+ } });
270
  } catch (error) { next(error); }
271
  });
272
 
 
284
  return res.status(400).json({ error: "Check the player's age, height and weight." });
285
  }
286
  const { data: existing, error: readError } = await supabaseAdmin.from("players")
287
+ .select("player_id,bdate").eq("player_id", id.data).eq("user_id", current.user.id).maybeSingle();
288
  if (readError) throw new Error(`Unable to load player: ${readError.message}`);
289
  if (!existing) return res.status(404).json({ error: "Player not found." });
290
  const [fname, ...lastParts] = input.name.split(/\s+/);
 
306
  club: input.club || null,
307
  position: input.position || null,
308
  gender: input.gender || null,
309
+ }).eq("player_id", id.data).eq("user_id", current.user.id);
310
  if (error) throw new Error(`Unable to update player: ${error.message}`);
311
  return res.json({ ok: true });
312
  } catch (error) { next(error); }
313
  });
314
 
315
+ // Player management and analysis choices are scoped to the signed-in owner.
 
 
316
  app.get("/api/players-overview", async (req, res, next) => {
317
  try {
318
  const current = await authenticatedUser(req, res);
319
  if (!current) return res.status(401).json({ error: "Authentication required." });
320
 
321
+ const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
322
+ let playersQuery = supabaseAdmin.from("players")
323
  .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
324
  .order("fname", { ascending: true });
325
+ if (!subscription) playersQuery = playersQuery.eq("user_id", current.user.id);
326
+ const { data: rows, error } = await playersQuery;
327
  if (error) throw new Error(`Unable to load players: ${error.message}`);
328
 
329
  const playerIds = (rows ?? []).map((row) => row.player_id);
330
+ let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at")
331
+ .in("player_id", playerIds).order("created_at", { ascending: false });
332
+ if (!subscription) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
333
  const { data: sessions, error: sessionsError } = playerIds.length
334
+ ? await sessionsQuery
 
335
  : { data: [], error: null };
336
  if (sessionsError) throw new Error(`Unable to load player sessions: ${sessionsError.message}`);
337
 
 
367
  });
368
  return {
369
  playerId: row.player_id,
370
+ isOwner: row.user_id === current.user.id,
371
  name: [row.fname, row.lname].filter(Boolean).join(" "),
372
  age: ageFromBirthday(row.bdate),
373
  heightCm: row.height == null ? "" : String(row.height),
 
396
  if (!current) return res.status(401).json({ error: "Authentication required." });
397
  const playerId = z.string().uuid().safeParse(req.params.playerId);
398
  if (!playerId.success) return res.status(404).json({ error: "Player not found." });
399
+ const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
400
+ let playerQuery = supabaseAdmin.from("players").select("player_id,user_id")
401
+ .eq("player_id", playerId.data);
402
+ if (!subscription) playerQuery = playerQuery.eq("user_id", current.user.id);
403
+ const { data, error } = await playerQuery.maybeSingle();
404
  if (error) throw new Error(`Unable to load player: ${error.message}`);
405
  if (!data) return res.status(404).json({ error: "Player not found." });
406
 
407
+ let overviewQuery = supabaseAdmin.from("players")
408
  .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
409
  .order("fname", { ascending: true });
410
+ if (!subscription) overviewQuery = overviewQuery.eq("user_id", current.user.id);
411
+ const { data: overview, error: overviewError } = await overviewQuery;
412
  if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
413
+ let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at");
414
+ if (!subscription) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
415
+ const { data: sessions, error: sessionsError } = await sessionsQuery;
416
  if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
417
  const { data: actions, error: actionsError } = (sessions ?? []).length
418
  ? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
 
447
  const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
448
  return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
449
  });
450
+ let peersQuery = supabaseAdmin.from("players").select("player_id");
451
+ if (!subscription) peersQuery = peersQuery.eq("user_id", current.user.id);
452
+ const { data: peers, error: peersError } = await peersQuery;
453
  if (peersError) throw new Error(`Unable to load roster: ${peersError.message}`);
454
  const playerScores = new Map<string, number[]>();
455
  for (const action of actions ?? []) {
 
464
  const squadAverage = { byQuality: [], byAction: squadByAction };
465
  return res.json({
466
  playerId: player.player_id, name: [player.fname, player.lname].filter(Boolean).join(" "),
467
+ isOwner: player.user_id === current.user.id,
468
  age: ageFromBirthday(player.bdate),
469
  heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
470
  club: player.club ?? "", position: player.position ?? "", gender: player.gender ?? null,
 
482
  const current = await authenticatedUser(req, res);
483
  if (!current) return res.status(401).json({ error: "Authentication required." });
484
 
485
+ const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
486
+ let sessionsQuery = supabaseAdmin.from("sessions")
487
+ .select("session_id,player_id,user_id,name,path,created_at,status")
488
  .order("created_at", { ascending: false });
489
+ if (!subscription) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
490
+ const { data: sessions, error } = await sessionsQuery;
491
  if (error) throw new Error(`Unable to load sessions: ${error.message}`);
492
  const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
493
  const { data: players, error: playersError } = playerIds.length
 
510
  return res.json({ sessions: (sessions ?? []).map((row) => {
511
  const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
512
  return {
513
+ sessionId: row.session_id, isOwner: row.user_id === current.user.id, playerId: row.player_id ?? "", playerName: row.player_id ? playerNames.get(row.player_id) ?? "" : "",
514
  playerGender: row.player_id ? playerImages.get(row.player_id)?.gender ?? null : null,
515
  playerImagePath: row.player_id ? playerImages.get(row.player_id)?.imagePath ?? null : null,
516
  name: row.name, createdAt: Date.parse(row.created_at) || 0,
 
529
  if (!sessionId.success) return res.sendStatus(404);
530
 
531
  const { data: session, error: sessionError } = await supabaseAdmin.from("sessions")
532
+ .select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
533
  if (sessionError) throw new Error(`Unable to verify review session: ${sessionError.message}`);
534
  if (!session) return res.sendStatus(404);
535
+ if (session.user_id !== current.user.id && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
536
+ return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
537
+ }
538
 
539
  const { data: actions, error: actionsError } = await supabaseAdmin.from("actions")
540
  .select("action_id").eq("session_id", sessionId.data);
 
839
  async function proxyAnalysisRequest(req: Request, res: Response) {
840
  const current = await authenticatedUser(req, res);
841
  if (!current) return res.status(401).json({ error: "Authentication required." });
842
+ const resourcePath = req.path;
843
+ 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);
844
+ if (["GET", "HEAD"].includes(req.method) && sessionMatch) {
845
+ const subscribed = await activeSubscriptionFor(current.user.id, { refresh: true });
846
+ if (!subscribed) {
847
+ const { data: session, error } = await supabaseAdmin.from("sessions")
848
+ .select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
849
+ if (error) throw new Error(`Unable to verify session access: ${error.message}`);
850
+ if (!session) return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
851
+ }
852
+ } else if (sessionMatch) {
853
+ const { data: session, error } = await supabaseAdmin.from("sessions")
854
+ .select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
855
+ if (error) throw new Error(`Unable to verify session ownership: ${error.message}`);
856
+ if (!session) return res.status(403).json({ error: "Only the session owner can change or delete this session." });
857
+ }
858
  // Archived sessions and results remain available after expiry. Only starting
859
  // a new analysis is gated here; the internal begin-analysis route repeats
860
  // this check before creating its player/session rows.
src/subscriptions.ts CHANGED
@@ -46,7 +46,7 @@ async function loadActiveSubscription(userId: string) {
46
  if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
47
 
48
  const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
49
- .select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
50
  if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
51
  return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
52
  }
 
46
  if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
47
 
48
  const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
49
+ .select("subscription_id,plan,price,player_limit,monthly_session_limit").eq("subscription_id", assignment.subscription_id).maybeSingle();
50
  if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
51
  return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
52
  }