File size: 28,151 Bytes
57b63b8
 
d1ceac4
 
 
 
 
 
61f39da
d1ceac4
 
0ffa20c
 
 
 
d1ceac4
 
 
 
 
3304e46
 
 
 
 
 
 
 
 
 
d1ceac4
61f39da
d1ceac4
 
 
 
 
 
0ffa20c
d1ceac4
 
 
 
 
 
0ffa20c
d1ceac4
0ffa20c
d1ceac4
 
 
 
 
 
 
 
 
 
 
 
57b63b8
 
 
 
 
 
 
 
 
 
 
 
d1ceac4
 
 
 
 
 
 
 
 
0ffa20c
e2ead0c
57b63b8
 
0ffa20c
d1ceac4
 
 
35962e4
d1ceac4
 
 
 
 
0ffa20c
d1ceac4
0ffa20c
d1ceac4
 
 
 
 
 
35962e4
0ffa20c
 
 
 
 
61f39da
d1ceac4
 
 
 
 
 
35962e4
d1ceac4
 
 
 
 
 
 
61f39da
d1ceac4
e2ead0c
61f39da
 
d1ceac4
 
 
61f39da
d1ceac4
61f39da
d1ceac4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e2ead0c
 
d1ceac4
 
 
 
 
 
 
 
e2ead0c
d1ceac4
 
 
 
 
 
 
 
 
 
 
 
 
 
957266c
57b63b8
 
 
957266c
d1ceac4
 
 
957266c
d1ceac4
 
 
 
 
 
 
 
 
 
 
0ffa20c
3304e46
7ceab81
3304e46
 
 
 
0ffa20c
3304e46
 
0ffa20c
d1ceac4
 
 
 
 
 
 
61f39da
 
 
 
 
 
 
 
d1ceac4
 
 
 
 
957266c
d1ceac4
61f39da
 
 
d1ceac4
 
61f39da
957266c
61f39da
d1ceac4
 
61f39da
 
 
 
d1ceac4
 
 
 
 
 
 
61f39da
d1ceac4
 
 
 
 
 
 
 
 
57b63b8
d1ceac4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61f39da
 
 
 
 
d1ceac4
 
 
 
 
 
61f39da
d1ceac4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61f39da
 
 
 
 
d1ceac4
0ffa20c
 
 
 
 
d1ceac4
 
997abf7
 
d1ceac4
997abf7
 
 
 
 
d1ceac4
 
997abf7
 
 
 
 
 
 
 
 
 
d1ceac4
 
 
 
 
 
 
61f39da
 
d1ceac4
 
 
61f39da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1ceac4
61f39da
 
d1ceac4
 
61f39da
 
957266c
d1ceac4
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
import { readBatches } from "./read-batches.js";
import { readPages } from "./read-pages.js";
import type { Express, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { env } from "./config.js";
import { supabaseAdmin } from "./supabase.js";
import { requireAdmin } from "./server.js";
import { invalidateSubscriptionCache } from "./subscriptions.js";
import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";

const uuid = z.uuid();
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
  const parsed = new Date(`${value}T00:00:00.000Z`);
  return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
}, "Enter a valid calendar date.");
const profileSchema = z.object({
  fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100),
  academy: z.string().trim().max(150).nullable(), role: z.string().trim().max(50).nullable(),
}).strict();
const playerSchema = z.object({
  fname: z.string().trim().min(1).max(100), lname: z.string().trim().max(100).nullable().optional().default(null),
  bdate: date.refine((value) => {
    const today = new Date();
    const [year, month, day] = value.split("-").map(Number);
    const age = today.getUTCFullYear() - year - Number(today.getUTCMonth() + 1 < month || (today.getUTCMonth() + 1 === month && today.getUTCDate() < day));
    return age >= 4 && age <= 60;
  }, "Enter a valid birth date for a player aged 4 to 60."),
  heightCm: z.number().min(100).max(230), weightKg: z.number().min(20).max(150),
  club: z.string().trim().max(150).nullable().optional(), position: z.string().trim().min(1).max(50),
  gender: z.enum(["male", "female"]), userId: uuid,
}).strict();
const playerUpdateSchema = playerSchema.omit({ userId: true });

