Spaces:
Running
Running
Reduce workspace query waterfalls and bound related reads
Browse files- PERFORMANCE.md +11 -0
- src/admin.ts +20 -18
- src/read-pages.ts +15 -0
- src/server.ts +97 -83
- src/subscriptions.ts +7 -4
- src/supabase.ts +2 -1
- tests/read-pages.test.ts +23 -0
- tests/workspace-performance.test.ts +135 -0
PERFORMANCE.md
CHANGED
|
@@ -24,3 +24,14 @@ Changes reduce repeated profile reads after mutations, avoid writes on ordinary
|
|
| 24 |
The frontend keeps bounded, account-keyed summary snapshots in tab session storage for at most five minutes. Reloads display them while revalidating. Full analysis payloads are excluded; logout/account changes clear the cache. Server authorization remains required for every API request.
|
| 25 |
|
| 26 |
Remaining scaling work should follow production measurements: paginate large rosters and session lists, aggregate ratings in the database, and evaluate backend/database region placement. No production infrastructure or database changes were made here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
The frontend keeps bounded, account-keyed summary snapshots in tab session storage for at most five minutes. Reloads display them while revalidating. Full analysis payloads are excluded; logout/account changes clear the cache. Server authorization remains required for every API request.
|
| 25 |
|
| 26 |
Remaining scaling work should follow production measurements: paginate large rosters and session lists, aggregate ratings in the database, and evaluate backend/database region placement. No production infrastructure or database changes were made here.
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
## 2026-10-21 follow-up
|
| 30 |
+
|
| 31 |
+
Active subscription and billing reads now embed the plan in the assignment query. Login profile synchronization skips writes for an unchanged email. Roster player/session reads start concurrently, action ratings join their actions, and Sessions embeds its optional player. Roster, detail and session list reads use stable ordered 500-row pages; action reads use batches of 100 session IDs with at most four batches in flight and paginate inside each batch. Authorization filters remain on every scoped parent read. No completed auth/role decisions are cached.
|
| 32 |
+
|
| 33 |
+
For small, non-admin, nonempty datasets, cold `/auth/me` uses three upstream calls rather than four; unchanged-profile login uses four rather than six. Players overview drops from six dependent stages to four; Sessions saves one total call while preserving its existing four-stage dependency depth. Large datasets require more pages. These are code-derived counts, not measured production latency. The new `workspace-performance.test.ts` runs the actual Express API against a local fake Supabase server and checks scopes, role changes, revocation, scores, billing and failures. `read-pages.test.ts` covers row-cap boundaries and failures.
|
| 34 |
+
|
| 35 |
+
The join shapes were verified read-only against the configured database. See [Supabase joins and nesting](https://supabase.com/docs/guides/database/joins-and-nesting) and [range pagination](https://supabase.com/docs/reference/javascript/range). The normal Supabase row cap must be at least the 500-row page size. No schema/dependency changes are needed. Full client-facing lists and squad aggregation remain scaling work. The root repository's `PERFORMANCE.md` records the broader audit and frontend bundle measurements.
|
| 36 |
+
|
| 37 |
+
Admin related counts now page and batch children of the displayed records. The admin Players page no longer scans unrelated sessions. Existing directory result caps remain; replacing them with full UI pagination remains follow-up work. Integration tests cover the three admin read endpoints as well.
|
src/admin.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
import type { Express, Request, Response, NextFunction } from "express";
|
| 2 |
import { z } from "zod";
|
| 3 |
import { env } from "./config.js";
|
|
@@ -56,6 +58,18 @@ async function removeImages(paths: Array<string | null | undefined>) {
|
|
| 56 |
if (error) throw new Error(`Unable to remove profile images: ${error.message}`);
|
| 57 |
}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
export function registerAdminRoutes(app: Express) {
|
| 60 |
app.get("/api/admin/users", asyncRoute(async (req, res) => {
|
| 61 |
if (!await admin(req, res)) return;
|
|
@@ -67,12 +81,10 @@ export function registerAdminRoutes(app: Express) {
|
|
| 67 |
const ids = (data ?? []).map((row) => row.user_id);
|
| 68 |
const [subs, counts, playerCounts] = ids.length ? await Promise.all([
|
| 69 |
supabaseAdmin.from("users_subscriptions").select("user_id,subscription_id,startdate,enddate,num_players,num_sessions").in("user_id", ids),
|
| 70 |
-
|
| 71 |
-
|
| 72 |
]) : [{ data: [], error: null }, { data: [], error: null }, { data: [], error: null }];
|
| 73 |
if (subs.error) throw new Error(`Unable to load user plans: ${subs.error.message}`);
|
| 74 |
-
if (counts.error) throw new Error(`Unable to load user session counts: ${counts.error.message}`);
|
| 75 |
-
if (playerCounts.error) throw new Error(`Unable to load user player counts: ${playerCounts.error.message}`);
|
| 76 |
const planIds = [...new Set((subs.data ?? []).flatMap((row) => row.subscription_id ? [row.subscription_id] : []))];
|
| 77 |
const { data: plans, error: plansError } = planIds.length
|
| 78 |
? await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,session_limit").in("subscription_id", planIds)
|
|
@@ -175,16 +187,9 @@ export function registerAdminRoutes(app: Express) {
|
|
| 175 |
if (error) throw new Error(`Unable to load players: ${error.message}`);
|
| 176 |
if (userError) throw new Error(`Unable to load owners: ${userError.message}`);
|
| 177 |
const sessionCounts = new Map<string, number>();
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
.select("player_id").not("player_id", "is", null).range(sessionOffset, sessionOffset + 999);
|
| 182 |
-
if (linkedSessionsError) throw new Error(`Unable to count player sessions: ${linkedSessionsError.message}`);
|
| 183 |
-
for (const session of linkedSessions ?? []) {
|
| 184 |
-
if (session.player_id) sessionCounts.set(session.player_id, (sessionCounts.get(session.player_id) ?? 0) + 1);
|
| 185 |
-
}
|
| 186 |
-
if ((linkedSessions ?? []).length < 1000) break;
|
| 187 |
-
sessionOffset += 1000;
|
| 188 |
}
|
| 189 |
const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
|
| 190 |
return res.json({ players: (players ?? []).map((player) => {
|
|
@@ -265,10 +270,7 @@ export function registerAdminRoutes(app: Express) {
|
|
| 265 |
const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
|
| 266 |
const roster = new Map((players ?? []).map((player) => [player.player_id, player]));
|
| 267 |
const ids = (sessions ?? []).map((session) => session.session_id);
|
| 268 |
-
const
|
| 269 |
-
? await supabaseAdmin.from("actions").select("session_id").in("session_id", ids)
|
| 270 |
-
: { data: [], error: null };
|
| 271 |
-
if (actionsError) throw new Error(`Unable to load session action counts: ${actionsError.message}`);
|
| 272 |
const actionCounts = new Map<string, number>();
|
| 273 |
for (const action of actionRows ?? []) if (action.session_id) actionCounts.set(action.session_id, (actionCounts.get(action.session_id) ?? 0) + 1);
|
| 274 |
return res.json({ sessions: (sessions ?? []).map((session) => {
|
|
|
|
| 1 |
+
import { readBatches } from "./read-batches.js";
|
| 2 |
+
import { readPages } from "./read-pages.js";
|
| 3 |
import type { Express, Request, Response, NextFunction } from "express";
|
| 4 |
import { z } from "zod";
|
| 5 |
import { env } from "./config.js";
|
|
|
|
| 58 |
if (error) throw new Error(`Unable to remove profile images: ${error.message}`);
|
| 59 |
}
|
| 60 |
|
| 61 |
+
// Count only children of the displayed records, without oversized filters or
|
| 62 |
+
// silently losing rows to Supabase's response cap.
|
| 63 |
+
async function loadRelatedCounts(table: "players" | "sessions" | "actions", key: "user_id" | "player_id" | "session_id", ids: string[]) {
|
| 64 |
+
const primaryKey = table === "players" ? "player_id" : table === "sessions" ? "session_id" : "action_id";
|
| 65 |
+
return readBatches<string, Record<string, string | null>>(ids, (batch) => readPages(async (from, to) => {
|
| 66 |
+
const { data, error } = await supabaseAdmin.from(table).select(`${primaryKey},${key}`)
|
| 67 |
+
.in(key, batch).order(primaryKey).range(from, to);
|
| 68 |
+
if (error) throw new Error(`Unable to load ${table} counts: ${error.message}`);
|
| 69 |
+
return (data ?? []) as unknown as Record<string, string | null>[];
|
| 70 |
+
}));
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
export function registerAdminRoutes(app: Express) {
|
| 74 |
app.get("/api/admin/users", asyncRoute(async (req, res) => {
|
| 75 |
if (!await admin(req, res)) return;
|
|
|
|
| 81 |
const ids = (data ?? []).map((row) => row.user_id);
|
| 82 |
const [subs, counts, playerCounts] = ids.length ? await Promise.all([
|
| 83 |
supabaseAdmin.from("users_subscriptions").select("user_id,subscription_id,startdate,enddate,num_players,num_sessions").in("user_id", ids),
|
| 84 |
+
loadRelatedCounts("sessions", "user_id", ids).then((data) => ({ data, error: null })),
|
| 85 |
+
loadRelatedCounts("players", "user_id", ids).then((data) => ({ data, error: null })),
|
| 86 |
]) : [{ data: [], error: null }, { data: [], error: null }, { data: [], error: null }];
|
| 87 |
if (subs.error) throw new Error(`Unable to load user plans: ${subs.error.message}`);
|
|
|
|
|
|
|
| 88 |
const planIds = [...new Set((subs.data ?? []).flatMap((row) => row.subscription_id ? [row.subscription_id] : []))];
|
| 89 |
const { data: plans, error: plansError } = planIds.length
|
| 90 |
? await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,session_limit").in("subscription_id", planIds)
|
|
|
|
| 187 |
if (error) throw new Error(`Unable to load players: ${error.message}`);
|
| 188 |
if (userError) throw new Error(`Unable to load owners: ${userError.message}`);
|
| 189 |
const sessionCounts = new Map<string, number>();
|
| 190 |
+
const linkedSessions = await loadRelatedCounts("sessions", "player_id", (players ?? []).map((player) => player.player_id));
|
| 191 |
+
for (const session of linkedSessions) {
|
| 192 |
+
if (session.player_id) sessionCounts.set(session.player_id, (sessionCounts.get(session.player_id) ?? 0) + 1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
|
| 195 |
return res.json({ players: (players ?? []).map((player) => {
|
|
|
|
| 270 |
const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
|
| 271 |
const roster = new Map((players ?? []).map((player) => [player.player_id, player]));
|
| 272 |
const ids = (sessions ?? []).map((session) => session.session_id);
|
| 273 |
+
const actionRows = await loadRelatedCounts("actions", "session_id", ids);
|
|
|
|
|
|
|
|
|
|
| 274 |
const actionCounts = new Map<string, number>();
|
| 275 |
for (const action of actionRows ?? []) if (action.session_id) actionCounts.set(action.session_id, (actionCounts.get(action.session_id) ?? 0) + 1);
|
| 276 |
return res.json({ sessions: (sessions ?? []).map((session) => {
|
src/read-pages.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Read ordered rows in bounded pages, including children of batched IDs.
|
| 2 |
+
* Callers must supply a stable order and preserve their authorization filters.
|
| 3 |
+
*/
|
| 4 |
+
export async function readPages<T>(
|
| 5 |
+
load: (from: number, to: number) => PromiseLike<readonly T[]>,
|
| 6 |
+
pageSize = 500,
|
| 7 |
+
): Promise<T[]> {
|
| 8 |
+
if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error("Invalid page size");
|
| 9 |
+
const rows: T[] = [];
|
| 10 |
+
for (let from = 0; ; from += pageSize) {
|
| 11 |
+
const page = await load(from, from + pageSize - 1);
|
| 12 |
+
rows.push(...page);
|
| 13 |
+
if (page.length < pageSize) return rows;
|
| 14 |
+
}
|
| 15 |
+
}
|
src/server.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";
|
|
| 19 |
import { singleFlight } from "./single-flight.js";
|
| 20 |
import { requestTiming } from "./request-timing.js";
|
| 21 |
import { readBatches } from "./read-batches.js";
|
|
|
|
| 22 |
|
| 23 |
const app = express();
|
| 24 |
app.disable("x-powered-by");
|
|
@@ -173,13 +174,15 @@ export async function requireAdmin(req: Request, res: Response) {
|
|
| 173 |
return current;
|
| 174 |
}
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
| 180 |
if (error) throw new Error(`Unable to load action scores: ${error.message}`);
|
| 181 |
-
return data ?? [];
|
| 182 |
-
});
|
| 183 |
}
|
| 184 |
|
| 185 |
|
|
@@ -250,16 +253,19 @@ app.get("/api/plan-billing", async (req, res, next) => {
|
|
| 250 |
const current = await authenticatedUser(req, res);
|
| 251 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 252 |
const userId = current.user.id;
|
| 253 |
-
const isAdmin = await
|
| 254 |
-
|
| 255 |
-
.
|
|
|
|
|
|
|
|
|
|
| 256 |
if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
|
| 257 |
const assignment = assignmentResult.data;
|
| 258 |
let subscription = null;
|
| 259 |
if (!isAdmin && assignment?.subscription_id) {
|
| 260 |
-
const
|
| 261 |
-
|
| 262 |
-
|
| 263 |
if (plan) {
|
| 264 |
const today = new Date().toISOString().slice(0, 10);
|
| 265 |
subscription = {
|
|
@@ -454,37 +460,44 @@ app.get("/api/players-overview", async (req, res, next) => {
|
|
| 454 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 455 |
|
| 456 |
const isAdmin = await userIsAdmin(current.user.id);
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
const sessionIds = (sessions ?? []).map((row) => row.session_id);
|
| 474 |
-
const
|
| 475 |
-
|
| 476 |
-
.in("session_id", sessionIds)
|
| 477 |
-
: { data: [], error: null };
|
| 478 |
-
if (actionsError) throw new Error(`Unable to load player actions: ${actionsError.message}`);
|
| 479 |
-
|
| 480 |
-
const actionIds = (actions ?? []).map((row) => row.action_id);
|
| 481 |
-
const annotations = await loadActionAnnotations(actionIds);
|
| 482 |
-
|
| 483 |
-
const scoresByAction = new Map(annotations.map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
|
| 484 |
const sessionsByPlayer = new Map<string, typeof sessions>();
|
| 485 |
-
for (const session of sessions ?? [])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
const actionsBySession = new Map<string, typeof actions>();
|
| 487 |
-
for (const action of actions
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
const players = (rows ?? []).map((row) => {
|
| 490 |
const playerSessions = sessionsByPlayer.get(row.player_id) ?? [];
|
|
@@ -535,31 +548,32 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 535 |
const playerId = z.string().uuid().safeParse(req.params.playerId);
|
| 536 |
if (!playerId.success) return res.status(404).json({ error: "Player not found." });
|
| 537 |
const isAdmin = await userIsAdmin(current.user.id);
|
| 538 |
-
let playerQuery = supabaseAdmin.from("players").select("player_id,user_id")
|
| 539 |
.eq("player_id", playerId.data);
|
| 540 |
if (!isAdmin) playerQuery = playerQuery.eq("user_id", current.user.id);
|
| 541 |
const { data, error } = await playerQuery.maybeSingle();
|
| 542 |
if (error) throw new Error(`Unable to load player: ${error.message}`);
|
| 543 |
if (!data) return res.status(404).json({ error: "Player not found." });
|
| 544 |
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
const
|
|
|
|
| 563 |
const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
|
| 564 |
const ownSessionIds = new Set(ownSessions.map((session) => session.session_id));
|
| 565 |
const ownActions = (actions ?? []).filter((row) => ownSessionIds.has(row.session_id));
|
|
@@ -571,7 +585,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 571 |
return Boolean(ownerId) && ownerId !== playerId.data;
|
| 572 |
});
|
| 573 |
const squadByAction = playerSkillRatings(squadActions, scoreMap);
|
| 574 |
-
const player =
|
| 575 |
if (!player) return res.status(404).json({ error: "Player not found." });
|
| 576 |
const sessionsForPlayer = ownSessions.map((row) => {
|
| 577 |
const sessionActions = ownActions.filter((action) => action.session_id === row.session_id);
|
|
@@ -582,7 +596,11 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 582 |
for (const action of actions ?? []) {
|
| 583 |
const ownerId = sessionPlayerById.get(action.session_id);
|
| 584 |
const score = scoreMap.get(action.action_id);
|
| 585 |
-
if (ownerId && score != null && Number.isFinite(score))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
}
|
| 587 |
const rankedPlayers = [...playerScores.entries()]
|
| 588 |
.map(([id, values]) => ({ id, score: meanPlayerScore(values)! }))
|
|
@@ -612,26 +630,21 @@ app.get("/api/sessions-overview", async (req, res, next) => {
|
|
| 612 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 613 |
|
| 614 |
const isAdmin = await userIsAdmin(current.user.id);
|
| 615 |
-
|
| 616 |
-
.
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
]);
|
| 631 |
-
if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
|
| 632 |
-
if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
|
| 633 |
-
const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
|
| 634 |
-
const playerImages = new Map((players ?? []).map((row) => [row.player_id, { gender: row.gender, imagePath: row.image_path }]));
|
| 635 |
const breakdownBySession = new Map<string, Record<string, number>>();
|
| 636 |
for (const action of actions ?? []) {
|
| 637 |
const breakdown = breakdownBySession.get(action.session_id) ?? {};
|
|
@@ -640,10 +653,11 @@ app.get("/api/sessions-overview", async (req, res, next) => {
|
|
| 640 |
}
|
| 641 |
return res.json({ sessions: (sessions ?? []).map((row) => {
|
| 642 |
const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
|
|
|
|
| 643 |
return {
|
| 644 |
-
sessionId: row.session_id, isOwner: row.user_id === current.user.id, canDelete: isAdmin || row.user_id === current.user.id, isAdmin, byAdmin: row.by_admin, playerId: row.player_id ?? "", playerName: row.player_id ?
|
| 645 |
-
playerGender: row.player_id ?
|
| 646 |
-
playerImagePath: row.player_id ?
|
| 647 |
name: row.name, createdAt: Date.parse(row.created_at) || 0,
|
| 648 |
actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
|
| 649 |
cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
|
|
|
|
| 19 |
import { singleFlight } from "./single-flight.js";
|
| 20 |
import { requestTiming } from "./request-timing.js";
|
| 21 |
import { readBatches } from "./read-batches.js";
|
| 22 |
+
import { readPages } from "./read-pages.js";
|
| 23 |
|
| 24 |
const app = express();
|
| 25 |
app.disable("x-powered-by");
|
|
|
|
| 174 |
return current;
|
| 175 |
}
|
| 176 |
|
| 177 |
+
type ScoredAction = { action_id: string; session_id: string; type: string; annotation: { score: number | null } | null };
|
| 178 |
+
async function loadScoredActions(sessionIds: string[]) {
|
| 179 |
+
return readBatches<string, ScoredAction>(sessionIds, (batch) => readPages(async (from, to) => {
|
| 180 |
+
const { data, error } = await supabaseAdmin.from("actions")
|
| 181 |
+
.select("action_id,session_id,type,annotation:annotations(score)").in("session_id", batch)
|
| 182 |
+
.order("action_id").range(from, to);
|
| 183 |
if (error) throw new Error(`Unable to load action scores: ${error.message}`);
|
| 184 |
+
return (data ?? []) as unknown as ScoredAction[];
|
| 185 |
+
}));
|
| 186 |
}
|
| 187 |
|
| 188 |
|
|
|
|
| 253 |
const current = await authenticatedUser(req, res);
|
| 254 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 255 |
const userId = current.user.id;
|
| 256 |
+
const [isAdmin, assignmentResult] = await Promise.all([
|
| 257 |
+
userIsAdmin(userId),
|
| 258 |
+
supabaseAdmin.from("users_subscriptions")
|
| 259 |
+
.select("subscription_id,startdate,enddate,num_players,num_sessions,plan:subscriptions(subscription_id,plan,price,player_limit,session_limit)")
|
| 260 |
+
.eq("user_id", userId).maybeSingle(),
|
| 261 |
+
]);
|
| 262 |
if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
|
| 263 |
const assignment = assignmentResult.data;
|
| 264 |
let subscription = null;
|
| 265 |
if (!isAdmin && assignment?.subscription_id) {
|
| 266 |
+
const plan = assignment.plan as unknown as {
|
| 267 |
+
subscription_id: string; plan: string; price: number; player_limit: number | null; session_limit: number | null;
|
| 268 |
+
} | null;
|
| 269 |
if (plan) {
|
| 270 |
const today = new Date().toISOString().slice(0, 10);
|
| 271 |
subscription = {
|
|
|
|
| 460 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 461 |
|
| 462 |
const isAdmin = await userIsAdmin(current.user.id);
|
| 463 |
+
// These reads share only the authenticated scope, so start them together.
|
| 464 |
+
const [rows, visibleSessions] = await Promise.all([
|
| 465 |
+
readPages(async (from, to) => {
|
| 466 |
+
let query = supabaseAdmin.from("players")
|
| 467 |
+
.select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
|
| 468 |
+
.order("fname").order("player_id").range(from, to);
|
| 469 |
+
if (!isAdmin) query = query.eq("user_id", current.user.id);
|
| 470 |
+
const { data, error } = await query;
|
| 471 |
+
if (error) throw new Error(`Unable to load players: ${error.message}`);
|
| 472 |
+
return data ?? [];
|
| 473 |
+
}),
|
| 474 |
+
readPages(async (from, to) => {
|
| 475 |
+
let query = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
|
| 476 |
+
.order("created_at", { ascending: false }).order("session_id").range(from, to);
|
| 477 |
+
if (!isAdmin) query = query.eq("user_id", current.user.id);
|
| 478 |
+
const { data, error } = await query;
|
| 479 |
+
if (error) throw new Error(`Unable to load player sessions: ${error.message}`);
|
| 480 |
+
return data ?? [];
|
| 481 |
+
}),
|
| 482 |
+
]);
|
| 483 |
+
const playerIds = new Set(rows.map((row) => row.player_id));
|
| 484 |
+
const sessions = visibleSessions.filter((row) => playerIds.has(row.player_id));
|
| 485 |
|
| 486 |
const sessionIds = (sessions ?? []).map((row) => row.session_id);
|
| 487 |
+
const actions = await loadScoredActions(sessionIds);
|
| 488 |
+
const scoresByAction = new Map(actions.map((row) => [row.action_id, row.annotation?.score == null ? null : Number(row.annotation.score)]));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
const sessionsByPlayer = new Map<string, typeof sessions>();
|
| 490 |
+
for (const session of sessions ?? []) {
|
| 491 |
+
const group = sessionsByPlayer.get(session.player_id) ?? [];
|
| 492 |
+
group.push(session);
|
| 493 |
+
sessionsByPlayer.set(session.player_id, group);
|
| 494 |
+
}
|
| 495 |
const actionsBySession = new Map<string, typeof actions>();
|
| 496 |
+
for (const action of actions) {
|
| 497 |
+
const group = actionsBySession.get(action.session_id) ?? [];
|
| 498 |
+
group.push(action);
|
| 499 |
+
actionsBySession.set(action.session_id, group);
|
| 500 |
+
}
|
| 501 |
|
| 502 |
const players = (rows ?? []).map((row) => {
|
| 503 |
const playerSessions = sessionsByPlayer.get(row.player_id) ?? [];
|
|
|
|
| 548 |
const playerId = z.string().uuid().safeParse(req.params.playerId);
|
| 549 |
if (!playerId.success) return res.status(404).json({ error: "Player not found." });
|
| 550 |
const isAdmin = await userIsAdmin(current.user.id);
|
| 551 |
+
let playerQuery = supabaseAdmin.from("players").select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
|
| 552 |
.eq("player_id", playerId.data);
|
| 553 |
if (!isAdmin) playerQuery = playerQuery.eq("user_id", current.user.id);
|
| 554 |
const { data, error } = await playerQuery.maybeSingle();
|
| 555 |
if (error) throw new Error(`Unable to load player: ${error.message}`);
|
| 556 |
if (!data) return res.status(404).json({ error: "Player not found." });
|
| 557 |
|
| 558 |
+
const [overview, sessions] = await Promise.all([
|
| 559 |
+
readPages(async (from, to) => {
|
| 560 |
+
let query = supabaseAdmin.from("players").select("player_id").order("player_id").range(from, to);
|
| 561 |
+
if (!isAdmin) query = query.eq("user_id", current.user.id);
|
| 562 |
+
const { data, error } = await query;
|
| 563 |
+
if (error) throw new Error(`Unable to load players: ${error.message}`);
|
| 564 |
+
return data ?? [];
|
| 565 |
+
}),
|
| 566 |
+
readPages(async (from, to) => {
|
| 567 |
+
let query = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
|
| 568 |
+
.order("created_at", { ascending: false }).order("session_id").range(from, to);
|
| 569 |
+
if (!isAdmin) query = query.eq("user_id", current.user.id);
|
| 570 |
+
const { data, error } = await query;
|
| 571 |
+
if (error) throw new Error(`Unable to load sessions: ${error.message}`);
|
| 572 |
+
return data ?? [];
|
| 573 |
+
}),
|
| 574 |
+
]);
|
| 575 |
+
const actions = await loadScoredActions((sessions ?? []).map((row) => row.session_id));
|
| 576 |
+
const scoreMap = new Map(actions.map((row) => [row.action_id, row.annotation?.score == null ? null : Number(row.annotation.score)]));
|
| 577 |
const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
|
| 578 |
const ownSessionIds = new Set(ownSessions.map((session) => session.session_id));
|
| 579 |
const ownActions = (actions ?? []).filter((row) => ownSessionIds.has(row.session_id));
|
|
|
|
| 585 |
return Boolean(ownerId) && ownerId !== playerId.data;
|
| 586 |
});
|
| 587 |
const squadByAction = playerSkillRatings(squadActions, scoreMap);
|
| 588 |
+
const player = data;
|
| 589 |
if (!player) return res.status(404).json({ error: "Player not found." });
|
| 590 |
const sessionsForPlayer = ownSessions.map((row) => {
|
| 591 |
const sessionActions = ownActions.filter((action) => action.session_id === row.session_id);
|
|
|
|
| 596 |
for (const action of actions ?? []) {
|
| 597 |
const ownerId = sessionPlayerById.get(action.session_id);
|
| 598 |
const score = scoreMap.get(action.action_id);
|
| 599 |
+
if (ownerId && score != null && Number.isFinite(score)) {
|
| 600 |
+
const values = playerScores.get(ownerId) ?? [];
|
| 601 |
+
values.push(score);
|
| 602 |
+
playerScores.set(ownerId, values);
|
| 603 |
+
}
|
| 604 |
}
|
| 605 |
const rankedPlayers = [...playerScores.entries()]
|
| 606 |
.map(([id, values]) => ({ id, score: meanPlayerScore(values)! }))
|
|
|
|
| 630 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 631 |
|
| 632 |
const isAdmin = await userIsAdmin(current.user.id);
|
| 633 |
+
const sessions = await readPages(async (from, to) => {
|
| 634 |
+
let query = supabaseAdmin.from("sessions")
|
| 635 |
+
.select("session_id,player_id,user_id,name,path,created_at,status,by_admin,player:players(fname,lname,gender,image_path)")
|
| 636 |
+
.order("created_at", { ascending: false }).order("session_id").range(from, to);
|
| 637 |
+
if (!isAdmin) query = query.eq("user_id", current.user.id);
|
| 638 |
+
const { data, error } = await query;
|
| 639 |
+
if (error) throw new Error(`Unable to load sessions: ${error.message}`);
|
| 640 |
+
return data ?? [];
|
| 641 |
+
});
|
| 642 |
+
const actions = await readBatches(sessions.map((row) => row.session_id), (batch) => readPages(async (from, to) => {
|
| 643 |
+
const { data, error } = await supabaseAdmin.from("actions").select("action_id,session_id,type")
|
| 644 |
+
.in("session_id", batch).order("action_id").range(from, to);
|
| 645 |
+
if (error) throw new Error(`Unable to load session actions: ${error.message}`);
|
| 646 |
+
return data ?? [];
|
| 647 |
+
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
const breakdownBySession = new Map<string, Record<string, number>>();
|
| 649 |
for (const action of actions ?? []) {
|
| 650 |
const breakdown = breakdownBySession.get(action.session_id) ?? {};
|
|
|
|
| 653 |
}
|
| 654 |
return res.json({ sessions: (sessions ?? []).map((row) => {
|
| 655 |
const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
|
| 656 |
+
const player = row.player as unknown as { fname: string; lname: string | null; gender: string | null; image_path: string | null } | null;
|
| 657 |
return {
|
| 658 |
+
sessionId: row.session_id, isOwner: row.user_id === current.user.id, canDelete: isAdmin || row.user_id === current.user.id, isAdmin, byAdmin: row.by_admin, playerId: row.player_id ?? "", playerName: row.player_id ? [player?.fname, player?.lname].filter(Boolean).join(" ") : "Deleted player",
|
| 659 |
+
playerGender: row.player_id ? player?.gender ?? null : null,
|
| 660 |
+
playerImagePath: row.player_id ? player?.image_path ?? null : null,
|
| 661 |
name: row.name, createdAt: Date.parse(row.created_at) || 0,
|
| 662 |
actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
|
| 663 |
cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
|
src/subscriptions.ts
CHANGED
|
@@ -40,13 +40,16 @@ export async function assignPaidSubscription(input: AssignSubscriptionInput) {
|
|
| 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
|
|
|
|
| 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 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
|
| 51 |
}
|
| 52 |
|
|
|
|
| 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,plan:subscriptions(subscription_id,plan,price,player_limit,session_limit)")
|
| 44 |
+
.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 |
+
// The FK is many-to-one; untyped Supabase clients infer embedded rows as arrays.
|
| 49 |
+
const plan = assignment.plan as unknown as {
|
| 50 |
+
subscription_id: string; plan: string; price: number;
|
| 51 |
+
player_limit: number | null; session_limit: number | null;
|
| 52 |
+
} | null;
|
| 53 |
return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
|
| 54 |
}
|
| 55 |
|
src/supabase.ts
CHANGED
|
@@ -53,9 +53,10 @@ async function randomPasswordHash() {
|
|
| 53 |
|
| 54 |
export async function syncProfile(user: User, profile?: { fname?: string; lname?: string; academy?: string | null; role?: string | null; passwordHash?: string }) {
|
| 55 |
const metadata = user.user_metadata ?? {};
|
| 56 |
-
const { data: existing, error: readError } = await supabaseAdmin.from("users").select("user_id").eq("user_id", user.id).maybeSingle();
|
| 57 |
if (readError) throw new Error(`Unable to read user profile: ${readError.message}`);
|
| 58 |
if (existing) {
|
|
|
|
| 59 |
// The profile table is the canonical source for editable profile fields.
|
| 60 |
// Auth callbacks should only keep the email in sync, never reset user edits.
|
| 61 |
const { error } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id);
|
|
|
|
| 53 |
|
| 54 |
export async function syncProfile(user: User, profile?: { fname?: string; lname?: string; academy?: string | null; role?: string | null; passwordHash?: string }) {
|
| 55 |
const metadata = user.user_metadata ?? {};
|
| 56 |
+
const { data: existing, error: readError } = await supabaseAdmin.from("users").select("user_id,email").eq("user_id", user.id).maybeSingle();
|
| 57 |
if (readError) throw new Error(`Unable to read user profile: ${readError.message}`);
|
| 58 |
if (existing) {
|
| 59 |
+
if (existing.email === user.email) return;
|
| 60 |
// The profile table is the canonical source for editable profile fields.
|
| 61 |
// Auth callbacks should only keep the email in sync, never reset user edits.
|
| 62 |
const { error } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id);
|
tests/read-pages.test.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from "node:assert/strict";
|
| 2 |
+
import { test } from "node:test";
|
| 3 |
+
import { readPages } from "../src/read-pages.js";
|
| 4 |
+
|
| 5 |
+
test("reads beyond the upstream row cap with inclusive, ordered ranges", async () => {
|
| 6 |
+
const input = Array.from({ length: 1203 }, (_, index) => index);
|
| 7 |
+
const ranges: number[][] = [];
|
| 8 |
+
const rows = await readPages(async (from, to) => {
|
| 9 |
+
ranges.push([from, to]);
|
| 10 |
+
return input.slice(from, to + 1);
|
| 11 |
+
});
|
| 12 |
+
assert.deepEqual(rows, input);
|
| 13 |
+
assert.deepEqual(ranges, [[0, 499], [500, 999], [1000, 1499]]);
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
test("handles empty and exact page boundaries, and propagates failure", async () => {
|
| 17 |
+
assert.deepEqual(await readPages(async () => []), []);
|
| 18 |
+
let calls = 0;
|
| 19 |
+
assert.deepEqual(await readPages(async (from) => { calls++; return from ? [] : [1, 2]; }, 2), [1, 2]);
|
| 20 |
+
assert.equal(calls, 2);
|
| 21 |
+
await assert.rejects(readPages(async () => { throw new Error("offline"); }), /offline/);
|
| 22 |
+
await assert.rejects(readPages(async () => [], 0), /Invalid page size/);
|
| 23 |
+
});
|
tests/workspace-performance.test.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from "node:assert/strict";
|
| 2 |
+
import { spawn } from "node:child_process";
|
| 3 |
+
import { once } from "node:events";
|
| 4 |
+
import { createServer } from "node:http";
|
| 5 |
+
import type { AddressInfo } from "node:net";
|
| 6 |
+
import { test } from "node:test";
|
| 7 |
+
|
| 8 |
+
test("optimized workspace reads preserve authorization, scores and fresh permission checks", { timeout: 30_000 }, async () => {
|
| 9 |
+
const owner = "11111111-1111-4111-8111-111111111111";
|
| 10 |
+
const other = "22222222-2222-4222-8222-222222222222";
|
| 11 |
+
const playerId = "33333333-3333-4333-8333-333333333333";
|
| 12 |
+
const otherPlayerId = "44444444-4444-4444-8444-444444444444";
|
| 13 |
+
const calls: { method: string; url: URL }[] = [];
|
| 14 |
+
let role = "user";
|
| 15 |
+
let revoked = false;
|
| 16 |
+
let failActions = false;
|
| 17 |
+
const user = { id: owner, email: "test@example.com", email_confirmed_at: new Date().toISOString(), user_metadata: {} };
|
| 18 |
+
const plan = { subscription_id: "plan", plan: "Pro", price: 10, player_limit: 5, session_limit: 10 };
|
| 19 |
+
const player = { player_id: playerId, user_id: owner, fname: "Own", lname: "Player", bdate: "2000-01-01", gender: "male", image_path: null };
|
| 20 |
+
const sessions = [
|
| 21 |
+
{ session_id: "s1", player_id: playerId, user_id: owner, created_at: "2026-01-02T00:00:00Z", status: "completed", name: "Own session", player },
|
| 22 |
+
{ session_id: "s2", player_id: otherPlayerId, user_id: other, created_at: "2026-01-01T00:00:00Z", status: "completed", name: "Other session", player: { fname: "Other" } },
|
| 23 |
+
];
|
| 24 |
+
const actions = [
|
| 25 |
+
{ action_id: "a1", session_id: "s1", type: "Pass", annotation: { score: 7 } },
|
| 26 |
+
{ action_id: "a2", session_id: "s1", type: "Pass", annotation: null },
|
| 27 |
+
{ action_id: "a3", session_id: "s2", type: "Pass", annotation: { score: 10 } },
|
| 28 |
+
];
|
| 29 |
+
const upstream = createServer((req, res) => {
|
| 30 |
+
const url = new URL(req.url!, "http://stub");
|
| 31 |
+
calls.push({ method: req.method!, url });
|
| 32 |
+
res.setHeader("Content-Type", "application/json");
|
| 33 |
+
if (url.pathname === "/auth/v1/user") {
|
| 34 |
+
res.statusCode = revoked ? 401 : 200;
|
| 35 |
+
return res.end(JSON.stringify(revoked ? { message: "Revoked" } : user));
|
| 36 |
+
}
|
| 37 |
+
if (url.pathname === "/auth/v1/token") {
|
| 38 |
+
return res.end(JSON.stringify({ user, access_token: "access", refresh_token: "refresh", expires_in: 3600, token_type: "bearer" }));
|
| 39 |
+
}
|
| 40 |
+
const table = url.pathname.split("/").at(-1);
|
| 41 |
+
if (table === "actions" && failActions) {
|
| 42 |
+
res.statusCode = 400;
|
| 43 |
+
return res.end(JSON.stringify({ message: "Unavailable" }));
|
| 44 |
+
}
|
| 45 |
+
let rows: Record<string, unknown>[] = table === "users" ? [{ user_id: owner, email: user.email, account_role: role }]
|
| 46 |
+
: table === "users_subscriptions" ? [{ user_id: owner, subscription_id: "plan", startdate: "2000-01-01", enddate: "2099-01-01", num_players: 1, num_sessions: 1, plan }]
|
| 47 |
+
: table === "players" ? [player, { player_id: otherPlayerId, user_id: other, fname: "Other" }]
|
| 48 |
+
: table === "sessions" ? sessions : table === "actions" ? actions : [];
|
| 49 |
+
for (const [key, filter] of url.searchParams) {
|
| 50 |
+
if (filter.startsWith("eq.")) rows = rows.filter((row) => String(row[key]) === filter.slice(3));
|
| 51 |
+
if (filter.startsWith("in.(")) {
|
| 52 |
+
const values = filter.slice(4, -1).split(",");
|
| 53 |
+
rows = rows.filter((row) => values.includes(String(row[key])));
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
const from = Number(url.searchParams.get("offset") ?? 0);
|
| 57 |
+
const limit = Number(url.searchParams.get("limit") ?? rows.length);
|
| 58 |
+
res.end(JSON.stringify(rows.slice(from, from + limit)));
|
| 59 |
+
});
|
| 60 |
+
upstream.listen(0, "127.0.0.1");
|
| 61 |
+
await once(upstream, "listening");
|
| 62 |
+
const portProbe = createServer();
|
| 63 |
+
portProbe.listen(0, "127.0.0.1");
|
| 64 |
+
await once(portProbe, "listening");
|
| 65 |
+
const port = (portProbe.address() as AddressInfo).port;
|
| 66 |
+
await new Promise<void>((resolve) => portProbe.close(() => resolve()));
|
| 67 |
+
const origin = `http://127.0.0.1:${port}`;
|
| 68 |
+
const child = spawn(process.execPath, ["--import", "tsx", "src/server.ts"], {
|
| 69 |
+
env: { ...process.env, NODE_ENV: "test", PORT: String(port), FRONTEND_URL: origin, BACKEND_URL: origin,
|
| 70 |
+
SUPABASE_URL: `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`,
|
| 71 |
+
SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_PROXY_SECRET: "x".repeat(32) },
|
| 72 |
+
stdio: ["ignore", "pipe", "pipe"],
|
| 73 |
+
});
|
| 74 |
+
try {
|
| 75 |
+
await new Promise<void>((resolve, reject) => {
|
| 76 |
+
child.stdout.on("data", (data) => { if (String(data).includes("API listening")) resolve(); });
|
| 77 |
+
child.once("exit", (code) => reject(new Error(`API exited: ${code}`)));
|
| 78 |
+
child.once("error", reject);
|
| 79 |
+
});
|
| 80 |
+
const request = (path: string, init: RequestInit = {}) => fetch(`${origin}${path}`, {
|
| 81 |
+
...init, signal: AbortSignal.timeout(10000), headers: { Cookie: "sp_access=access", Origin: origin, ...init.headers },
|
| 82 |
+
}).catch((error) => { throw new Error(`Request failed: ${path}`, { cause: error }); });
|
| 83 |
+
assert.equal((await fetch(`${origin}/api/players-overview`)).status, 401);
|
| 84 |
+
const meStart = calls.length;
|
| 85 |
+
const me = await (await request("/api/auth/me")).json();
|
| 86 |
+
assert.equal(me.access.active, true);
|
| 87 |
+
assert.equal(me.access.subscription.plan, "Pro");
|
| 88 |
+
assert.equal(calls.length - meStart, 3); // auth + role + joined assignment/plan
|
| 89 |
+
const rosterResponse = await request("/api/players-overview");
|
| 90 |
+
assert.equal(rosterResponse.status, 200);
|
| 91 |
+
const roster = (await rosterResponse.json()).players;
|
| 92 |
+
assert.equal(roster.length, 1);
|
| 93 |
+
assert.equal(roster[0].scores.overall, 70);
|
| 94 |
+
assert.equal(roster[0].actionCount, 2);
|
| 95 |
+
assert.equal(roster[0].scores.scoredActions, 1);
|
| 96 |
+
const list = (await (await request("/api/sessions-overview")).json()).sessions;
|
| 97 |
+
assert.equal(list.length, 1);
|
| 98 |
+
assert.equal(list[0].playerName, "Own Player");
|
| 99 |
+
assert.equal(list[0].actionBreakdown.Pass, 2);
|
| 100 |
+
const detail = await (await request(`/api/players-overview/${playerId}`)).json();
|
| 101 |
+
assert.equal(detail.scores.overall, 70);
|
| 102 |
+
assert.equal(detail.playerCount, 1);
|
| 103 |
+
assert.equal((await request(`/api/players-overview/${otherPlayerId}`)).status, 404);
|
| 104 |
+
const billing = await (await request("/api/plan-billing")).json();
|
| 105 |
+
assert.equal(billing.subscription.name, "Pro");
|
| 106 |
+
assert.equal(billing.usage.sessions, 1);
|
| 107 |
+
assert.ok(!calls.some(({ url }) => url.pathname.endsWith("/annotations")));
|
| 108 |
+
const login = await request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: user.email, password: "password123" }) });
|
| 109 |
+
assert.equal(login.status, 200);
|
| 110 |
+
assert.ok(!calls.some(({ method }) => method === "PATCH"));
|
| 111 |
+
role = "admin";
|
| 112 |
+
const adminPlayers = await (await request("/api/admin/players")).json();
|
| 113 |
+
assert.equal(adminPlayers.players.find((row: { player_id: string }) => row.player_id === playerId).sessionCount, 1);
|
| 114 |
+
const adminSessions = await (await request("/api/admin/sessions")).json();
|
| 115 |
+
assert.equal(adminSessions.sessions.find((row: { session_id: string }) => row.session_id === "s1").actionCount, 2);
|
| 116 |
+
const adminUsers = await (await request("/api/admin/users")).json();
|
| 117 |
+
assert.equal(adminUsers.users[0].dataCounts.sessions, 1);
|
| 118 |
+
const adminRows = (await (await request("/api/players-overview")).json()).players;
|
| 119 |
+
assert.equal(adminRows.length, 2);
|
| 120 |
+
role = "user";
|
| 121 |
+
assert.equal((await (await request("/api/players-overview")).json()).players.length, 1);
|
| 122 |
+
failActions = true;
|
| 123 |
+
assert.equal((await request("/api/sessions-overview")).status, 500);
|
| 124 |
+
revoked = true;
|
| 125 |
+
assert.equal((await request("/api/sessions-overview")).status, 401);
|
| 126 |
+
} finally {
|
| 127 |
+
if (child.exitCode === null && child.signalCode === null) {
|
| 128 |
+
const exited = once(child, "exit");
|
| 129 |
+
child.kill();
|
| 130 |
+
await exited;
|
| 131 |
+
}
|
| 132 |
+
upstream.closeAllConnections();
|
| 133 |
+
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
| 134 |
+
}
|
| 135 |
+
});
|