Spaces:
Running
Running
Add admin management console and APIs
Browse files- app.py +56 -8
- src/admin.ts +343 -0
- src/analysis.ts +72 -20
- src/server.ts +52 -12
- src/subscriptions.ts +75 -72
- 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
|
| 50 |
-
|
|
|
|
| 51 |
return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
|
| 52 |
}
|
| 53 |
-
|
| 54 |
-
.
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 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 |
-
|
| 102 |
-
|
|
|
|
| 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 |
-
|
| 118 |
-
|
|
|
|
| 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 |
-
|
| 127 |
-
|
|
|
|
| 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
|
| 163 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|
| 738 |
-
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
if (
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
const
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 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 |
}
|