function fail(res: Response, status: number, error: string) { return res.status(status).json({ error }); }
function asyncRoute(handler: (req: Request, res: Response) => Promise<unknown>) {
  return (req: Request, res: Response, next: NextFunction) => { void handler(req, res).catch(next); };
}
async function admin(req: Request, res: Response) { return requireAdmin(req, res); }
async function analysisRequest(path: string, userId: string, method: string, isAdmin = true, body?: unknown) {
  const response = await fetch(`${env.ANALYSIS_API_URL}/api${path}`, {
    method,
    headers: {
      "x-sporalize-proxy-secret": env.ANALYSIS_PROXY_SECRET,
      "x-sporalize-user-id": userId,
      ...(isAdmin ? { "x-sporalize-admin": "true" } : {}),
      ...(body !== undefined ? { "content-type": "application/json" } : {}),
    },
    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
  });
  if (!response.ok) throw new Error(`Analysis storage request failed (${response.status}).`);
  return response;
}

async function removeImages(paths: Array<string | null | undefined>) {
  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!)))];
  if (!safe.length) return;
  const { error } = await supabaseAdmin.storage.from("profile-images").remove(safe);
  if (error) throw new Error(`Unable to remove profile images: ${error.message}`);
}

// Count only children of the displayed records, without oversized filters or
// silently losing rows to Supabase's response cap.
async function loadRelatedCounts(table: "players" | "sessions" | "actions", key: "user_id" | "player_id" | "session_id", ids: string[]) {
  const primaryKey = table === "players" ? "player_id" : table === "sessions" ? "session_id" : "action_id";
  return readBatches<string, Record<string, string | null>>(ids, (batch) => readPages(async (from, to) => {
    const { data, error } = await supabaseAdmin.from(table).select(`${primaryKey},${key}`)
      .in(key, batch).order(primaryKey).range(from, to);
    if (error) throw new Error(`Unable to load ${table} counts: ${error.message}`);
    return (data ?? []) as unknown as Record<string, string | null>[];
  }));
}

