Shoraky commited on
Commit
d1ceac4
·
1 Parent(s): 8aa1d7f

Add admin management console and APIs

Browse files
Files changed (6) hide show
  1. app.py +56 -8
  2. src/admin.ts +343 -0
  3. src/analysis.ts +72 -20
  4. src/server.ts +52 -12
  5. src/subscriptions.ts +75 -72
  6. src/supabase.ts +2 -2
app.py CHANGED
@@ -53,11 +53,12 @@ def hub():
53
  return HfApi(token=os.environ["HF_TOKEN"])
54
 
55
 
56
- def backend(path, user_id, method="GET", body=None):
57
  for attempt in range(3):
58
  try:
59
  response = httpx.request(method, f'{os.environ["BACKEND_INTERNAL_URL"].rstrip("/")}/internal/analysis{path}',
60
- json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id})
 
61
  response.raise_for_status()
62
  return response.json() if response.content else None
63
  except (httpx.TransportError, httpx.HTTPStatusError) as error:
@@ -153,6 +154,7 @@ async def authenticate(request: Request, call_next):
153
  request.state.user_id = str(UUID(request.headers.get("x-sporalize-user-id", "")))
154
  except ValueError:
155
  return JSONResponse({"detail": "Invalid user identity"}, status_code=403)
 
156
  return await call_next(request)
157
 
158
 
@@ -580,9 +582,9 @@ def result_action_id(session_id, index):
580
  return f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
581
 
582
 
583
- def owned_session_row(session_id, user_id):
584
  row = backend(f"/{session_id}", user_id)
585
- if row.get("user_id") != user_id:
586
  raise HTTPException(404, "Session not found")
587
  return row
588
 
@@ -601,7 +603,7 @@ def upload_session_results(path_in_repo, payload, commit_message):
601
 
602
  @app.delete("/api/sessions/{session_id}/actions/{action_id}")
603
  def delete_action(session_id: UUID, action_id: UUID, request: Request):
604
- row = owned_session_row(session_id, request.state.user_id)
605
  if row.get("path") != f"sessions/{session_id}":
606
  raise HTTPException(409, "Session results are not available")
607
 
@@ -627,7 +629,7 @@ def delete_action(session_id: UUID, action_id: UUID, request: Request):
627
  updated_payload = json.dumps(json_value(updated_reports), allow_nan=False).encode("utf-8")
628
  upload_session_results(result_path, updated_payload, f"Delete action {action_id}")
629
  try:
630
- backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "DELETE")
631
  except Exception:
632
  try:
633
  upload_session_results(result_path, original_payload, f"Restore action {action_id} after delete failure")
@@ -637,9 +639,55 @@ def delete_action(session_id: UUID, action_id: UUID, request: Request):
637
  return {"ok": True}
638
 
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  @app.delete("/api/sessions/{session_id}")
641
  def delete_session(session_id: UUID, request: Request):
642
- row = owned_session_row(session_id, request.state.user_id)
643
  expected_path = f"sessions/{session_id}"
644
  storage_path = row.get("path")
645
  if storage_path is not None and storage_path != expected_path:
@@ -654,7 +702,7 @@ def delete_session(session_id: UUID, request: Request):
654
  path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
655
  )
656
 
657
- backend(f"/{session_id}", request.state.user_id, "DELETE")
658
  return {"ok": True}
659
 
660
 
 
53
  return HfApi(token=os.environ["HF_TOKEN"])
54
 
55
 
56
+ def backend(path, user_id, method="GET", body=None, is_admin=False):
57
  for attempt in range(3):
58
  try:
59
  response = httpx.request(method, f'{os.environ["BACKEND_INTERNAL_URL"].rstrip("/")}/internal/analysis{path}',
60
+ json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id,
61
+ **({"x-sporalize-admin": "true"} if is_admin else {})})
62
  response.raise_for_status()
63
  return response.json() if response.content else None
64
  except (httpx.TransportError, httpx.HTTPStatusError) as error:
 
154
  request.state.user_id = str(UUID(request.headers.get("x-sporalize-user-id", "")))
155
  except ValueError:
156
  return JSONResponse({"detail": "Invalid user identity"}, status_code=403)
157
+ request.state.is_admin = request.headers.get("x-sporalize-admin") == "true"
158
  return await call_next(request)
159
 
160
 
 
582
  return f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
583
 
584
 
585
+ def owned_session_row(session_id, user_id, is_admin=False):
586
  row = backend(f"/{session_id}", user_id)
587
+ if row.get("user_id") != user_id and not is_admin:
588
  raise HTTPException(404, "Session not found")
589
  return row
590
 
 
603
 
604
  @app.delete("/api/sessions/{session_id}/actions/{action_id}")
605
  def delete_action(session_id: UUID, action_id: UUID, request: Request):
606
+ row = owned_session_row(session_id, request.state.user_id, request.state.is_admin)
607
  if row.get("path") != f"sessions/{session_id}":
608
  raise HTTPException(409, "Session results are not available")
609
 
 
629
  updated_payload = json.dumps(json_value(updated_reports), allow_nan=False).encode("utf-8")
630
  upload_session_results(result_path, updated_payload, f"Delete action {action_id}")
631
  try:
632
+ backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "DELETE", is_admin=request.state.is_admin)
633
  except Exception:
634
  try:
635
  upload_session_results(result_path, original_payload, f"Restore action {action_id} after delete failure")
 
639
  return {"ok": True}
640
 
641
 
642
+ @app.patch("/api/sessions/{session_id}/actions/{action_id}")
643
+ def update_action(session_id: UUID, action_id: UUID, payload: dict, request: Request):
644
+ if not request.state.is_admin:
645
+ raise HTTPException(403, "Administrator access is required")
646
+ row = owned_session_row(session_id, request.state.user_id, True)
647
+ if row.get("path") != f"sessions/{session_id}":
648
+ raise HTTPException(409, "Session results are not available")
649
+
650
+ start_frame = payload.get("startFrame")
651
+ end_frame = payload.get("endFrame")
652
+ action_type = payload.get("type")
653
+ if not isinstance(start_frame, int) or isinstance(start_frame, bool) or start_frame < 0:
654
+ raise HTTPException(400, "Start frame must be a non-negative integer")
655
+ if not isinstance(end_frame, int) or isinstance(end_frame, bool) or end_frame < start_frame:
656
+ raise HTTPException(400, "End frame must be an integer greater than or equal to the start frame")
657
+ if not isinstance(action_type, str) or not action_type.strip() or len(action_type.strip()) > 50:
658
+ raise HTTPException(400, "Action type is invalid")
659
+
660
+ result_path = f'{row["path"]}/results.json'
661
+ downloaded_path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
662
+ filename=result_path, token=os.environ["HF_TOKEN"])
663
+ original_payload = Path(downloaded_path).read_bytes()
664
+ reports = json.loads(original_payload.decode("utf-8"))
665
+ matching_index = next((index for index, report in enumerate(reports)
666
+ if not report.get("error") and not report.get("_deleted")
667
+ and result_action_id(session_id, index) == str(action_id)), None)
668
+ if matching_index is None:
669
+ raise HTTPException(404, "Action not found")
670
+ reports[matching_index]["start_frame"] = start_frame
671
+ reports[matching_index]["end_frame"] = end_frame
672
+ reports[matching_index]["action"] = action_type.strip()
673
+ updated_payload = json.dumps(json_value(reports), allow_nan=False).encode("utf-8")
674
+ upload_session_results(result_path, updated_payload, f"Update action {action_id}")
675
+ try:
676
+ backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "PATCH", {
677
+ "startFrame": start_frame, "endFrame": end_frame, "type": action_type.strip(),
678
+ }, is_admin=True)
679
+ except Exception:
680
+ try:
681
+ upload_session_results(result_path, original_payload, f"Restore action {action_id} after update failure")
682
+ except Exception:
683
+ logger.exception("Could not restore session results after action update failed: session=%s action=%s", session_id, action_id)
684
+ raise
685
+ return {"ok": True}
686
+
687
+
688
  @app.delete("/api/sessions/{session_id}")
