Shoraky commited on
Commit
9a6afb0
·
1 Parent(s): e70b90c

Add profile images and plan billing API

Browse files
Files changed (4) hide show
  1. README.md +7 -3
  2. src/profile-images.ts +152 -0
  3. src/server.ts +130 -12
  4. src/subscriptions.ts +2 -2
README.md CHANGED
@@ -43,7 +43,7 @@ Node health: `GET /health`. AI health is internal at `http://127.0.0.1:8000/heal
43
 
44
  Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
45
 
46
- Apply `../Database/migrations/20261016_node_analysis.sql` before analyses. It assumes the legacy actions table is empty. Apply `../Database/migrations/20261016_action_reviews.sql` to enable saving coach scores and per-action annotation notes. Secret-protected `/internal/analysis` routes authorize sessions and persist completion/failure.
47
 
48
  ## Supabase dashboard setup
49
 
@@ -102,10 +102,14 @@ Brevo credentials stay in Supabase SMTP settings and are not added to the browse
102
  - `GET /api/auth/me` — verifies the session and refreshes it when necessary.
103
  - `POST /api/auth/logout` — clears the session cookies.
104
  - `GET /api/auth/profile` and `PATCH /api/auth/profile` — read and update the signed-in user's profile.
 
 
 
 
105
  - `POST /api/auth/password` — change or set the signed-in user's password.
106
  - `DELETE /api/auth/account` — delete the signed-in user's Supabase Auth account and profile row.
107
 
108
- To allow profiles with no selected role, apply `Database/migrations/20261015_user_role_optional.sql` to the Supabase database once. New accounts start with a `NULL` role and academy; users can complete both from `/labs/profile`.
109
 
110
  ## Subscription activation flow
111
 
@@ -131,4 +135,4 @@ The Pages proxy streams uploads and media without buffering. Cloudflare's reques
131
 
132
  `/api/auth/me` returns identity and subscription access together. The frontend keeps that UI snapshot in tab-scoped sessionStorage for 60 seconds, including across reloads, and shares concurrent checks. Logout, account deletion, and starting Google sign-in clear the snapshot. No access or refresh token is stored in browser storage; HttpOnly cookies and backend authorization remain authoritative.
133
 
134
- The backend caches subscription lookup results per user for at most 30 seconds (never beyond UTC midnight), with concurrent lookup deduplication and a 1,000-entry bound. Assigning a subscription through `assignPaidSubscription` invalidates that user's entry immediately. Direct database changes can take up to 30 seconds to affect backend authorization; the UI snapshot can take up to 60 seconds to refresh. Authentication is still verified for protected API requests.
 
43
 
44
  Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
45
 
46
+ The production database must have the analysis functions and action-review structures used by secret-protected `/internal/analysis` routes. The database also needs the profile columns shown in `../Database/schema.sql` and a private `profile-images` Supabase Storage bucket for JPG, PNG, and WEBP files up to 4 MB. The backend reads and writes images through its service-role client; browsers access them through authenticated API routes.
47
 
48
  ## Supabase dashboard setup
49
 
 
102
  - `GET /api/auth/me` — verifies the session and refreshes it when necessary.
103
  - `POST /api/auth/logout` — clears the session cookies.
104
  - `GET /api/auth/profile` and `PATCH /api/auth/profile` — read and update the signed-in user's profile.
105
+ - `GET`, `POST`, and `DELETE /api/auth/image` — view, upload, or remove the signed-in user's profile image. POST sends raw JPG, PNG, or WEBP bytes with the matching `Content-Type`.
106
+ - `GET`, `POST`, and `DELETE /api/players/:playerId/image` — view, upload, or remove a player profile image.
107
+ - `PUT /api/players/:playerId` — update a player's profile, including gender.
108
+ - `GET /api/plan-billing` — current plan assignment, period and status, plus the signed-in user's player and session counts. Billing history is unavailable until a payment provider is integrated.
109
  - `POST /api/auth/password` — change or set the signed-in user's password.
110
  - `DELETE /api/auth/account` — delete the signed-in user's Supabase Auth account and profile row.
111
 
112
+ New accounts start with a `NULL` role and academy; users can complete both from `/labs/profile`.
113
 
114
  ## Subscription activation flow
115
 
 
135
 
136
  `/api/auth/me` returns identity and subscription access together. The frontend keeps that UI snapshot in tab-scoped sessionStorage for 60 seconds, including across reloads, and shares concurrent checks. Logout, account deletion, and starting Google sign-in clear the snapshot. No access or refresh token is stored in browser storage; HttpOnly cookies and backend authorization remain authoritative.
137
 
138
+ The backend caches subscription lookup results per user for at most 30 seconds (never beyond UTC midnight), with concurrent lookup deduplication and a 1,000-entry bound. Assigning a subscription through `assignPaidSubscription` invalidates that user's entry immediately. The access-status endpoint bypasses that cache, and Labs polls it every 10 seconds while access is locked, so directly assigned plans unlock without another login. Protected API requests can use a cached result for up to 30 seconds; authentication is still verified for every request.
src/profile-images.ts ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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";
8
+ const MAX_BYTES = 4 * 1024 * 1024;
9
+ const uploadBody = express.raw({ type: ["image/jpeg", "image/png", "image/webp"], limit: MAX_BYTES });
10
+ const playerIdSchema = z.string().uuid();
11
+
12
+ type Owner = "players" | "users";
13
+ type ImageType = { mime: string; extension: string };
14
+
15
+ function inspectImage(bytes: Buffer): ImageType | null {
16
+ if (bytes.length < 12 || bytes.length > MAX_BYTES) return null;
17
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return { mime: "image/jpeg", extension: "jpg" };
18
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return { mime: "image/png", extension: "png" };
19
+ if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return { mime: "image/webp", extension: "webp" };
20
+ return null;
21
+ }
22
+
23
+ function safePreviousPath(owner: Owner, id: string, path: string | null | undefined) {
24
+ const prefix = `${owner}/${id}/`;
25
+ if (!path?.startsWith(prefix)) return null;
26
+ const filename = path.slice(prefix.length);
27
+ return /^[0-9a-f-]{36}\.(jpg|png|webp)$/.test(filename) ? path : null;
28
+ }
29
+
30
+ async function removeStoredImage(path: string) {
31
+ const { error } = await supabaseAdmin.storage.from(BUCKET).remove([path]);
32
+ if (error) throw new Error(`Unable to remove profile image: ${error.message}`);
33
+ }
34
+
35
+ async function sendImage(res: Response, path: string | null | undefined) {
36
+ if (!path) return res.status(404).json({ error: "Profile image not found." });
37
+ const { data, error } = await supabaseAdmin.storage.from(BUCKET).download(path);
38
+ if (error || !data) return res.status(404).json({ error: "Profile image not found." });
39
+ const type = path.endsWith(".png") ? "image/png" : path.endsWith(".webp") ? "image/webp" : "image/jpeg";
40
+ res.set({
41
+ "Content-Type": type,
42
+ "Cache-Control": "private, max-age=3600",
43
+ "Cross-Origin-Resource-Policy": "cross-origin",
44
+ "X-Content-Type-Options": "nosniff",
45
+ });
46
+ return res.send(Buffer.from(await data.arrayBuffer()));
47
+ }
48
+
49
+ async function uploadImage(req: Request, res: Response, owner: Owner, id: string, previousPath: string | null | undefined) {
50
+ const bytes = req.body;
51
+ if (!Buffer.isBuffer(bytes)) return res.status(415).json({ error: "Choose a JPG, PNG or WEBP image." });
52
+ const type = inspectImage(bytes);
53
+ if (!type || req.headers["content-type"]?.split(";")[0] !== type.mime) {
54
+ return res.status(415).json({ error: "Choose a valid JPG, PNG or WEBP image up to 4 MB." });
55
+ }
56
+ const path = `${owner}/${id}/${randomUUID()}.${type.extension}`;
57
+ const { error: uploadError } = await supabaseAdmin.storage.from(BUCKET).upload(path, bytes, {
58
+ contentType: type.mime,
59
+ cacheControl: "3600",
60
+ upsert: false,
61
+ });
62
+ if (uploadError) throw new Error(`Unable to upload profile image: ${uploadError.message}`);
63
+
64
+ const table = owner === "players" ? "players" : "users";
65
+ const idColumn = owner === "players" ? "player_id" : "user_id";
66
+ const { data: saved, error: saveError } = await supabaseAdmin.from(table).update({ image_path: path }).eq(idColumn, id).select(idColumn).maybeSingle();
67
+ if (saveError || !saved) {
68
+ await removeStoredImage(path).catch((cleanupError) => console.error(cleanupError));
69
+ throw new Error(`Unable to save profile image: ${saveError?.message ?? "Profile not found."}`);
70
+ }
71
+ const oldPath = safePreviousPath(owner, id, previousPath);
72
+ if (oldPath) await removeStoredImage(oldPath).catch((cleanupError) => console.error(cleanupError));
73
+ return res.json({ imagePath: path });
74
+ }
75
+
76
+ async function deleteImage(res: Response, owner: Owner, id: string, previousPath: string | null | undefined) {
77
+ const table = owner === "players" ? "players" : "users";
78
+ const idColumn = owner === "players" ? "player_id" : "user_id";
79
+ const { data: saved, error } = await supabaseAdmin.from(table).update({ image_path: null }).eq(idColumn, id).select(idColumn).maybeSingle();
80
+ if (error || !saved) throw new Error(`Unable to remove profile image: ${error?.message ?? "Profile not found."}`);
81
+ const oldPath = safePreviousPath(owner, id, previousPath);
82
+ if (oldPath) await removeStoredImage(oldPath).catch((cleanupError) => console.error(cleanupError));
83
+ return res.json({ imagePath: null });
84
+ }
85
+
86
+ export function registerProfileImageRoutes(app: Express) {
87
+ app.get("/api/auth/image", async (req, res, next) => {
88
+ try {
89
+ const current = await authenticatedUser(req, res);
90
+ if (!current) return res.status(401).json({ error: "Authentication required." });
91
+ const { data, error } = await supabaseAdmin.from("users").select("image_path").eq("user_id", current.user.id).maybeSingle();
92
+ if (error) throw new Error(`Unable to load user image: ${error.message}`);
93
+ return await sendImage(res, safePreviousPath("users", current.user.id, data?.image_path));
94
+ } catch (error) { next(error); }
95
+ });
96
+
97
+ app.post("/api/auth/image", uploadBody, async (req, res, next) => {
98
+ try {
99
+ const current = await authenticatedUser(req, res);
100
+ if (!current) return res.status(401).json({ error: "Authentication required." });
101
+ const { data, error } = await supabaseAdmin.from("users").select("image_path").eq("user_id", current.user.id).maybeSingle();
102
+ if (error || !data) return res.status(404).json({ error: "User profile not found." });
103
+ return await uploadImage(req, res, "users", current.user.id, data.image_path);
104
+ } catch (error) { next(error); }
105
+ });
106
+
107
+ app.delete("/api/auth/image", async (req, res, next) => {
108
+ try {
109
+ const current = await authenticatedUser(req, res);
110
+ if (!current) return res.status(401).json({ error: "Authentication required." });
111
+ const { data, error } = await supabaseAdmin.from("users").select("image_path").eq("user_id", current.user.id).maybeSingle();
112
+ if (error || !data) return res.status(404).json({ error: "User profile not found." });
113
+ return await deleteImage(res, "users", current.user.id, data.image_path);
114
+ } catch (error) { next(error); }
115
+ });
116
+
117
+ app.get("/api/players/:playerId/image", async (req, res, next) => {
118
+ try {
119
+ const current = await authenticatedUser(req, res);
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
+ });
128
+
129
+ app.post("/api/players/:playerId/image", uploadBody, async (req, res, next) => {
130
+ try {
131
+ const current = await authenticatedUser(req, res);
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); }
139
+ });
140
+
141
+ app.delete("/api/players/:playerId/image", async (req, res, next) => {
142
+ try {
143
+ const current = await authenticatedUser(req, res);
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); }
151
+ });
152
+ }
src/server.ts CHANGED
@@ -11,6 +11,7 @@ import { authPublicOrigin, env } from "./config.js";
11
  import { analysisRouter } from "./analysis.js";
12
  import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
 
14
 
15
  const app = express();
16
  app.disable("x-powered-by");
@@ -67,6 +68,28 @@ const actionReviewSchema = z.object({
67
  coachScore: z.number().int().min(0).max(10).nullable(),
68
  feedback: z.array(z.string().trim().min(1).max(2000)).max(30),
69
  }).strict();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  // Stored coach ratings use a 0–10 scale. Player dashboards use a 0–100 scale.
72
  const meanPlayerScore = (values: number[]) => values.length
@@ -122,11 +145,91 @@ app.get("/api/subscription/access", async (req, res, next) => {
122
  try {
123
  const current = await authenticatedUser(req, res);
124
  if (!current) return res.status(401).json({ error: "Authentication required." });
125
- const subscription = await activeSubscriptionFor(current.user.id);
126
  return res.json({ active: Boolean(subscription), subscription });
127
  } catch (error) { next(error); }
128
  });
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  // Players and sessions are currently a shared workspace: all signed-in users
131
  // can read the common catalogue. Revisit these read scopes if per-user data is
132
  // introduced later. The service-role client bypasses RLS, so keep this explicit.
@@ -136,7 +239,7 @@ app.get("/api/players-overview", async (req, res, next) => {
136
  if (!current) return res.status(401).json({ error: "Authentication required." });
137
 
138
  const { data: rows, error } = await supabaseAdmin.from("players")
139
- .select("player_id,user_id,fname,lname,bdate,height,weight,position,club")
140
  .order("fname", { ascending: true });
141
  if (error) throw new Error(`Unable to load players: ${error.message}`);
142
 
@@ -180,10 +283,11 @@ app.get("/api/players-overview", async (req, res, next) => {
180
  return {
181
  playerId: row.player_id,
182
  name: [row.fname, row.lname].filter(Boolean).join(" "),
183
- age: row.bdate ? String(Math.max(0, new Date().getFullYear() - new Date(row.bdate).getFullYear())) : "",
184
  heightCm: row.height == null ? "" : String(row.height),
185
  weightKg: row.weight == null ? "" : String(row.weight),
186
- club: row.club ?? "", position: row.position ?? "", dominantFoot: "", notes: "", avatarPath: "",
 
187
  createdAt: "", updatedAt: "", hasProfile: true,
188
  sessionCount: playerSessions.length, actionCount: playerActions.length,
189
  lastSessionAt: playerScoreSessions[0]?.createdAt ?? 0,
@@ -212,7 +316,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
212
  if (!data) return res.status(404).json({ error: "Player not found." });
213
 
214
  const { data: overview, error: overviewError } = await supabaseAdmin.from("players")
215
- .select("player_id,user_id,fname,lname,bdate,height,weight,position,club")
216
  .order("fname", { ascending: true });
217
  if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
218
  const { data: sessions, error: sessionsError } = await supabaseAdmin.from("sessions")
@@ -266,9 +370,10 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
266
  const squadAverage = { byQuality: [], byAction: squadByAction };
267
  return res.json({
268
  playerId: player.player_id, name: [player.fname, player.lname].filter(Boolean).join(" "),
269
- age: player.bdate ? String(Math.max(0, new Date().getFullYear() - new Date(player.bdate).getFullYear())) : "",
270
  heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
271
- club: player.club ?? "", position: player.position ?? "", dominantFoot: "", notes: "", avatarPath: "",
 
272
  createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
273
  lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
274
  scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
@@ -288,7 +393,7 @@ app.get("/api/sessions-overview", async (req, res, next) => {
288
  if (error) throw new Error(`Unable to load sessions: ${error.message}`);
289
  const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
290
  const { data: players, error: playersError } = playerIds.length
291
- ? await supabaseAdmin.from("players").select("player_id,fname,lname").in("player_id", playerIds)
292
  : { data: [], error: null };
293
  if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
294
  const sessionIds = (sessions ?? []).map((row) => row.session_id);
@@ -297,6 +402,7 @@ app.get("/api/sessions-overview", async (req, res, next) => {
297
  : { data: [], error: null };
298
  if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
299
  const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
 
300
  const breakdownBySession = new Map<string, Record<string, number>>();
301
  for (const action of actions ?? []) {
302
  const breakdown = breakdownBySession.get(action.session_id) ?? {};
@@ -307,6 +413,8 @@ app.get("/api/sessions-overview", async (req, res, next) => {
307
  const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
308
  return {
309
  sessionId: row.session_id, playerId: row.player_id ?? "", playerName: row.player_id ? playerNames.get(row.player_id) ?? "" : "",
 
 
310
  name: row.name, createdAt: Date.parse(row.created_at) || 0,
311
  actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
312
  cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
@@ -550,12 +658,12 @@ app.get("/api/auth/profile", async (req, res, next) => {
550
  if (!current) return res.status(401).json({ error: "Authentication required." });
551
  await syncProfile(current.user);
552
  const { data, error } = await supabaseAdmin.from("users")
553
- .select("fname,lname,email,academy,role")
554
  .eq("user_id", current.user.id)
555
  .single();
556
  if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
557
  const hasPassword = userHasPassword(current.user);
558
- return res.json({ profile: { ...data, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
559
  } catch (error) { next(error); }
560
  });
561
 
@@ -568,7 +676,7 @@ app.patch("/api/auth/profile", async (req, res, next) => {
568
  const { data, error } = await supabaseAdmin.from("users")
569
  .update(profile)
570
  .eq("user_id", current.user.id)
571
- .select("fname,lname,email,academy,role")
572
  .single();
573
  if (error || !data) throw new Error(`Unable to save user profile: ${error?.message ?? "Profile not found"}`);
574
 
@@ -577,7 +685,7 @@ app.patch("/api/auth/profile", async (req, res, next) => {
577
  user_metadata: { ...current.user.user_metadata, ...profile },
578
  });
579
  if (metadataError) console.error("Unable to sync profile metadata", { code: metadataError.code, message: metadataError.message });
580
- return res.json({ profile: { ...data, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword: userHasPassword(current.user) } });
581
  } catch (error) { next(error); }
582
  });
583
 
@@ -610,12 +718,19 @@ app.delete("/api/auth/account", async (req, res, next) => {
610
  const current = await authenticatedUser(req, res);
611
  if (!current) return res.status(401).json({ error: "Authentication required." });
612
  if (req.body?.confirmation !== "DELETE") return res.status(400).json({ error: "Confirm account deletion by typing DELETE." });
 
 
 
613
  const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(current.user.id);
614
  if (authError) return res.status(500).json({ error: "Unable to delete your account. Please try again." });
615
  const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", current.user.id);
616
  clearSessionCookies(res);
617
  clearPasswordRecoveryCookie(res);
618
  if (profileError) throw new Error(`Account deleted but profile cleanup failed: ${profileError.message}`);
 
 
 
 
619
  return res.status(204).send();
620
  } catch (error) { next(error); }
621
  });
@@ -681,6 +796,9 @@ app.use((_req, res) => res.status(404).json({ error: "Route not found." }));
681
  app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
682
  console.error(error);
683
  if (error instanceof z.ZodError) return res.status(400).json({ error: "Please check the submitted fields." });
 
 
 
684
  return res.status(500).json({ error: "Something went wrong. Please try again." });
685
  });
686
 
 
11
  import { analysisRouter } from "./analysis.js";
12
  import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
14
+ import { registerProfileImageRoutes } from "./profile-images.js";
15
 
16
  const app = express();
17
  app.disable("x-powered-by");
 
68
  coachScore: z.number().int().min(0).max(10).nullable(),
69
  feedback: z.array(z.string().trim().min(1).max(2000)).max(30),
70
  }).strict();
71
+ const playerProfileSchema = z.object({
72
+ name: z.string().trim().min(1).max(200),
73
+ age: z.string().regex(/^(|\d{1,2})$/),
74
+ heightCm: z.string().regex(/^(|\d{2,3}(?:\.\d{1,2})?)$/),
75
+ weightKg: z.string().regex(/^(|\d{1,3}(?:\.\d{1,2})?)$/),
76
+ club: z.string().trim().max(150),
77
+ position: z.string().trim().max(50),
78
+ gender: z.union([z.enum(["male", "female"]), z.literal("")]),
79
+ dominantFoot: z.union([z.enum(["left", "right", "both"]), z.literal("")]),
80
+ notes: z.string().trim().max(5000),
81
+ }).strict();
82
+
83
+ function ageFromBirthday(birthday: string | null): string {
84
+ if (!birthday) return "";
85
+ const today = new Date();
86
+ const [year, month, day] = birthday.slice(0, 10).split("-").map(Number);
87
+ if (!year || !month || !day) return "";
88
+ const beforeBirthday = today.getUTCMonth() + 1 < month || (today.getUTCMonth() + 1 === month && today.getUTCDate() < day);
89
+ return String(Math.max(0, today.getUTCFullYear() - year - Number(beforeBirthday)));
90
+ }
91
+
92
+ registerProfileImageRoutes(app);
93
 
94
  // Stored coach ratings use a 0–10 scale. Player dashboards use a 0–100 scale.
95
  const meanPlayerScore = (values: number[]) => values.length
 
145
  try {
146
  const current = await authenticatedUser(req, res);
147
  if (!current) return res.status(401).json({ error: "Authentication required." });
148
+ const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
149
  return res.json({ active: Boolean(subscription), subscription });
150
  } catch (error) { next(error); }
151
  });
152
 
153
+ app.get("/api/plan-billing", async (req, res, next) => {
154
+ try {
155
+ const current = await authenticatedUser(req, res);
156
+ if (!current) return res.status(401).json({ error: "Authentication required." });
157
+ const userId = current.user.id;
158
+ const [assignmentResult, playersResult, sessionsResult] = await Promise.all([
159
+ supabaseAdmin.from("users_subscriptions").select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle(),
160
+ supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }).eq("user_id", userId),
161
+ supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }).eq("user_id", userId),
162
+ ]);
163
+ if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
164
+ if (playersResult.error) throw new Error(`Unable to count players: ${playersResult.error.message}`);
165
+ if (sessionsResult.error) throw new Error(`Unable to count sessions: ${sessionsResult.error.message}`);
166
+ const assignment = assignmentResult.data;
167
+ let subscription = null;
168
+ if (assignment?.subscription_id) {
169
+ const { data: plan, error } = await supabaseAdmin.from("subscriptions")
170
+ .select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
171
+ if (error) throw new Error(`Unable to load plan: ${error.message}`);
172
+ if (plan) {
173
+ const today = new Date().toISOString().slice(0, 10);
174
+ subscription = {
175
+ subscriptionId: plan.subscription_id,
176
+ name: plan.plan,
177
+ price: Number(plan.price),
178
+ startDate: assignment.startdate,
179
+ endDate: assignment.enddate,
180
+ status: today < assignment.startdate ? "scheduled" : today > assignment.enddate ? "expired" : "active",
181
+ };
182
+ }
183
+ }
184
+ res.set("Cache-Control", "no-store");
185
+ return res.json({ subscription, usage: { players: playersResult.count ?? 0, sessions: sessionsResult.count ?? 0 }, billingHistoryAvailable: false });
186
+ } catch (error) { next(error); }
187
+ });
188
+
189
+ app.put("/api/players/:playerId", async (req, res, next) => {
190
+ try {
191
+ const current = await authenticatedUser(req, res);
192
+ if (!current) return res.status(401).json({ error: "Authentication required." });
193
+ const id = z.string().uuid().safeParse(req.params.playerId);
194
+ if (!id.success) return res.status(404).json({ error: "Player not found." });
195
+ const input = playerProfileSchema.parse(req.body);
196
+ const age = input.age ? Number(input.age) : null;
197
+ const height = input.heightCm ? Number(input.heightCm) : null;
198
+ const weight = input.weightKg ? Number(input.weightKg) : null;
199
+ if ((age !== null && (age < 4 || age > 60)) || (height !== null && (height < 100 || height > 230)) || (weight !== null && (weight < 20 || weight > 150))) {
200
+ return res.status(400).json({ error: "Check the player's age, height and weight." });
201
+ }
202
+ const { data: existing, error: readError } = await supabaseAdmin.from("players")
203
+ .select("player_id,bdate").eq("player_id", id.data).maybeSingle();
204
+ if (readError) throw new Error(`Unable to load player: ${readError.message}`);
205
+ if (!existing) return res.status(404).json({ error: "Player not found." });
206
+ const [fname, ...lastParts] = input.name.split(/\s+/);
207
+ const lname = lastParts.join(" ");
208
+ if (fname.length > 100 || lname.length > 100) return res.status(400).json({ error: "Player first and last names must be 100 characters or fewer." });
209
+ let birthday = existing.bdate;
210
+ if (age === null) birthday = null;
211
+ else if (ageFromBirthday(existing.bdate) !== String(age)) {
212
+ const date = new Date();
213
+ date.setUTCFullYear(date.getUTCFullYear() - age);
214
+ birthday = date.toISOString().slice(0, 10);
215
+ }
216
+ const { error } = await supabaseAdmin.from("players").update({
217
+ fname,
218
+ lname,
219
+ bdate: birthday,
220
+ height,
221
+ weight,
222
+ club: input.club || null,
223
+ position: input.position || null,
224
+ gender: input.gender || null,
225
+ dominant_foot: input.dominantFoot || null,
226
+ notes: input.notes || null,
227
+ }).eq("player_id", id.data);
228
+ if (error) throw new Error(`Unable to update player: ${error.message}`);
229
+ return res.json({ ok: true });
230
+ } catch (error) { next(error); }
231
+ });
232
+
233
  // Players and sessions are currently a shared workspace: all signed-in users
234
  // can read the common catalogue. Revisit these read scopes if per-user data is
235
  // introduced later. The service-role client bypasses RLS, so keep this explicit.
 
239
  if (!current) return res.status(401).json({ error: "Authentication required." });
240
 
241
  const { data: rows, error } = await supabaseAdmin.from("players")
242
+ .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path,dominant_foot,notes")
243
  .order("fname", { ascending: true });
244
  if (error) throw new Error(`Unable to load players: ${error.message}`);
245
 
 
283
  return {
284
  playerId: row.player_id,
285
  name: [row.fname, row.lname].filter(Boolean).join(" "),
286
+ age: ageFromBirthday(row.bdate),
287
  heightCm: row.height == null ? "" : String(row.height),
288
  weightKg: row.weight == null ? "" : String(row.weight),
289
+ club: row.club ?? "", position: row.position ?? "", gender: row.gender ?? null,
290
+ dominantFoot: row.dominant_foot ?? "", notes: row.notes ?? "", imagePath: row.image_path ?? null,
291
  createdAt: "", updatedAt: "", hasProfile: true,
292
  sessionCount: playerSessions.length, actionCount: playerActions.length,
293
  lastSessionAt: playerScoreSessions[0]?.createdAt ?? 0,
 
316
  if (!data) return res.status(404).json({ error: "Player not found." });
317
 
318
  const { data: overview, error: overviewError } = await supabaseAdmin.from("players")
319
+ .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path,dominant_foot,notes")
320
  .order("fname", { ascending: true });
321
  if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
322
  const { data: sessions, error: sessionsError } = await supabaseAdmin.from("sessions")
 
370
  const squadAverage = { byQuality: [], byAction: squadByAction };
371
  return res.json({
372
  playerId: player.player_id, name: [player.fname, player.lname].filter(Boolean).join(" "),
373
+ age: ageFromBirthday(player.bdate),
374
  heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
375
+ club: player.club ?? "", position: player.position ?? "", gender: player.gender ?? null,
376
+ dominantFoot: player.dominant_foot ?? "", notes: player.notes ?? "", imagePath: player.image_path ?? null,
377
  createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
378
  lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
379
  scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
 
393
  if (error) throw new Error(`Unable to load sessions: ${error.message}`);
394
  const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
395
  const { data: players, error: playersError } = playerIds.length
396
+ ? await supabaseAdmin.from("players").select("player_id,fname,lname,gender,image_path").in("player_id", playerIds)
397
  : { data: [], error: null };
398
  if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
399
  const sessionIds = (sessions ?? []).map((row) => row.session_id);
 
402
  : { data: [], error: null };
403
  if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
404
  const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
405
+ const playerImages = new Map((players ?? []).map((row) => [row.player_id, { gender: row.gender, imagePath: row.image_path }]));
406
  const breakdownBySession = new Map<string, Record<string, number>>();
407
  for (const action of actions ?? []) {
408
  const breakdown = breakdownBySession.get(action.session_id) ?? {};
 
413
  const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
414
  return {
415
  sessionId: row.session_id, playerId: row.player_id ?? "", playerName: row.player_id ? playerNames.get(row.player_id) ?? "" : "",
416
+ playerGender: row.player_id ? playerImages.get(row.player_id)?.gender ?? null : null,
417
+ playerImagePath: row.player_id ? playerImages.get(row.player_id)?.imagePath ?? null : null,
418
  name: row.name, createdAt: Date.parse(row.created_at) || 0,
419
  actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
420
  cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
 
658
  if (!current) return res.status(401).json({ error: "Authentication required." });
659
  await syncProfile(current.user);
660
  const { data, error } = await supabaseAdmin.from("users")
661
+ .select("fname,lname,email,academy,role,image_path")
662
  .eq("user_id", current.user.id)
663
  .single();
664
  if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
665
  const hasPassword = userHasPassword(current.user);
666
+ return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
667
  } catch (error) { next(error); }
668
  });
669
 
 
676
  const { data, error } = await supabaseAdmin.from("users")
677
  .update(profile)
678
  .eq("user_id", current.user.id)
679
+ .select("fname,lname,email,academy,role,image_path")
680
  .single();
681
  if (error || !data) throw new Error(`Unable to save user profile: ${error?.message ?? "Profile not found"}`);
682
 
 
685
  user_metadata: { ...current.user.user_metadata, ...profile },
686
  });
687
  if (metadataError) console.error("Unable to sync profile metadata", { code: metadataError.code, message: metadataError.message });
688
+ return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword: userHasPassword(current.user) } });
689
  } catch (error) { next(error); }
690
  });
691
 
 
718
  const current = await authenticatedUser(req, res);
719
  if (!current) return res.status(401).json({ error: "Authentication required." });
720
  if (req.body?.confirmation !== "DELETE") return res.status(400).json({ error: "Confirm account deletion by typing DELETE." });
721
+ const { data: imageRow, error: imageReadError } = await supabaseAdmin.from("users")
722
+ .select("image_path").eq("user_id", current.user.id).maybeSingle();
723
+ if (imageReadError) throw new Error(`Unable to load account image: ${imageReadError.message}`);
724
  const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(current.user.id);
725
  if (authError) return res.status(500).json({ error: "Unable to delete your account. Please try again." });
726
  const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", current.user.id);
727
  clearSessionCookies(res);
728
  clearPasswordRecoveryCookie(res);
729
  if (profileError) throw new Error(`Account deleted but profile cleanup failed: ${profileError.message}`);
730
+ if (imageRow?.image_path?.startsWith(`users/${current.user.id}/`)) {
731
+ const { error: imageError } = await supabaseAdmin.storage.from("profile-images").remove([imageRow.image_path]);
732
+ if (imageError) console.error("Account deleted but image cleanup failed", imageError);
733
+ }
734
  return res.status(204).send();
735
  } catch (error) { next(error); }
736
  });
 
796
  app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
797
  console.error(error);
798
  if (error instanceof z.ZodError) return res.status(400).json({ error: "Please check the submitted fields." });
799
+ if (typeof error === "object" && error !== null && "type" in error && error.type === "entity.too.large") {
800
+ return res.status(413).json({ error: "Profile images must be 4 MB or smaller." });
801
+ }
802
  return res.status(500).json({ error: "Something went wrong. Please try again." });
803
  });
804
 
src/subscriptions.ts CHANGED
@@ -54,9 +54,9 @@ async function loadActiveSubscription(userId: string) {
54
  // Brief, per-user cache shared by polling, media and session requests.
55
  const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
56
  const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
57
- export async function activeSubscriptionFor(userId: string) {
58
  const cached = accessCache.get(userId);
59
- if (cached && cached.expires > Date.now()) return cached.value;
60
  const existing = accessRequests.get(userId);
61
  if (existing) return existing;
62
  const pending = loadActiveSubscription(userId).then((value) => {
 
54
  // Brief, per-user cache shared by polling, media and session requests.
55
  const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
56
  const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
57
+ export async function activeSubscriptionFor(userId: string, options: { refresh?: boolean } = {}) {
58
  const cached = accessCache.get(userId);
59
+ if (!options.refresh && cached && cached.expires > Date.now()) return cached.value;
60
  const existing = accessRequests.get(userId);
61
  if (existing) return existing;
62
  const pending = loadActiveSubscription(userId).then((value) => {