export function registerAdminRoutes(app: Express) {
  app.get("/api/admin/users", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const search = String(req.query.search ?? "").replace(/[^\p{L}\p{N}@._ -]/gu, "").trim().slice(0, 100);
    let query = supabaseAdmin.from("users").select("user_id,fname,lname,email,academy,role,image_path,account_role").order("fname").limit(250);
    if (search) query = query.or(`fname.ilike.%${search}%,lname.ilike.%${search}%,email.ilike.%${search}%`);
    const { data, error } = await query;
    if (error) throw new Error(`Unable to load user directory: ${error.message}`);
    const ids = (data ?? []).map((row) => row.user_id);
    const [subs, counts, playerCounts] = ids.length ? await Promise.all([
      supabaseAdmin.from("users_subscriptions").select("user_id,subscription_id,startdate,enddate,num_players,num_sessions").in("user_id", ids),
      loadRelatedCounts("sessions", "user_id", ids).then((data) => ({ data, error: null })),
      loadRelatedCounts("players", "user_id", ids).then((data) => ({ data, error: null })),
    ]) : [{ data: [], error: null }, { data: [], error: null }, { data: [], error: null }];
    if (subs.error) throw new Error(`Unable to load user plans: ${subs.error.message}`);
    const planIds = [...new Set((subs.data ?? []).flatMap((row) => row.subscription_id ? [row.subscription_id] : []))];
    const { data: plans, error: plansError } = planIds.length
      ? await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,session_limit").in("subscription_id", planIds)
      : { data: [], error: null };
    if (plansError) throw new Error(`Unable to load plan names: ${plansError.message}`);
    const planMap = new Map((plans ?? []).map((plan) => [plan.subscription_id, plan]));
    const subMap = new Map((subs.data ?? []).map((row) => [row.user_id, row]));
    const sessionCounts = new Map<string, number>();
    const playerCountByUser = new Map<string, number>();
    for (const row of counts.data ?? []) if (row.user_id) sessionCounts.set(row.user_id, (sessionCounts.get(row.user_id) ?? 0) + 1);
    for (const row of playerCounts.data ?? []) if (row.user_id) playerCountByUser.set(row.user_id, (playerCountByUser.get(row.user_id) ?? 0) + 1);
    return res.json({ users: (data ?? []).map((row) => {
      const assignment = String(row.account_role).toLowerCase() === "admin" ? null : subMap.get(row.user_id);
      const plan = assignment?.subscription_id ? planMap.get(assignment.subscription_id) : null;
      return {
        id: row.user_id, fname: row.fname, lname: row.lname, name: `${row.fname} ${row.lname}`.trim(),
        email: row.email, academy: row.academy, role: row.role, imagePath: row.image_path, accountRole: row.account_role,
        subscription: plan ? { id: plan.subscription_id, name: plan.plan, startDate: assignment?.startdate, endDate: assignment?.enddate, playerLimit: plan.player_limit, sessionLimit: plan.session_limit } : null,
        usage: {
          players: String(row.account_role).toLowerCase() === "admin" ? playerCountByUser.get(row.user_id) ?? 0 : assignment?.num_players ?? 0,
          sessions: String(row.account_role).toLowerCase() === "admin" ? sessionCounts.get(row.user_id) ?? 0 : assignment?.num_sessions ?? 0,
          totalSessions: sessionCounts.get(row.user_id) ?? 0,
        },
        dataCounts: { players: playerCountByUser.get(row.user_id) ?? 0, sessions: sessionCounts.get(row.user_id) ?? 0 },
      };
    }) });
  }));

  app.get("/api/admin/plans", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const { data, error } = await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,session_limit").order("price");
    if (error) throw new Error(`Unable to load plans: ${error.message}`);
    return res.json({ plans: data ?? [] });
  }));

  app.get("/api/admin/users/:userId", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const id = uuid.safeParse(req.params.userId); if (!id.success) return res.sendStatus(404);
    const [{ data: user, error }, { data: assignment, error: subError }, { count: players, error: playersError }, { count: sessions, error: sessionsError }] = await Promise.all([
      supabaseAdmin.from("users").select("user_id,fname,lname,email,academy,role,image_path,account_role").eq("user_id", id.data).maybeSingle(),
      supabaseAdmin.from("users_subscriptions").select("subscription_id,startdate,enddate,num_players,num_sessions").eq("user_id", id.data).maybeSingle(),
      supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }).eq("user_id", id.data),
      supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }).eq("user_id", id.data),
    ]);
    if (error) throw new Error(`Unable to load user: ${error.message}`);
    if (subError) throw new Error(`Unable to load user plan: ${subError.message}`);
    if (playersError || sessionsError) throw new Error(`Unable to load account data counts: ${(playersError ?? sessionsError)?.message}`);
    if (!user) return res.sendStatus(404);
    return res.json({ user, subscription: assignment ?? null, counts: { players: players ?? 0, sessions: sessions ?? 0 } });
  }));

  app.patch("/api/admin/users/:userId", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const id = uuid.safeParse(req.params.userId); const input = profileSchema.safeParse(req.body);
    if (!id.success || !input.success) return fail(res, 400, "Check the profile details.");
    const { data, error } = await supabaseAdmin.from("users").update(input.data).eq("user_id", id.data)
      .select("user_id,fname,lname,email,academy,role,image_path,account_role").maybeSingle();
    if (error) throw new Error(`Unable to update profile: ${error.message}`);
    if (!data) return res.sendStatus(404);
    return res.json({ user: data });
  }));

  app.put("/api/admin/users/:userId/subscription", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const id = uuid.safeParse(req.params.userId);
    const input = z.object({ subscriptionId: uuid, startDate: date, endDate: date }).strict().safeParse(req.body);
    if (!id.success || !input.success || input.data.endDate < input.data.startDate) return fail(res, 400, "Choose a valid plan period.");
    const { data: account, error: accountError } = await supabaseAdmin.from("users").select("account_role").eq("user_id", id.data).maybeSingle();
    if (accountError) throw new Error(`Unable to verify account: ${accountError.message}`);
    if (!account) return res.sendStatus(404);
    if (String(account.account_role).toLowerCase() === "admin") return fail(res, 409, "Administrator accounts do not use subscriptions.");
    const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions").select("subscription_id").eq("subscription_id", input.data.subscriptionId).maybeSingle();
    if (planError) throw new Error(`Unable to verify plan: ${planError.message}`);
    if (!plan) return fail(res, 404, "Plan not found.");
    const { data: row, error } = await supabaseAdmin.from("users_subscriptions").upsert({
      user_id: id.data, subscription_id: plan.subscription_id, startdate: input.data.startDate, enddate: input.data.endDate,
      num_players: 0, num_sessions: 0,
    }, { onConflict: "user_id" }).select("user_id,subscription_id,startdate,enddate,num_players,num_sessions").single();
    if (error) throw new Error(`Unable to assign plan: ${error.message}`);
    invalidateSubscriptionCache(id.data);
    return res.json({ subscription: row });
  }));

  app.delete("/api/admin/users/:userId/subscription", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const id = uuid.safeParse(req.params.userId); if (!id.success) return res.sendStatus(404);
    const { error } = await supabaseAdmin.from("users_subscriptions").update({ subscription_id: null, num_players: 0, num_sessions: 0 })
      .eq("user_id", id.data);
    if (error) throw new Error(`Unable to cancel plan: ${error.message}`);
    invalidateSubscriptionCache(id.data);
    return res.sendStatus(204);
  }));

  app.get("/api/admin/players", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const [{ data: players, error }, { data: users, error: userError }] = await Promise.all([
      supabaseAdmin.from("players").select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path").order("fname").limit(1000),
      supabaseAdmin.from("users").select("user_id,fname,lname,email").order("fname").limit(1000),
    ]);
    if (error) throw new Error(`Unable to load players: ${error.message}`);
    if (userError) throw new Error(`Unable to load owners: ${userError.message}`);
    const sessionCounts = new Map<string, number>();
    const linkedSessions = await loadRelatedCounts("sessions", "player_id", (players ?? []).map((player) => player.player_id));
    for (const session of linkedSessions) {
      if (session.player_id) sessionCounts.set(session.player_id, (sessionCounts.get(session.player_id) ?? 0) + 1);
    }
    const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
    return res.json({ players: (players ?? []).map((player) => {
      const owner = player.user_id ? owners.get(player.user_id) : null;
      return { ...player, sessionCount: sessionCounts.get(player.player_id) ?? 0, owner: owner ? `${owner.fname} ${owner.lname}`.trim() || owner.email : null };
    }), users: users ?? [] });
  }));

  app.post("/api/admin/players", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const input = playerSchema.safeParse(req.body); if (!input.success) return fail(res, 400, "Check the player details.");
    if (input.data.userId) {
      const { data, error } = await supabaseAdmin.from("users").select("user_id").eq("user_id", input.data.userId).maybeSingle();
      if (error) throw new Error(`Unable to verify player owner: ${error.message}`);
      if (!data) return fail(res, 404, "Player owner not found.");
    }
    const { data, error } = await supabaseAdmin.rpc("admin_create_player", {
      p_user_id: input.data.userId,
      p_fname: input.data.fname,
      p_lname: input.data.lname?.trim() || null,
      p_bdate: input.data.bdate,
      p_height: input.data.heightCm,
      p_weight: input.data.weightKg,
      p_club: input.data.club || null,
      p_position: input.data.position,
      p_gender: input.data.gender,
    });
    if (error) throw new Error(`Unable to add player: ${error.message}`);
    return res.status(201).json({ player: data });
  }));

  app.patch("/api/admin/players/:playerId", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const id = uuid.safeParse(req.params.playerId);
    const input = playerUpdateSchema.safeParse(req.body);
    if (!id.success || !input.success) return fail(res, 400, "Check the player details.");
    const { data, error } = await supabaseAdmin.from("players").update({
      fname: input.data.fname, lname: input.data.lname, bdate: input.data.bdate,
      height: input.data.heightCm, weight: input.data.weightKg, club: input.data.club,
      position: input.data.position, gender: input.data.gender,
    }).eq("player_id", id.data).select("player_id,user_id").maybeSingle();
    if (error) throw new Error(`Unable to update player: ${error.message}`);
    if (!data) return res.sendStatus(404);
    return res.json({ player: data });
  }));

  app.delete("/api/admin/players/:playerId", asyncRoute(async (req, res) => {
    const current = await admin(req, res); if (!current) return;
    const id = uuid.safeParse(req.params.playerId); if (!id.success) return res.sendStatus(404);
    const mode = z.enum(["keep_sessions", "delete_sessions"]).safeParse(req.body?.mode);
    if (!mode.success) return fail(res, 400, "Choose whether to keep or delete this player's sessions.");
    const { data: player, error: readError } = await supabaseAdmin.from("players").select("player_id,user_id,image_path").eq("player_id", id.data).maybeSingle();
    if (readError) throw new Error(`Unable to load player: ${readError.message}`);
    if (!player) return res.sendStatus(404);
    const { data: linkedSessions, error: sessionsError } = await supabaseAdmin.from("sessions").select("session_id,user_id,by_admin").eq("player_id", id.data);
    if (sessionsError) throw new Error(`Unable to load player sessions: ${sessionsError.message}`);
    if (mode.data === "delete_sessions") for (const session of linkedSessions ?? []) await deleteStoredSession(session.session_id, current.user.id, true);
    const { error } = await supabaseAdmin.from("players").delete().eq("player_id", id.data);
    if (error) throw new Error(`Unable to delete player: ${error.message}`);
    const chargedSessions = mode.data === "delete_sessions"
      ? (linkedSessions ?? []).filter((session) => session.user_id === player.user_id && !session.by_admin).length
      : 0;
    await refundAdminUsage(player.user_id, 1, chargedSessions);
    await removeImages([player.image_path]).catch((imageError) => console.error("Player was deleted but its profile image could not be removed", imageError));
    return res.sendStatus(204);
  }));

  app.get("/api/admin/sessions", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const [{ data: sessions, error }, { data: users, error: usersError }, { data: players, error: playersError }] = await Promise.all([
      supabaseAdmin.from("sessions").select("session_id,user_id,player_id,name,path,status,created_at,by_admin").order("created_at", { ascending: false }).limit(1000),
      supabaseAdmin.from("users").select("user_id,fname,lname,email").order("fname").limit(1000),
      supabaseAdmin.from("players").select("player_id,fname,lname").order("fname").limit(1000),
    ]);
    if (error) throw new Error(`Unable to load sessions: ${error.message}`);
    if (usersError) throw new Error(`Unable to load owners: ${usersError.message}`);
    if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
    const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
    const roster = new Map((players ?? []).map((player) => [player.player_id, player]));
    const ids = (sessions ?? []).map((session) => session.session_id);
    const actionRows = await loadRelatedCounts("actions", "session_id", ids);
    const actionCounts = new Map<string, number>();
    for (const action of actionRows ?? []) if (action.session_id) actionCounts.set(action.session_id, (actionCounts.get(action.session_id) ?? 0) + 1);
    return res.json({ sessions: (sessions ?? []).map((session) => {
      const owner = session.user_id ? owners.get(session.user_id) : null;
      const player = session.player_id ? roster.get(session.player_id) : null;
      return {
        ...session,
        owner: owner ? `${owner.fname} ${owner.lname}`.trim() || owner.email : null,
        player: player ? `${player.fname} ${player.lname}`.trim() : null,
        actionCount: actionCounts.get(session.session_id) ?? 0,
      };
    }), users: users ?? [], players: players ?? [] });
  }));

  app.delete("/api/admin/sessions/:sessionId", asyncRoute(async (req, res) => {
    const current = await admin(req, res); if (!current) return;
    const id = uuid.safeParse(req.params.sessionId); if (!id.success) return res.sendStatus(404);
    const { data: session, error } = await supabaseAdmin.from("sessions").select("user_id,by_admin").eq("session_id", id.data).maybeSingle();
    if (error) throw new Error(`Unable to load session before deletion: ${error.message}`);
    if (!session) return res.sendStatus(404);
    await deleteStoredSession(id.data, current.user.id, true);
    await refundAdminUsage(session.user_id, 0, session.by_admin ? 0 : 1);
    return res.sendStatus(204);
  }));

  app.get("/api/admin/sessions/:sessionId", asyncRoute(async (req, res) => {
    if (!await admin(req, res)) return;
    const id = uuid.safeParse(req.params.sessionId); if (!id.success) return res.sendStatus(404);
    const { data: session, error } = await supabaseAdmin.from("sessions").select("session_id,user_id,player_id,name,path,status,created_at,by_admin").eq("session_id", id.data).maybeSingle();
    if (error) throw new Error(`Unable to load session: ${error.message}`);
    if (!session) return res.sendStatus(404);
    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");
    if (actionsError) throw new Error(`Unable to load actions: ${actionsError.message}`);
    const actionIds = (actions ?? []).map((action) => action.action_id);
    const [{ data: annotations, error: annotationError }, { data: notes, error: notesError }] = actionIds.length ? await Promise.all([
      supabaseAdmin.from("annotations").select("action_id,score,user_id").in("action_id", actionIds),
      supabaseAdmin.from("annotations_notes").select("action_id,note").in("action_id", actionIds),
    ]) : [{ data: [], error: null }, { data: [], error: null }];
    if (annotationError) throw new Error(`Unable to load action annotations: ${annotationError.message}`);
    if (notesError) throw new Error(`Unable to load action notes: ${notesError.message}`);
    const annotationsById = new Map((annotations ?? []).map((row) => [row.action_id, row]));
    const notesById = new Map<string, string[]>();
    for (const row of notes ?? []) notesById.set(row.action_id, [...(notesById.get(row.action_id) ?? []), row.note]);
    return res.json({ session, actions: (actions ?? []).map((action) => ({ ...action, score: annotationsById.get(action.action_id)?.score ?? null, notes: notesById.get(action.action_id) ?? [] })) });
  }));

  app.patch("/api/admin/sessions/:sessionId/actions/:actionId", asyncRoute(async (req, res) => {
    const current = await admin(req, res); if (!current) return;
    const sessionId = uuid.safeParse(req.params.sessionId); const actionId = uuid.safeParse(req.params.actionId);
    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);
    if (!sessionId.success || !actionId.success || !input.success || input.data.endFrame < input.data.startFrame) return fail(res, 400, "Check the action review and frame interval.");
    const { data: sessionState, error: sessionStateError } = await supabaseAdmin.from("sessions")
      .select("status").eq("session_id", sessionId.data).maybeSingle();
    if (sessionStateError) throw new Error(`Unable to verify session state: ${sessionStateError.message}`);
    if (!sessionState) return res.sendStatus(404);
    if (["pending", "uploading", "queued", "running"].includes(sessionState.status)) return fail(res, 409, "Actions cannot be changed while the session is running.");
    const notes = [...new Set(input.data.notes)];
    const remote = await analysisRequest(`/sessions/${sessionId.data}/actions/${actionId.data}`, current.user.id, "PATCH", true, {
      startFrame: input.data.startFrame,
      endFrame: input.data.endFrame,
      type: input.data.type,
    });
    const remoteResult = await remote.json().catch(() => ({}));
    if (!remote.ok) return fail(res, remote.status, (remoteResult as { detail?: string }).detail ?? "Unable to update action data.");
    const { data: existing, error: readError } = await supabaseAdmin.from("annotations")
      .select("score,annotations_notes(note)").eq("action_id", actionId.data).maybeSingle();
    if (readError) throw new Error(`Unable to load action review: ${readError.message}`);
    if (input.data.score === null && notes.length === 0) {
      if (existing) {
        const { error } = await supabaseAdmin.from("annotations").delete().eq("action_id", actionId.data);
        if (error) throw new Error(`Unable to remove action review: ${error.message}`);
      }
      return res.json({ ok: true });
    }
    const previousNotes = (existing?.annotations_notes ?? []).map((row) => row.note);
    const previousScore = existing?.score == null ? null : Number(existing.score);
    const reviewUnchanged = existing && previousScore === input.data.score
      && previousNotes.length === notes.length && previousNotes.every((note) => notes.includes(note));
    if (reviewUnchanged) return res.json({ ok: true });
    const { error } = await supabaseAdmin.rpc("save_action_review", {
      p_session_id: sessionId.data, p_action_id: actionId.data,
      p_user_id: current.user.id, p_score: input.data.score, p_notes: notes,
    });
    if (error) throw new Error(`Unable to save action review: ${error.message}`);
    return res.json({ ok: true });
  }));

  app.delete("/api/admin/users/:userId", asyncRoute(async (req, res) => {
    const current = await admin(req, res); if (!current) return;
    const id = uuid.safeParse(req.params.userId); if (!id.success) return res.sendStatus(404);
    if (id.data === current.user.id) return fail(res, 400, "You cannot delete your own administrator account.");
    const mode = z.enum(["keep_data", "delete_data"]).safeParse(req.body?.mode);
    if (!mode.success) return fail(res, 400, "Choose whether to keep or delete this account's players and sessions.");
    const { data: target, error } = await supabaseAdmin.from("users").select("user_id,account_role,image_path").eq("user_id", id.data).maybeSingle();
    if (error) throw new Error(`Unable to load account: ${error.message}`);
    if (!target) return res.sendStatus(404);
    const { data: players, error: playersError } = await supabaseAdmin.from("players")
      .select("player_id,image_path").eq("user_id", id.data);
    if (playersError) throw new Error(`Unable to load account players: ${playersError.message}`);
    if (mode.data === "delete_data") {
      const playerIds = (players ?? []).map((player) => player.player_id);
      const direct = await supabaseAdmin.from("sessions").select("session_id").eq("user_id", id.data);
      if (direct.error) throw new Error(`Unable to load account sessions: ${direct.error.message}`);
      const linked = playerIds.length
        ? await supabaseAdmin.from("sessions").select("session_id").in("player_id", playerIds)
        : { data: [], error: null };
      if (linked.error) throw new Error(`Unable to load player sessions: ${linked.error.message}`);
      const sessionIds = [...new Set([...(direct.data ?? []), ...(linked.data ?? [])].map((row) => row.session_id))];
      for (const sessionId of sessionIds) await deleteStoredSession(sessionId, current.user.id, true);
      if (playerIds.length) {
        const { error: playerDeleteError } = await supabaseAdmin.from("players").delete().in("player_id", playerIds);
        if (playerDeleteError) throw new Error(`Unable to delete account players: ${playerDeleteError.message}`);
      }
      await removeImages((players ?? []).map((player) => player.image_path));
    }
    const { error: assignmentError } = await supabaseAdmin.from("users_subscriptions").delete().eq("user_id", id.data);
    if (assignmentError) throw new Error(`Unable to remove account subscription: ${assignmentError.message}`);
    const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(id.data);
    if (authError) throw new Error(`Unable to remove authentication account: ${authError.message}`);
    const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", id.data);
    if (profileError) throw new Error(`Authentication account was removed but profile cleanup failed: ${profileError.message}`);
    await removeImages([target.image_path])
      .catch((imageError) => console.error("Account was deleted but some profile images could not be removed", imageError));
    return res.sendStatus(204);
  }));
}