689
  def delete_session(session_id: UUID, request: Request):
690
+ row = owned_session_row(session_id, request.state.user_id, request.state.is_admin)
691
  expected_path = f"sessions/{session_id}"
692
  storage_path = row.get("path")
693
  if storage_path is not None and storage_path != expected_path:
 
702
  path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
703
  )
704
 
705
+ backend(f"/{session_id}", request.state.user_id, "DELETE", is_admin=request.state.is_admin)
706
  return {"ok": True}
707
 
708
 
src/admin.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Express, Request, Response, NextFunction } from "express";
2
+ import { z } from "zod";
3
+ import { env } from "./config.js";
4
+ import { supabaseAdmin } from "./supabase.js";
5
+ import { requireAdmin } from "./server.js";
6
+ import { invalidateSubscriptionCache } from "./subscriptions.js";
7
+
8
+ const uuid = z.uuid();
9
+ const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
10
+ const profileSchema = z.object({
11
+ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100),
12
+ academy: z.string().trim().max(150).nullable(), role: z.string().trim().max(50).nullable(),
13
+ }).strict();
14
+ const playerSchema = z.object({
15
+ name: z.string().trim().min(1).max(200), age: z.number().int().min(4).max(60).nullable().optional(),
16
+ heightCm: z.number().min(100).max(230).nullable().optional(), weightKg: z.number().min(20).max(150).nullable().optional(),
17
+ club: z.string().trim().max(150).nullable().optional(), position: z.string().trim().max(50).nullable().optional(),
18
+ gender: z.enum(["male", "female"]).nullable().optional(), userId: uuid.nullable().optional(),
19
+ }).strict();
20
+
21
+ function fail(res: Response, status: number, error: string) { return res.status(status).json({ error }); }
22
+ function asyncRoute(handler: (req: Request, res: Response) => Promise<unknown>) {
23
+ return (req: Request, res: Response, next: NextFunction) => { void handler(req, res).catch(next); };
24
+ }
25
+ async function admin(req: Request, res: Response) { return requireAdmin(req, res); }
26
+ function playerName(name: string) {
27
+ const [fname, ...parts] = name.split(/\s+/);
28
+ return { fname, lname: parts.join(" ") };
29
+ }
30
+ function ageDate(age?: number | null) {
31
+ if (age == null) return null;
32
+ const birth = new Date();
33
+ birth.setUTCFullYear(birth.getUTCFullYear() - age);
34
+ return birth.toISOString().slice(0, 10);
35
+ }
36
+
37
+ async function analysisRequest(path: string, userId: string, method: string, isAdmin = true) {
38
+ const response = await fetch(`${env.ANALYSIS_API_URL}/api${path}`, {
39
+ method,
40
+ headers: {
41
+ "x-sporalize-proxy-secret": env.ANALYSIS_PROXY_SECRET,
42
+ "x-sporalize-user-id": userId,
43
+ ...(isAdmin ? { "x-sporalize-admin": "true" } : {}),
44
+ },
45
+ });
46
+ if (!response.ok) throw new Error(`Analysis storage request failed (${response.status}).`);
47
+ return response;
48
+ }
49
+
50
+ async function removeImages(paths: Array<string | null | undefined>) {
51
+ const safe = [...new Set(paths.filter((path): path is string => Boolean(path) && /^(players|users)\/[0-9a-f-]{36}\/[0-9a-f-]{36}\.(jpg|png|webp)$/i.test(path!)))];
52
+ if (!safe.length) return;
53
+ const { error } = await supabaseAdmin.storage.from("profile-images").remove(safe);
54
+ if (error) throw new Error(`Unable to remove profile images: ${error.message}`);
55
+ }
56
+
57
+ export function registerAdminRoutes(app: Express) {
58
+ app.get("/api/admin/users", asyncRoute(async (req, res) => {
59
+ if (!await admin(req, res)) return;
60
+ const search = String(req.query.search ?? "").replace(/[^\p{L}\p{N}@._ -]/gu, "").trim().slice(0, 100);
61
+ let query = supabaseAdmin.from("users").select("user_id,fname,lname,email,academy,role,image_path,account_role").order("fname").limit(250);
62
+ if (search) query = query.or(`fname.ilike.%${search}%,lname.ilike.%${search}%,email.ilike.%${search}%`);
63
+ const { data, error } = await query;
64
+ if (error) throw new Error(`Unable to load user directory: ${error.message}`);
65
+ const ids = (data ?? []).map((row) => row.user_id);
66
+ const [subs, counts] = ids.length ? await Promise.all([
67
+ supabaseAdmin.from("users_subscriptions").select("user_id,subscription_id,startdate,enddate,num_players,num_sessions,num_sessions_month").in("user_id", ids),
68
+ supabaseAdmin.from("sessions").select("user_id").in("user_id", ids),
69
+ ]) : [{ data: [], error: null }, { data: [], error: null }];
70
+ if (subs.error) throw new Error(`Unable to load user plans: ${subs.error.message}`);
71
+ if (counts.error) throw new Error(`Unable to load user session counts: ${counts.error.message}`);
72
+ const planIds = [...new Set((subs.data ?? []).flatMap((row) => row.subscription_id ? [row.subscription_id] : []))];
73
+ const { data: plans, error: plansError } = planIds.length
74
+ ? await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,monthly_session_limit").in("subscription_id", planIds)
75
+ : { data: [], error: null };
76
+ if (plansError) throw new Error(`Unable to load plan names: ${plansError.message}`);
77
+ const planMap = new Map((plans ?? []).map((plan) => [plan.subscription_id, plan]));
78
+ const subMap = new Map((subs.data ?? []).map((row) => [row.user_id, row]));
79
+ const sessionCounts = new Map<string, number>();
80
+ for (const row of counts.data ?? []) if (row.user_id) sessionCounts.set(row.user_id, (sessionCounts.get(row.user_id) ?? 0) + 1);
81
+ return res.json({ users: (data ?? []).map((row) => {
82
+ const assignment = String(row.account_role).toLowerCase() === "admin" ? null : subMap.get(row.user_id);
83
+ const plan = assignment?.subscription_id ? planMap.get(assignment.subscription_id) : null;
84
+ return {
85
+ id: row.user_id, fname: row.fname, lname: row.lname, name: `${row.fname} ${row.lname}`.trim(),
86
+ email: row.email, academy: row.academy, role: row.role, imagePath: row.image_path, accountRole: row.account_role,
87
+ subscription: plan ? { id: plan.subscription_id, name: plan.plan, startDate: assignment?.startdate, endDate: assignment?.enddate, playerLimit: plan.player_limit, sessionLimit: plan.monthly_session_limit } : null,
88
+ usage: { players: assignment?.num_players ?? 0, sessions: assignment?.num_sessions_month ?? assignment?.num_sessions ?? 0, totalSessions: sessionCounts.get(row.user_id) ?? 0 },
89
+ };
90
+ }) });
91
+ }));
92
+
93
+ app.get("/api/admin/plans", asyncRoute(async (req, res) => {
94
+ if (!await admin(req, res)) return;
95
+ const { data, error } = await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,monthly_session_limit").order("price");
96
+ if (error) throw new Error(`Unable to load plans: ${error.message}`);
97
+ return res.json({ plans: data ?? [] });
98
+ }));
99
+
100
+ app.get("/api/admin/users/:userId", asyncRoute(async (req, res) => {
101
+ if (!await admin(req, res)) return;
102
+ const id = uuid.safeParse(req.params.userId); if (!id.success) return res.sendStatus(404);
103
+ const [{ data: user, error }, { data: assignment, error: subError }] = await Promise.all([
104
+ supabaseAdmin.from("users").select("user_id,fname,lname,email,academy,role,image_path,account_role").eq("user_id", id.data).maybeSingle(),
105
+ supabaseAdmin.from("users_subscriptions").select("subscription_id,startdate,enddate,num_players,num_sessions,num_sessions_month").eq("user_id", id.data).maybeSingle(),
106
+ ]);
107
+ if (error) throw new Error(`Unable to load user: ${error.message}`);
108
+ if (subError) throw new Error(`Unable to load user plan: ${subError.message}`);
109
+ if (!user) return res.sendStatus(404);
110
+ return res.json({ user, subscription: assignment ?? null });
111
+ }));
112
+
113
+ app.patch("/api/admin/users/:userId", asyncRoute(async (req, res) => {
114
+ if (!await admin(req, res)) return;
115
+ const id = uuid.safeParse(req.params.userId); const input = profileSchema.safeParse(req.body);
116
+ if (!id.success || !input.success) return fail(res, 400, "Check the profile details.");
117
+ const { data, error } = await supabaseAdmin.from("users").update(input.data).eq("user_id", id.data)
118
+ .select("user_id,fname,lname,email,academy,role,image_path,account_role").maybeSingle();
119
+ if (error) throw new Error(`Unable to update profile: ${error.message}`);
120
+ if (!data) return res.sendStatus(404);
121
+ return res.json({ user: data });
122
+ }));
123
+
124
+ app.put("/api/admin/users/:userId/subscription", asyncRoute(async (req, res) => {
125
+ if (!await admin(req, res)) return;
126
+ const id = uuid.safeParse(req.params.userId);
127
+ const input = z.object({ subscriptionId: uuid, startDate: date, endDate: date }).strict().safeParse(req.body);
128
+ if (!id.success || !input.success || input.data.endDate < input.data.startDate) return fail(res, 400, "Choose a valid plan period.");
129
+ const { data: account, error: accountError } = await supabaseAdmin.from("users").select("account_role").eq("user_id", id.data).maybeSingle();
130
+ if (accountError) throw new Error(`Unable to verify account: ${accountError.message}`);
131
+ if (!account) return res.sendStatus(404);
132
+ if (String(account.account_role).toLowerCase() === "admin") return fail(res, 409, "Administrator accounts do not use subscriptions.");
133
+ const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions").select("subscription_id").eq("subscription_id", input.data.subscriptionId).maybeSingle();
134
+ if (planError) throw new Error(`Unable to verify plan: ${planError.message}`);
135
+ if (!plan) return fail(res, 404, "Plan not found.");
136
+ const { data: row, error } = await supabaseAdmin.from("users_subscriptions").upsert({
137
+ user_id: id.data, subscription_id: plan.subscription_id, startdate: input.data.startDate, enddate: input.data.endDate,
138
+ num_players: 0, num_sessions: 0, num_sessions_month: 0,
139
+ }, { onConflict: "user_id" }).select("user_id,subscription_id,startdate,enddate,num_players,num_sessions,num_sessions_month").single();
140
+ if (error) throw new Error(`Unable to assign plan: ${error.message}`);
141
+ invalidateSubscriptionCache(id.data);
142
+ return res.json({ subscription: row });
143
+ }));
144
+
145
+ app.delete("/api/admin/users/:userId/subscription", asyncRoute(async (req, res) => {
146
+ if (!await admin(req, res)) return;
147
+ const id = uuid.safeParse(req.params.userId); if (!id.success) return res.sendStatus(404);
148
+ const { error } = await supabaseAdmin.from("users_subscriptions").update({ subscription_id: null, num_players: 0, num_sessions: 0, num_sessions_month: 0 })
149
+ .eq("user_id", id.data);
150
+ if (error) throw new Error(`Unable to cancel plan: ${error.message}`);
151
+ invalidateSubscriptionCache(id.data);
152
+ return res.sendStatus(204);
153
+ }));
154
+
155
+ app.get("/api/admin/players", asyncRoute(async (req, res) => {
156
+ if (!await admin(req, res)) return;
157
+ const [{ data: players, error }, { data: users, error: userError }] = await Promise.all([
158
+ supabaseAdmin.from("players").select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path").order("fname").limit(1000),
159
+ supabaseAdmin.from("users").select("user_id,fname,lname,email").order("fname").limit(1000),
160
+ ]);
161
+ if (error) throw new Error(`Unable to load players: ${error.message}`);
162
+ if (userError) throw new Error(`Unable to load owners: ${userError.message}`);
163
+ const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
164
+ return res.json({ players: (players ?? []).map((player) => {
165
+ const owner = player.user_id ? owners.get(player.user_id) : null;
166
+ return { ...player, owner: owner ? `${owner.fname} ${owner.lname}`.trim() || owner.email : null };
167
+ }), users: users ?? [] });
168
+ }));
169
+
170
+ app.post("/api/admin/players", asyncRoute(async (req, res) => {
171
+ if (!await admin(req, res)) return;
172
+ const input = playerSchema.safeParse(req.body); if (!input.success) return fail(res, 400, "Check the player details.");
173
+ if (input.data.userId) {
174
+ const { data, error } = await supabaseAdmin.from("users").select("user_id").eq("user_id", input.data.userId).maybeSingle();
175
+ if (error) throw new Error(`Unable to verify player owner: ${error.message}`);
176
+ if (!data) return fail(res, 404, "Player owner not found.");
177
+ }
178
+ const { fname, lname } = playerName(input.data.name);
179
+ const { data, error } = await supabaseAdmin.from("players").insert({
180
+ user_id: input.data.userId ?? null, fname, lname, bdate: ageDate(input.data.age), height: input.data.heightCm ?? null,
181
+ weight: input.data.weightKg ?? null, club: input.data.club || null, position: input.data.position || null, gender: input.data.gender ?? null,
182
+ }).select("player_id,user_id,fname,lname,position,club,gender,image_path").single();
183
+ if (error) throw new Error(`Unable to add player: ${error.message}`);
184
+ return res.status(201).json({ player: data });
185
+ }));
186
+
187
+ app.patch("/api/admin/players/:playerId", asyncRoute(async (req, res) => {
188
+ if (!await admin(req, res)) return;
189
+ const id = uuid.safeParse(req.params.playerId);
190
+ const input = z.object({ userId: uuid.nullable() }).strict().safeParse(req.body);
191
+ if (!id.success || !input.success) return fail(res, 400, "Choose a valid owner.");
192
+ if (input.data.userId) {
193
+ const { data, error } = await supabaseAdmin.from("users").select("user_id").eq("user_id", input.data.userId).maybeSingle();
194
+ if (error) throw new Error(`Unable to verify player owner: ${error.message}`);
195
+ if (!data) return fail(res, 404, "Player owner not found.");
196
+ }
197
+ const { data, error } = await supabaseAdmin.from("players").update({ user_id: input.data.userId }).eq("player_id", id.data).select("player_id,user_id").maybeSingle();
198
+ if (error) throw new Error(`Unable to transfer player: ${error.message}`);
199
+ if (!data) return res.sendStatus(404);
200
+ return res.json({ player: data });
201
+ }));
202
+
203
+ app.delete("/api/admin/players/:playerId", asyncRoute(async (req, res) => {
204
+ if (!await admin(req, res)) return;
205
+ const id = uuid.safeParse(req.params.playerId); if (!id.success) return res.sendStatus(404);
206
+ const { data: player, error: readError } = await supabaseAdmin.from("players").select("player_id,image_path").eq("player_id", id.data).maybeSingle();
207
+ if (readError) throw new Error(`Unable to load player: ${readError.message}`);
208
+ if (!player) return res.sendStatus(404);
209
+ const { error } = await supabaseAdmin.from("players").delete().eq("player_id", id.data);
210
+ if (error) throw new Error(`Unable to delete player: ${error.message}`);
211
+ await removeImages([player.image_path]).catch((imageError) => console.error("Player was deleted but its profile image could not be removed", imageError));
212
+ return res.sendStatus(204);
213
+ }));
214
+
215
+ app.get("/api/admin/sessions", asyncRoute(async (req, res) => {
216
+ if (!await admin(req, res)) return;
217
+ const [{ data: sessions, error }, { data: users, error: usersError }, { data: players, error: playersError }] = await Promise.all([
218
+ supabaseAdmin.from("sessions").select("session_id,user_id,player_id,name,path,status,created_at").order("created_at", { ascending: false }).limit(1000),
219
+ supabaseAdmin.from("users").select("user_id,fname,lname,email").order("fname").limit(1000),
220
+ supabaseAdmin.from("players").select("player_id,fname,lname").order("fname").limit(1000),
221
+ ]);
222
+ if (error) throw new Error(`Unable to load sessions: ${error.message}`);
223
+ if (usersError) throw new Error(`Unable to load owners: ${usersError.message}`);
224
+ if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
225
+ const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
226
+ const roster = new Map((players ?? []).map((player) => [player.player_id, player]));
227
+ const ids = (sessions ?? []).map((session) => session.session_id);
228
+ const { data: actionRows, error: actionsError } = ids.length
229
+ ? await supabaseAdmin.from("actions").select("session_id").in("session_id", ids)
230
+ : { data: [], error: null };
231
+ if (actionsError) throw new Error(`Unable to load session action counts: ${actionsError.message}`);
232
+ const actionCounts = new Map<string, number>();
233
+ for (const action of actionRows ?? []) if (action.session_id) actionCounts.set(action.session_id, (actionCounts.get(action.session_id) ?? 0) + 1);
234
+ return res.json({ sessions: (sessions ?? []).map((session) => {
235
+ const owner = session.user_id ? owners.get(session.user_id) : null;
236
+ const player = session.player_id ? roster.get(session.player_id) : null;
237
+ return {
238
+ ...session,
239
+ owner: owner ? `${owner.fname} ${owner.lname}`.trim() || owner.email : null,
240
+ player: player ? `${player.fname} ${player.lname}`.trim() : null,
241
+ actionCount: actionCounts.get(session.session_id) ?? 0,
242
+ };
243
+ }), users: users ?? [], players: players ?? [] });
244
+ }));
245
+
246
+ app.patch("/api/admin/sessions/:sessionId", asyncRoute(async (req, res) => {
247
+ if (!await admin(req, res)) return;
248
+ const id = uuid.safeParse(req.params.sessionId);
249
+ const input = z.object({ userId: uuid.nullable() }).strict().safeParse(req.body);
250
+ if (!id.success || !input.success) return fail(res, 400, "Choose a valid session owner.");
251
+ if (input.data.userId) {
252
+ const { data, error } = await supabaseAdmin.from("users").select("user_id").eq("user_id", input.data.userId).maybeSingle();
253
+ if (error) throw new Error(`Unable to verify session owner: ${error.message}`);
254
+ if (!data) return fail(res, 404, "Session owner not found.");
255
+ }
256
+ const { data, error } = await supabaseAdmin.from("sessions").update({ user_id: input.data.userId }).eq("session_id", id.data).select("session_id,user_id").maybeSingle();
257
+ if (error) throw new Error(`Unable to transfer session: ${error.message}`);
258
+ if (!data) return res.sendStatus(404);
259
+ return res.json({ session: data });
260
+ }));
261
+
262
+ app.delete("/api/admin/sessions/:sessionId", asyncRoute(async (req, res) => {
263
+ const current = await admin(req, res); if (!current) return;
264
+ const id = uuid.safeParse(req.params.sessionId); if (!id.success) return res.sendStatus(404);
265
+ await analysisRequest(`/sessions/${id.data}`, current.user.id, "DELETE");
266
+ return res.sendStatus(204);
267
+ }));
268
+
269
+ app.get("/api/admin/sessions/:sessionId", asyncRoute(async (req, res) => {
270
+ if (!await admin(req, res)) return;
271
+ const id = uuid.safeParse(req.params.sessionId); if (!id.success) return res.sendStatus(404);
272
+ const { data: session, error } = await supabaseAdmin.from("sessions").select("session_id,user_id,player_id,name,path,status,created_at").eq("session_id", id.data).maybeSingle();
273
+ if (error) throw new Error(`Unable to load session: ${error.message}`);
274
+ if (!session) return res.sendStatus(404);
275
+ const { data: actions, error: actionsError } = await supabaseAdmin.from("actions").select("action_id,start_frame,end_frame,type").eq("session_id", id.data).order("start_frame");
276
+ if (actionsError) throw new Error(`Unable to load actions: ${actionsError.message}`);
277
+ const actionIds = (actions ?? []).map((action) => action.action_id);
278
+ const [{ data: annotations, error: annotationError }, { data: notes, error: notesError }] = actionIds.length ? await Promise.all([
279
+ supabaseAdmin.from("annotations").select("action_id,score,user_id").in("action_id", actionIds),
280
+ supabaseAdmin.from("annotations_notes").select("action_id,note").in("action_id", actionIds),
281
+ ]) : [{ data: [], error: null }, { data: [], error: null }];
282
+ if (annotationError) throw new Error(`Unable to load action annotations: ${annotationError.message}`);
283
+ if (notesError) throw new Error(`Unable to load action notes: ${notesError.message}`);
284
+ const annotationsById = new Map((annotations ?? []).map((row) => [row.action_id, row]));
285
+ const notesById = new Map<string, string[]>();
286
+ for (const row of notes ?? []) notesById.set(row.action_id, [...(notesById.get(row.action_id) ?? []), row.note]);
287
+ return res.json({ session, actions: (actions ?? []).map((action) => ({ ...action, score: annotationsById.get(action.action_id)?.score ?? null, notes: notesById.get(action.action_id) ?? [] })) });
288
+ }));
289
+
290
+ app.patch("/api/admin/sessions/:sessionId/actions/:actionId", asyncRoute(async (req, res) => {
291
+ const current = await admin(req, res); if (!current) return;
292
+ const sessionId = uuid.safeParse(req.params.sessionId); const actionId = uuid.safeParse(req.params.actionId);
293
+ const input = z.object({ startFrame: z.number().int().nonnegative(), endFrame: z.number().int().nonnegative(), type: z.string().trim().min(1).max(50), score: z.number().min(0).max(10).nullable(), notes: z.array(z.string().trim().min(1).max(2000)).max(30) }).strict().safeParse(req.body);
294
+ if (!sessionId.success || !actionId.success || !input.success || input.data.endFrame < input.data.startFrame) return fail(res, 400, "Check the action review and frame interval.");
295
+ const notes = [...new Set(input.data.notes)];
296
+ const remote = await analysisRequest(`/sessions/${sessionId.data}/actions/${actionId.data}`, current.user.id, "PATCH");
297
+ const remoteResult = await remote.json().catch(() => ({}));
298
+ if (!remote.ok) return fail(res, remote.status, (remoteResult as { detail?: string }).detail ?? "Unable to update action data.");
299
+ const { data: existing, error: readError } = await supabaseAdmin.from("annotations").select("user_id").eq("action_id", actionId.data).maybeSingle();
300
+ if (readError) throw new Error(`Unable to load action review: ${readError.message}`);
301
+ if (input.data.score !== null || notes.length) {
302
+ const { error } = await supabaseAdmin.from("annotations").upsert({ action_id: actionId.data, user_id: existing?.user_id ?? current.user.id, score: input.data.score }, { onConflict: "action_id" });
303
+ if (error) throw new Error(`Unable to save action review: ${error.message}`);
304
+ } else {
305
+ const { error } = await supabaseAdmin.from("annotations").delete().eq("action_id", actionId.data);
306
+ if (error) throw new Error(`Unable to remove action review: ${error.message}`);
307
+ return res.json({ ok: true });
308
+ }
309
+ const { error: clearError } = await supabaseAdmin.from("annotations_notes").delete().eq("action_id", actionId.data);
310
+ if (clearError) throw new Error(`Unable to replace action notes: ${clearError.message}`);
311
+ if (notes.length) {
312
+ const { error } = await supabaseAdmin.from("annotations_notes").upsert(notes.map((note) => ({ action_id: actionId.data, note })), { onConflict: "action_id,note", ignoreDuplicates: true });
313
+ if (error) throw new Error(`Unable to save action notes: ${error.message}`);
314
+ }
315
+ return res.json({ ok: true });
316
+ }));
317
+
318
+ app.delete("/api/admin/users/:userId", asyncRoute(async (req, res) => {
319
+ const current = await admin(req, res); if (!current) return;
320
+ const id = uuid.safeParse(req.params.userId); if (!id.success) return res.sendStatus(404);
321
+ if (id.data === current.user.id) return fail(res, 400, "You cannot delete your own administrator account.");
322
+ const { data: target, error } = await supabaseAdmin.from("users").select("user_id,account_role,image_path").eq("user_id", id.data).maybeSingle();
323
+ if (error) throw new Error(`Unable to load account: ${error.message}`);
324
+ if (!target) return res.sendStatus(404);
325
+ if (String(target.account_role).toLowerCase() === "admin") {
326
+ return fail(res, 409, "Administrator accounts cannot be deleted here. Change administrator access directly in the database first.");
327
+ }
328
+ const { data: sessions, error: sessionError } = await supabaseAdmin.from("sessions").select("session_id").eq("user_id", id.data);
329
+ if (sessionError) throw new Error(`Unable to load account sessions: ${sessionError.message}`);
330
+ for (const session of sessions ?? []) await analysisRequest(`/sessions/${session.session_id}`, current.user.id, "DELETE");
331
+ const { data: players, error: playersError } = await supabaseAdmin.from("players").select("player_id,image_path").eq("user_id", id.data);
332
+ if (playersError) throw new Error(`Unable to load account players: ${playersError.message}`);
333
+ const { error: playerDeleteError } = await supabaseAdmin.from("players").delete().eq("user_id", id.data);
334
+ if (playerDeleteError) throw new Error(`Unable to remove account players: ${playerDeleteError.message}`);
335
+ const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(id.data);
336
+ if (authError) throw new Error(`Unable to remove authentication account: ${authError.message}`);
337
+ const { error: profileDeleteError } = await supabaseAdmin.from("users").delete().eq("user_id", id.data);
338
+ if (profileDeleteError) throw new Error(`Account authentication was removed but profile cleanup failed: ${profileDeleteError.message}`);
339
+ await removeImages([target.image_path, ...(players ?? []).map((player) => player.image_path)])
340
+ .catch((imageError) => console.error("Account was deleted but some profile images could not be removed", imageError));
341
+ return res.sendStatus(204);
342
+ }));
343
+ }
src/analysis.ts CHANGED
@@ -39,6 +39,7 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
39
  const user = z.uuid().safeParse(req.header("x-sporalize-user-id"));
40
  if (!user.success) return res.sendStatus(403);
41
  res.locals.userId = user.data;
 
42
  next();
43
  });
44
  router.use(express.json({ limit: "2mb" }));
@@ -46,25 +47,38 @@ 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
- 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).eq("user_id", userId).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 your own 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,
@@ -98,8 +112,9 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
98
  const sessionId = z.uuid().parse(req.params.sessionId);
99
  const actionId = z.uuid().parse(req.params.actionId);
100
  const userId = res.locals.userId as string;
101
- const { data: session, error: sessionError } = await db.from("sessions")
102
- .select("session_id").eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
 
103
  if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
104
  if (!session) return res.status(404).json({ error: "Session not found." });
105
 
@@ -110,12 +125,32 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
110
  return res.sendStatus(204);
111
  } catch (error) { next(error); }
112
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  router.delete("/:sessionId", async (req, res, next) => {
114
  try {
115
  const sessionId = z.uuid().parse(req.params.sessionId);
116
  const userId = res.locals.userId as string;
117
- const { data: session, error: sessionError } = await db.from("sessions")
118
- .select("session_id").eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
 
119
  if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
120
  if (!session) return res.status(404).json({ error: "Session not found." });
121
 
@@ -123,8 +158,9 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
123
  // delete them first. Their annotations and feedback cascade from actions.
124
  const { error: actionsError } = await db.from("actions").delete().eq("session_id", sessionId);
125
  if (actionsError) return res.status(503).json({ error: "Unable to remove session actions." });
126
- const { data: deleted, error } = await db.from("sessions").delete()
127
- .eq("session_id", sessionId).eq("user_id", userId).select("session_id").maybeSingle();
 
128
  if (error) return res.status(503).json({ error: "Unable to delete session." });
129
  if (!deleted) return res.status(404).json({ error: "Session not found." });
130
  return res.sendStatus(204);
@@ -138,6 +174,22 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
138
  let actions;
139
  try { actions = input.status === "completed" ? actionRows(sessionId, input.actions) : []; }
140
  catch { return res.status(400).json({ error: "Invalid action details" }); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  const { error } = await db.rpc("finish_analysis", {
142
  p_session_id: sessionId, p_user_id: res.locals.userId, p_status: input.status,
143
  p_path: input.status === "completed" ? input.path : null, p_actions: actions,
 
39
  const user = z.uuid().safeParse(req.header("x-sporalize-user-id"));
40
  if (!user.success) return res.sendStatus(403);
41
  res.locals.userId = user.data;
42
+ res.locals.isAdmin = req.header("x-sporalize-admin") === "true";
43
  next();
44
  });
45
  router.use(express.json({ limit: "2mb" }));
 
47
  try {
48
  const input = beginSchema.parse(req.body);
49
  const userId = res.locals.userId as string;
50
+ const isAdmin = res.locals.isAdmin === true;
51
+ const subscription = isAdmin ? null : await activeSubscriptionFor(userId, { refresh: true });
52
+ if (!isAdmin && !subscription) {
53
  return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
54
  }
55
+ if (!isAdmin) {
56
+ const { data: usage, error: usageError } = await db.from("users_subscriptions")
57
+ .select("num_sessions_month").eq("user_id", userId).maybeSingle();
58
+ if (usageError) return res.status(503).json({ error: "Unable to check your session quota." });
59
+ const usedSessions = Number(usage?.num_sessions_month ?? 0);
60
+ const sessionLimit = subscription?.monthly_session_limit == null ? null : Number(subscription.monthly_session_limit);
61
+ if (sessionLimit !== null && usedSessions >= sessionLimit) {
62
+ return res.status(403).json({
63
+ error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.",
64
+ code: "quota_exceeded", resource: "sessions", used: usedSessions, limit: sessionLimit,
65
+ });
66
+ }
67
  }
68
+ let playerQuery = db.from("players").select("player_id").eq("player_id", input.playerId);
69
+ if (!isAdmin) playerQuery = playerQuery.eq("user_id", userId);
70
+ const { data: player, error: playerError } = await playerQuery.maybeSingle();
71
  if (playerError) return res.status(503).json({ error: "Unable to verify the selected player." });
72
  if (!player) return res.status(404).json({ error: "Choose a player from your own roster before starting analysis." });
73
+ if (isAdmin) {
74
+ const { error } = await db.from("sessions").insert({
75
+ session_id: input.clientId, user_id: userId, player_id: input.playerId,
76
+ name: input.name || `Session ${input.clientId.slice(0, 8)}`,
77
+ path: `sessions/${input.clientId}`, status: "pending",
78
+ });
79
+ if (error) return res.status(503).json({ error: "Unable to prepare analysis session." });
80
+ return res.json({ session_id: input.clientId, player_id: input.playerId });
81
+ }
82
  const { data, error } = await db.rpc("begin_analysis", {
83
  p_session_id: input.clientId, p_user_id: userId,
84
  p_player_id: input.playerId,
 
112
  const sessionId = z.uuid().parse(req.params.sessionId);
113
  const actionId = z.uuid().parse(req.params.actionId);
114
  const userId = res.locals.userId as string;
115
+ let sessionQuery = db.from("sessions").select("session_id").eq("session_id", sessionId);
116
+ if (res.locals.isAdmin !== true) sessionQuery = sessionQuery.eq("user_id", userId);
117
+ const { data: session, error: sessionError } = await sessionQuery.maybeSingle();
118
  if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
119
  if (!session) return res.status(404).json({ error: "Session not found." });
120
 
 
125
  return res.sendStatus(204);
126
  } catch (error) { next(error); }
127
  });
128
+ router.patch("/:sessionId/actions/:actionId", async (req, res, next) => {
129
+ try {
130
+ if (res.locals.isAdmin !== true) return res.sendStatus(403);
131
+ const sessionId = z.uuid().parse(req.params.sessionId);
132
+ const actionId = z.uuid().parse(req.params.actionId);
133
+ const input = z.object({
134
+ startFrame: z.number().int().nonnegative(),
135
+ endFrame: z.number().int().nonnegative(),
136
+ type: z.string().trim().min(1).max(50),
137
+ }).strict().parse(req.body);
138
+ if (input.endFrame < input.startFrame) return res.status(400).json({ error: "Action end frame must be after its start frame." });
139
+ const { data, error } = await db.from("actions").update({
140
+ start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
141
+ }).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
142
+ if (error) return res.status(503).json({ error: "Unable to update action." });
143
+ if (!data) return res.sendStatus(404);
144
+ return res.json({ ok: true });
145
+ } catch (error) { next(error); }
146
+ });
147
  router.delete("/:sessionId", async (req, res, next) => {
148
  try {
149
  const sessionId = z.uuid().parse(req.params.sessionId);
150
  const userId = res.locals.userId as string;
151
+ let sessionQuery = db.from("sessions").select("session_id").eq("session_id", sessionId);
152
+ if (res.locals.isAdmin !== true) sessionQuery = sessionQuery.eq("user_id", userId);
153
+ const { data: session, error: sessionError } = await sessionQuery.maybeSingle();
154
  if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
155
  if (!session) return res.status(404).json({ error: "Session not found." });
156
 
 
158
  // delete them first. Their annotations and feedback cascade from actions.
159
  const { error: actionsError } = await db.from("actions").delete().eq("session_id", sessionId);
160
  if (actionsError) return res.status(503).json({ error: "Unable to remove session actions." });
161
+ let deleteQuery = db.from("sessions").delete().eq("session_id", sessionId);
162
+ if (res.locals.isAdmin !== true) deleteQuery = deleteQuery.eq("user_id", userId);
163
+ const { data: deleted, error } = await deleteQuery.select("session_id").maybeSingle();
164
  if (error) return res.status(503).json({ error: "Unable to delete session." });
165
  if (!deleted) return res.status(404).json({ error: "Session not found." });
166
  return res.sendStatus(204);
 
174
  let actions;
175
  try { actions = input.status === "completed" ? actionRows(sessionId, input.actions) : []; }
176
  catch { return res.status(400).json({ error: "Invalid action details" }); }
177
+ if (res.locals.isAdmin === true) {
178
+ const { data: session, error: sessionError } = await db.from("sessions").update({
179
+ status: input.status,
180
+ ...(input.status === "completed" ? { path: input.path } : {}),
181
+ }).eq("session_id", sessionId).eq("user_id", res.locals.userId).select("session_id").maybeSingle();
182
+ if (sessionError) return res.status(503).json({ error: "Unable to save analysis session." });
183
+ if (!session) return res.sendStatus(404);
184
+ if (input.status === "completed") {
185
+ const rows = actions.map((action) => ({ ...action, session_id: sessionId }));
186
+ if (rows.length) {
187
+ const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
188
+ if (error) return res.status(503).json({ error: "Unable to save analysis actions." });
189
+ }
190
+ }
191
+ return res.json({ ok: true });
192
+ }
193
  const { error } = await db.rpc("finish_analysis", {
194
  p_session_id: sessionId, p_user_id: res.locals.userId, p_status: input.status,
195
  p_path: input.status === "completed" ? input.path : null, p_actions: actions,
src/server.ts CHANGED
@@ -12,6 +12,7 @@ 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");
@@ -94,6 +95,29 @@ const meanPlayerScore = (values: number[]) => values.length
94
  ? values.reduce((sum, value) => sum + value, 0) / values.length * 10
95
  : null;
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  async function loadActionAnnotations(actionIds: string[]) {
98
  const annotations: Array<{ action_id: string; score: number | null }> = [];
99
  // Keep PostgREST query URLs small when a player has many analyzed actions.
@@ -114,6 +138,9 @@ async function requireActiveSubscription(req: Request, res: Response) {
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" });
@@ -122,6 +149,8 @@ async function requireActiveSubscription(req: Request, res: Response) {
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();
@@ -159,8 +188,9 @@ app.get("/api/subscription/access", async (req, res, next) => {
159
  try {
160
  const current = await authenticatedUser(req, res);
161
  if (!current) return res.status(401).json({ error: "Authentication required." });
162
- const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
163
- return res.json({ active: Boolean(subscription), subscription });
 
164
  } catch (error) { next(error); }
165
  });
166
 
@@ -169,6 +199,10 @@ app.get("/api/plan-billing", async (req, res, next) => {
169
  const current = await authenticatedUser(req, res);
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}`);
@@ -207,7 +241,8 @@ app.get("/api/owned-players", async (req, res, next) => {
207
  .order("fname", { ascending: true });
208
  if (error) throw new Error(`Unable to load players: ${error.message}`);
209
  // Analysis player choices stay scoped to the signed-in user's own roster.
210
- const ownedPlayers = (data ?? []).filter((player) => player.user_id === current.user.id);
 
211
  return res.json({ players: ownedPlayers.map((player) => ({
212
  playerId: player.player_id,
213
  name: [player.fname, player.lname].filter(Boolean).join(" "),
@@ -523,7 +558,7 @@ app.get("/api/sessions/:sessionId/reviews", async (req, res, next) => {
523
  .select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
524
  if (sessionError) throw new Error(`Unable to verify review session: ${sessionError.message}`);
525
  if (!session) return res.sendStatus(404);
526
- if (session.user_id !== current.user.id && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
527
  return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
528
  }
529
 
@@ -574,7 +609,7 @@ app.get("/api/sessions/:sessionId/feedbacks", async (req, res, next) => {
574
  .select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
575
  if (sessionError) throw new Error(`Unable to verify feedback session: ${sessionError.message}`);
576
  if (!session) return res.sendStatus(404);
577
- if (session.user_id !== current.user.id && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
578
  return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
579
  }
580
 
@@ -734,8 +769,9 @@ app.post("/api/auth/login", authLimiter, async (req, res, next) => {
734
  await syncProfile(data.user);
735
  setSessionCookies(res, data.session);
736
  clearPasswordRecoveryCookie(res);
737
- const subscription = await activeSubscriptionFor(data.user.id);
738
- return res.json({ user: userPayload(data.user), access: { active: Boolean(subscription), subscription } });
 
739
  } catch (error) { next(error); }
740
  });
741
 
@@ -790,9 +826,10 @@ app.get("/api/auth/me", async (req, res, next) => {
790
  try {
791
  const current = await authenticatedUser(req, res);
792
  if (!current) return res.status(401).json({ error: "Authentication required." });
793
- const subscription = await activeSubscriptionFor(current.user.id);
 
794
  res.set("Cache-Control", "no-store");
795
- return res.json({ user: userPayload(current.user), access: { active: Boolean(subscription), subscription } });
796
  } catch (error) { next(error); }
797
  });
798
 
@@ -861,6 +898,7 @@ app.delete("/api/auth/account", async (req, res, next) => {
861
  try {
862
  const current = await authenticatedUser(req, res);
863
  if (!current) return res.status(401).json({ error: "Authentication required." });
 
864
  if (req.body?.confirmation !== "DELETE") return res.status(400).json({ error: "Confirm account deletion by typing DELETE." });
865
  const { data: imageRow, error: imageReadError } = await supabaseAdmin.from("users")
866
  .select("image_path").eq("user_id", current.user.id).maybeSingle();
@@ -883,16 +921,17 @@ async function proxyAnalysisRequest(req: Request, res: Response) {
883
  const current = await authenticatedUser(req, res);
884
  if (!current) return res.status(401).json({ error: "Authentication required." });
885
  const resourcePath = req.path;
 
886
  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);
887
  if (["GET", "HEAD"].includes(req.method) && sessionMatch) {
888
- const subscribed = await activeSubscriptionFor(current.user.id, { refresh: true });
889
  if (!subscribed) {
890
  const { data: session, error } = await supabaseAdmin.from("sessions")
891
  .select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
892
  if (error) throw new Error(`Unable to verify session access: ${error.message}`);
893
  if (!session) return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
894
  }
895
- } else if (sessionMatch) {
896
  const { data: session, error } = await supabaseAdmin.from("sessions")
897
  .select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
898
  if (error) throw new Error(`Unable to verify session ownership: ${error.message}`);
@@ -902,7 +941,7 @@ async function proxyAnalysisRequest(req: Request, res: Response) {
902
  // a new analysis is gated here; the internal begin-analysis route repeats
903
  // this check before creating its player/session rows.
904
  if (req.method === "POST" && req.path === "/analyze") {
905
- const active = await activeSubscriptionFor(current.user.id, { refresh: true });
906
  if (!active) return res.status(402).json({ error: "An active subscription is required to create or change Labs data.", code: "subscription_required" });
907
  }
908
 
@@ -911,6 +950,7 @@ async function proxyAnalysisRequest(req: Request, res: Response) {
911
  headers.set("x-sporalize-proxy-secret", env.ANALYSIS_PROXY_SECRET);
912
  // Derived from the verified cookie session; never trust a browser-supplied ID.
913
  headers.set("x-sporalize-user-id", current.user.id);
 
914
  headers.set("x-forwarded-host", new URL(env.BACKEND_URL).host);
915
  headers.set("x-forwarded-proto", new URL(env.BACKEND_URL).protocol.replace(":", ""));
916
  for (const name of ["accept", "content-type", "range", "if-range"]) {
 
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
+ import { registerAdminRoutes } from "./admin.js";
16
 
17
  const app = express();
18
  app.disable("x-powered-by");
 
95
  ? values.reduce((sum, value) => sum + value, 0) / values.length * 10
96
  : null;
97
 
98
+ export async function accountRoleFor(userId: string) {
99
+ const { data, error } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle();
100
+ if (error) throw new Error(`Unable to load account permissions: ${error.message}`);
101
+ return String(data?.account_role ?? "user").toLowerCase();
102
+ }
103
+
104
+ export async function userIsAdmin(userId: string) {
105
+ return await accountRoleFor(userId) === "admin";
106
+ }
107
+
108
+ export async function requireAdmin(req: Request, res: Response) {
109
+ const current = await authenticatedUser(req, res);
110
+ if (!current) {
111
+ res.status(401).json({ error: "Authentication required." });
112
+ return null;
113
+ }
114
+ if (!await userIsAdmin(current.user.id)) {
115
+ res.status(403).json({ error: "Administrator access is required." });
116
+ return null;
117
+ }
118
+ return current;
119
+ }
120
+
121
  async function loadActionAnnotations(actionIds: string[]) {
122
  const annotations: Array<{ action_id: string; score: number | null }> = [];
123
  // Keep PostgREST query URLs small when a player has many analyzed actions.
 
138
  res.status(401).json({ error: "Authentication required." });
139
  return null;
140
  }
141
+ if (await userIsAdmin(current.user.id)) {
142
+ return { ...current, isAdmin: true, subscription: { player_limit: null, monthly_session_limit: null } };
143
+ }
144
  const subscription = await activeSubscriptionFor(current.user.id, { refresh: true });
145
  if (!subscription) {
146
  res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
 
149
  return { ...current, subscription };
150
  }
151
 
152
+ registerAdminRoutes(app);
153
+
154
  async function enforcePlayerQuota(userId: string, limit: number | null, res: Response) {
155
  const { data, error } = await supabaseAdmin.from("users_subscriptions")
156
  .select("num_players").eq("user_id", userId).maybeSingle();
 
188
  try {
189
  const current = await authenticatedUser(req, res);
190
  if (!current) return res.status(401).json({ error: "Authentication required." });
191
+ const admin = await userIsAdmin(current.user.id);
192
+ const subscription = admin ? null : await activeSubscriptionFor(current.user.id, { refresh: true });
193
+ return res.json({ active: admin || Boolean(subscription), subscription, isAdmin: admin });
194
  } catch (error) { next(error); }
195
  });
196
 
 
199
  const current = await authenticatedUser(req, res);
200
  if (!current) return res.status(401).json({ error: "Authentication required." });
201
  const userId = current.user.id;
202
+ if (await userIsAdmin(userId)) {
203
+ res.set("Cache-Control", "no-store");
204
+ return res.json({ subscription: null, usage: { players: 0, sessions: 0, monthlySessions: 0 }, billingHistoryAvailable: false, isAdmin: true });
205
+ }
206
  const assignmentResult = await supabaseAdmin.from("users_subscriptions")
207
  .select("subscription_id,startdate,enddate,num_players,num_sessions,num_sessions_month").eq("user_id", userId).maybeSingle();
208
  if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
 
241
  .order("fname", { ascending: true });
242
  if (error) throw new Error(`Unable to load players: ${error.message}`);
243
  // Analysis player choices stay scoped to the signed-in user's own roster.
244
+ const admin = await userIsAdmin(current.user.id);
245
+ const ownedPlayers = admin ? data ?? [] : (data ?? []).filter((player) => player.user_id === current.user.id);
246
  return res.json({ players: ownedPlayers.map((player) => ({
247
  playerId: player.player_id,
248
  name: [player.fname, player.lname].filter(Boolean).join(" "),
 
558
  .select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
559
  if (sessionError) throw new Error(`Unable to verify review session: ${sessionError.message}`);
560
  if (!session) return res.sendStatus(404);
561
+ if (session.user_id !== current.user.id && !await userIsAdmin(current.user.id) && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
562
  return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
563
  }
564
 
 
609
  .select("session_id,user_id").eq("session_id", sessionId.data).maybeSingle();
610
  if (sessionError) throw new Error(`Unable to verify feedback session: ${sessionError.message}`);
611
  if (!session) return res.sendStatus(404);
612
+ if (session.user_id !== current.user.id && !await userIsAdmin(current.user.id) && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
613
  return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
614
  }
615
 
 
769
  await syncProfile(data.user);
770
  setSessionCookies(res, data.session);
771
  clearPasswordRecoveryCookie(res);
772
+ const role = await accountRoleFor(data.user.id);
773
+ const subscription = role === "admin" ? null : await activeSubscriptionFor(data.user.id);
774
+ return res.json({ user: userPayload(data.user, role), access: { active: role === "admin" || Boolean(subscription), subscription } });
775
  } catch (error) { next(error); }
776
  });
777
 
 
826
  try {
827
  const current = await authenticatedUser(req, res);
828
  if (!current) return res.status(401).json({ error: "Authentication required." });
829
+ const role = await accountRoleFor(current.user.id);
830
+ const subscription = role === "admin" ? null : await activeSubscriptionFor(current.user.id);
831
  res.set("Cache-Control", "no-store");
832
+ return res.json({ user: userPayload(current.user, role), access: { active: role === "admin" || Boolean(subscription), subscription } });
833
  } catch (error) { next(error); }
834
  });
835
 
 
898
  try {
899
  const current = await authenticatedUser(req, res);
900
  if (!current) return res.status(401).json({ error: "Authentication required." });
901
+ if (await userIsAdmin(current.user.id)) return res.status(403).json({ error: "Admin accounts cannot delete themselves." });
902
  if (req.body?.confirmation !== "DELETE") return res.status(400).json({ error: "Confirm account deletion by typing DELETE." });
903
  const { data: imageRow, error: imageReadError } = await supabaseAdmin.from("users")
904
  .select("image_path").eq("user_id", current.user.id).maybeSingle();
 
921
  const current = await authenticatedUser(req, res);
922
  if (!current) return res.status(401).json({ error: "Authentication required." });
923
  const resourcePath = req.path;
924
+ const isAdmin = await userIsAdmin(current.user.id);
925
  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);
926
  if (["GET", "HEAD"].includes(req.method) && sessionMatch) {
927
+ const subscribed = isAdmin || await activeSubscriptionFor(current.user.id, { refresh: true });
928
  if (!subscribed) {
929
  const { data: session, error } = await supabaseAdmin.from("sessions")
930
  .select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
931
  if (error) throw new Error(`Unable to verify session access: ${error.message}`);
932
  if (!session) return res.status(403).json({ error: "An active subscription is required to view shared sessions.", code: "subscription_required" });
933
  }
934
+ } else if (sessionMatch && !isAdmin) {
935
  const { data: session, error } = await supabaseAdmin.from("sessions")
936
  .select("session_id").eq("session_id", sessionMatch[1]).eq("user_id", current.user.id).maybeSingle();
937
  if (error) throw new Error(`Unable to verify session ownership: ${error.message}`);
 
941
  // a new analysis is gated here; the internal begin-analysis route repeats
942
  // this check before creating its player/session rows.
943
  if (req.method === "POST" && req.path === "/analyze") {
944
+ const active = isAdmin || await activeSubscriptionFor(current.user.id, { refresh: true });
945
  if (!active) return res.status(402).json({ error: "An active subscription is required to create or change Labs data.", code: "subscription_required" });
946
  }
947
 
 
950
  headers.set("x-sporalize-proxy-secret", env.ANALYSIS_PROXY_SECRET);
951
  // Derived from the verified cookie session; never trust a browser-supplied ID.
952
  headers.set("x-sporalize-user-id", current.user.id);
953
+ if (isAdmin) headers.set("x-sporalize-admin", "true");
954
  headers.set("x-forwarded-host", new URL(env.BACKEND_URL).host);
955
  headers.set("x-forwarded-proto", new URL(env.BACKEND_URL).protocol.replace(":", ""));
956
  for (const name of ["accept", "content-type", "range", "if-range"]) {
src/subscriptions.ts CHANGED
@@ -1,72 +1,75 @@
1
- import { supabaseAdmin } from "./supabase.js";
2
-
3
- type AssignSubscriptionInput = {
4
- userId: string;
5
- subscriptionId: string;
6
- startDate: string;
7
- endDate: string;
8
- };
9
-
10
- /**
11
- * Persist a paid subscription after a payment provider's webhook has been
12
- * authenticated and its payment has been confirmed. Never call this directly
13
- * from a browser request.
14
- */
15
- export async function assignPaidSubscription(input: AssignSubscriptionInput) {
16
- const { userId, subscriptionId, startDate, endDate } = input;
17
- const isoDate = /^\d{4}-\d{2}-\d{2}$/;
18
- if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
19
- throw new Error("Invalid subscription period.");
20
- }
21
-
22
- const { data: plan, error: planError } = await supabaseAdmin
23
- .from("subscriptions")
24
- .select("subscription_id")
25
- .eq("subscription_id", subscriptionId)
26
- .maybeSingle();
27
- if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
28
- if (!plan) throw new Error("Subscription plan was not found.");
29
-
30
- const { data, error } = await supabaseAdmin
31
- .from("users_subscriptions")
32
- .upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
33
- .select("user_id,subscription_id,startdate,enddate")
34
- .single();
35
- if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
36
- accessCache.delete(userId);
37
- accessRequests.delete(userId);
38
- return data;
39
- }
40
-
41
- async function loadActiveSubscription(userId: string) {
42
- const today = new Date().toISOString().slice(0, 10);
43
- const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
44
- .select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
45
- if (error) throw new Error(`Unable to check subscription: ${error.message}`);
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
- }
53
-
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) => {
63
- if (accessRequests.get(userId) === pending) {
64
- if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
65
- const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
66
- accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
67
- }
68
- return value;
69
- }).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
70
- accessRequests.set(userId, pending);
71
- return pending;
72
- }
 
 
 
 
1
+ import { supabaseAdmin } from "./supabase.js";
2
+
3
+ type AssignSubscriptionInput = {
4
+ userId: string;
5
+ subscriptionId: string;
6
+ startDate: string;
7
+ endDate: string;
8
+ };
9
+
10
+ /**
11
+ * Persist a paid subscription after a payment provider's webhook has been
12
+ * authenticated and its payment has been confirmed. Never call this directly
13
+ * from a browser request.
14
+ */
15
+ export async function assignPaidSubscription(input: AssignSubscriptionInput) {
16
+ const { userId, subscriptionId, startDate, endDate } = input;
17
+ const isoDate = /^\d{4}-\d{2}-\d{2}$/;
18
+ if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
19
+ throw new Error("Invalid subscription period.");
20
+ }
21
+
22
+ const { data: plan, error: planError } = await supabaseAdmin
23
+ .from("subscriptions")
24
+ .select("subscription_id")
25
+ .eq("subscription_id", subscriptionId)
26
+ .maybeSingle();
27
+ if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
28
+ if (!plan) throw new Error("Subscription plan was not found.");
29
+
30
+ const { data, error } = await supabaseAdmin
31
+ .from("users_subscriptions")
32
+ .upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
33
+ .select("user_id,subscription_id,startdate,enddate")
34
+ .single();
35
+ if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
36
+ invalidateSubscriptionCache(userId);
37
+ return data;
38
+ }
39
+
40
+ async function loadActiveSubscription(userId: string) {
41
+ const today = new Date().toISOString().slice(0, 10);
42
+ const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
43
+ .select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
44
+ if (error) throw new Error(`Unable to check subscription: ${error.message}`);
45
+ if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
46
+
47
+ const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
48
+ .select("subscription_id,plan,price,player_limit,monthly_session_limit").eq("subscription_id", assignment.subscription_id).maybeSingle();
49
+ if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
50
+ return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
51
+ }
52
+
53
+ // Brief, per-user cache shared by polling, media and session requests.
54
+ const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
55
+ const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
56
+ export function invalidateSubscriptionCache(userId: string) {
57
+ accessCache.delete(userId);
58
+ accessRequests.delete(userId);
59
+ }
60
+ export async function activeSubscriptionFor(userId: string, options: { refresh?: boolean } = {}) {
61
+ const cached = accessCache.get(userId);
62
+ if (!options.refresh && cached && cached.expires > Date.now()) return cached.value;
63
+ const existing = accessRequests.get(userId);
64
+ if (existing) return existing;
65
+ const pending = loadActiveSubscription(userId).then((value) => {
66
+ if (accessRequests.get(userId) === pending) {
67
+ if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
68
+ const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
69
+ accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
70
+ }
71
+ return value;
72
+ }).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
73
+ accessRequests.set(userId, pending);
74
+ return pending;
75
+ }
src/supabase.ts CHANGED
@@ -71,6 +71,6 @@ export async function syncProfile(user: User, profile?: { fname?: string; lname?
71
  if (error) throw new Error(`Unable to create user profile: ${error.message}`);
72
  }
73
 
74
- export function userPayload(user: User) {
75
- return { id: user.id, email: user.email, emailVerified: Boolean(user.email_confirmed_at), metadata: user.user_metadata ?? {} };
76
  }
 
71
  if (error) throw new Error(`Unable to create user profile: ${error.message}`);
72
  }
73
 
74
+ export function userPayload(user: User, accountRole = "user") {
75
+ return { id: user.id, email: user.email, emailVerified: Boolean(user.email_confirmed_at), accountRole, metadata: user.user_metadata ?? {} };
76
  }