Spaces:
Running
Running
File size: 15,836 Bytes
ce4b4fb 48226b7 10ace60 ce4b4fb 99cf4c0 ce4b4fb d1e2705 ce4b4fb d1e2705 ce4b4fb d1e2705 ce4b4fb d1e2705 ce4b4fb 89c7f9c ce4b4fb d1ceac4 ce4b4fb 10ace60 d1ceac4 10ace60 d1ceac4 e2ead0c d1ceac4 e2ead0c 35962e4 d1ceac4 99cf4c0 61f39da d1ceac4 99cf4c0 7bc8a26 d1ceac4 0ffa20c 61f39da 0ffa20c 61f39da d1ceac4 61f39da d1ceac4 ce4b4fb 10ace60 99cf4c0 ce4b4fb ef7986f 99cf4c0 ef7986f 61f39da ce4b4fb 61f39da ce4b4fb d1e2705 a66365f d1e2705 ce4b4fb 692e287 61f39da d1ceac4 692e287 61f39da 692e287 d1ceac4 d1e2705 d1ceac4 d1e2705 61f39da d1ceac4 d1e2705 d1ceac4 d1e2705 d1ceac4 692e287 d1ceac4 692e287 d1ceac4 692e287 ce4b4fb d1ceac4 61f39da d1ceac4 d1e2705 d1ceac4 61f39da d1ceac4 ce4b4fb ef7986f 89c7f9c 61f39da 89c7f9c ce4b4fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 | import { createHash, timingSafeEqual } from "node:crypto";
import express from "express";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { env } from "./config.js";
import { activeSubscriptionFor } from "./subscriptions.js";
export function stableId(value: string) {
const hex = createHash("sha256").update(value).digest("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
const beginSchema = z.object({ clientId: z.uuid(), playerId: z.uuid(), name: z.string().trim().max(150) });
const reportSchema = z.object({
index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
start_time_seconds: z.number().finite().nonnegative().optional(),
end_time_seconds: z.number().finite().positive().optional(),
error: z.string().max(2000).optional(),
});
export const completionSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("completed"), path: z.string(), actions: z.array(reportSchema).max(5000) }),
z.object({ status: z.enum(["failed", "cancelled"]) }),
]);
export function actionRows(sessionId: string, reports: z.infer<typeof reportSchema>[]) {
if (new Set(reports.map((report) => report.index)).size !== reports.length) throw new Error("Duplicate action index");
return reports.filter((report) => report.error === undefined).map((report) => {
const { start_frame: startFrame, end_frame: endFrame,
start_time_seconds: startTime, end_time_seconds: endTime } = report;
if (startFrame === undefined || endFrame === undefined || endFrame < startFrame) throw new Error("Invalid action interval");
if ((startTime === undefined) !== (endTime === undefined) ||
(startTime !== undefined && endTime !== undefined && endTime <= startTime)) throw new Error("Invalid action playback times");
return { action_id: stableId(`${sessionId}:${report.index}`), type: report.action,
start_frame: startFrame, end_frame: endFrame,
start_time_seconds: startTime ?? null, end_time_seconds: endTime ?? null };
});
}
export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?: (sessionId: string, userId: string) => Promise<void>) {
const router = express.Router();
router.use((req, res, next) => {
const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
const expected = Buffer.from(secret);
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return res.sendStatus(403);
const user = z.uuid().safeParse(req.header("x-sporalize-user-id"));
if (!user.success) return res.sendStatus(403);
res.locals.userId = user.data;
res.locals.isAdmin = req.header("x-sporalize-admin") === "true";
next();
});
router.use(express.json({ limit: "2mb" }));
router.post("/", async (req, res, next) => {
try {
const input = beginSchema.parse(req.body);
const userId = res.locals.userId as string;
const isAdmin = res.locals.isAdmin === true;
const subscription = isAdmin ? null : await activeSubscriptionFor(userId, { refresh: true });
if (!isAdmin && !subscription) {
return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
}
if (!isAdmin) {
const { data: usage, error: usageError } = await db.from("users_subscriptions")
.select("num_sessions").eq("user_id", userId).maybeSingle();
if (usageError) return res.status(503).json({ error: "Unable to check your session quota." });
const usedSessions = Number(usage?.num_sessions ?? 0);
const sessionLimit = subscription?.session_limit == null ? null : Number(subscription.session_limit);
if (sessionLimit !== null && usedSessions >= sessionLimit) {
return res.status(403).json({
error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.",
code: "quota_exceeded", resource: "sessions", used: usedSessions, limit: sessionLimit,
});
}
}
let playerQuery = db.from("players").select("player_id,user_id").eq("player_id", input.playerId);
if (!isAdmin) playerQuery = playerQuery.eq("user_id", userId);
const { data: player, error: playerError } = await playerQuery.maybeSingle();
if (playerError) return res.status(503).json({ error: "Unable to verify the selected player." });
if (!player) return res.status(404).json({ error: isAdmin
? "The selected player was not found. Refresh the player list and try again."
: "Choose a player from your own roster before starting analysis." });
if (isAdmin) {
const { error } = await db.rpc("admin_create_analysis_session", {
p_session_id: input.clientId,
p_user_id: player.user_id,
p_player_id: input.playerId,
p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
p_path: `sessions/${input.clientId}`,
p_by_admin: player.user_id !== userId,
});
if (error) return res.status(503).json({ error: "Unable to prepare analysis session." });
return res.json({ session_id: input.clientId, player_id: input.playerId, user_id: player.user_id, by_admin: player.user_id !== userId, actor_is_admin: true });
}
const { data, error } = await db.rpc("begin_analysis", {
p_session_id: input.clientId, p_user_id: userId,
p_player_id: input.playerId,
p_reference: null,
p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
});
if (error) {
console.error("Unable to prepare analysis session", { code: error.code, message: error.message });
if (error.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
return res.status(403).json({ error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.", code: "quota_exceeded", resource: "sessions" });
}
if (error.code === "P0001" && error.message.includes("subscription_required")) {
return res.status(402).json({ error: "An active subscription is required to start an analysis.", code: "subscription_required" });
}
return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to prepare analysis session" });
}
return res.json({ ...data, user_id: userId, by_admin: false, actor_is_admin: false });
} catch (error) { next(error); }
});
router.get("/:sessionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
let query = db.from("sessions").select("session_id,player_id,user_id,path,status,name,created_at,by_admin")
.eq("session_id", sessionId);
if (res.locals.isAdmin !== true) query = query.eq("user_id", res.locals.userId);
const { data, error } = await query.maybeSingle();
if (error) return res.status(503).json({ error: "Unable to load session" });
if (!data) return res.sendStatus(404);
if (req.query.playbackTimes === "false") return res.json(data);
const { data: actionTimes, error: timesError } = await db.from("actions")
.select("action_id,start_time_seconds,end_time_seconds").eq("session_id", sessionId);
if (timesError) {
console.error("Unable to load action playback times", { sessionId, code: timesError.code, message: timesError.message });
}
return res.json({ ...data, action_times: actionTimes ?? [] });
} catch (error) { next(error); }
});
router.delete("/:sessionId/actions/:actionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
const actionId = z.uuid().parse(req.params.actionId);
const userId = res.locals.userId as string;
let sessionQuery = db.from("sessions").select("session_id,status").eq("session_id", sessionId);
if (res.locals.isAdmin !== true) sessionQuery = sessionQuery.eq("user_id", userId);
const { data: session, error: sessionError } = await sessionQuery.maybeSingle();
if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
if (!session) return res.status(404).json({ error: "Session not found." });
if (["pending", "uploading", "queued", "running"].includes(session.status)) return res.status(409).json({ error: "Actions cannot be deleted while analysis is running." });
const { data: action, error } = await db.from("actions").delete()
.eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
if (error) return res.status(503).json({ error: "Unable to delete action." });
if (!action) return res.status(404).json({ error: "Action not found." });
return res.sendStatus(204);
} catch (error) { next(error); }
});
router.patch("/:sessionId/actions/:actionId", async (req, res, next) => {
try {
if (res.locals.isAdmin !== true) return res.sendStatus(403);
const sessionId = z.uuid().parse(req.params.sessionId);
const actionId = z.uuid().parse(req.params.actionId);
const input = z.object({
startFrame: z.number().int().nonnegative(),
endFrame: z.number().int().nonnegative(),
type: z.string().trim().min(1).max(50),
startTimeSeconds: z.number().finite().nonnegative(),
endTimeSeconds: z.number().finite().positive(),
}).strict().parse(req.body);
if (input.endFrame < input.startFrame) return res.status(400).json({ error: "Action end frame must be after its start frame." });
if (input.endTimeSeconds <= input.startTimeSeconds) return res.status(400).json({ error: "Action playback interval is invalid." });
const { data: session } = await db.from("sessions").select("status").eq("session_id", sessionId).maybeSingle();
if (!session || ["pending", "uploading", "queued", "running"].includes(session.status)) return res.status(409).json({ error: "Actions cannot be edited while analysis is running." });
const { data, error } = await db.from("actions").update({
start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
start_time_seconds: input.startTimeSeconds, end_time_seconds: input.endTimeSeconds,
}).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
if (error && !["42703", "PGRST204"].includes(error.code)) return res.status(503).json({ error: "Unable to update action." });
let updated = data;
if (error) {
const fallback = await db.from("actions").update({
start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
}).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
if (fallback.error) return res.status(503).json({ error: "Unable to update action." });
updated = fallback.data;
}
if (!updated) return res.sendStatus(404);
return res.json({ ok: true });
} catch (error) { next(error); }
});
router.delete("/:sessionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
const userId = res.locals.userId as string;
let sessionQuery = db.from("sessions").select("session_id").eq("session_id", sessionId);
if (res.locals.isAdmin !== true) sessionQuery = sessionQuery.eq("user_id", userId);
const { data: session, error: sessionError } = await sessionQuery.maybeSingle();
if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
if (!session) return res.status(404).json({ error: "Session not found." });
// The schema keeps actions when a session row is removed, so explicitly
// delete them first. Their annotations and feedback cascade from actions.
const { error: actionsError } = await db.from("actions").delete().eq("session_id", sessionId);
if (actionsError) return res.status(503).json({ error: "Unable to remove session actions." });
let deleteQuery = db.from("sessions").delete().eq("session_id", sessionId);
if (res.locals.isAdmin !== true) deleteQuery = deleteQuery.eq("user_id", userId);
const { data: deleted, error } = await deleteQuery.select("session_id").maybeSingle();
if (error) return res.status(503).json({ error: "Unable to delete session." });
if (!deleted) return res.status(404).json({ error: "Session not found." });
return res.sendStatus(204);
} catch (error) { next(error); }
});
router.put("/:sessionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
const input = completionSchema.parse(req.body);
if (input.status === "completed" && input.path !== `sessions/${sessionId}`) return res.sendStatus(400);
let actions;
try { actions = input.status === "completed" ? actionRows(sessionId, input.actions) : []; }
catch { return res.status(400).json({ error: "Invalid action details" }); }
if (res.locals.isAdmin === true) {
const { data: session, error: sessionError } = await db.from("sessions").update({
status: input.status,
...(input.status === "completed" ? { path: input.path } : {}),
}).eq("session_id", sessionId).select("session_id,user_id").maybeSingle();
if (sessionError) return res.status(503).json({ error: "Unable to save analysis session." });
if (!session) return res.sendStatus(404);
if (input.status === "completed") {
const rows = actions.map((action) => ({ ...action, session_id: sessionId }));
if (rows.length) {
const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
if (error && !["42703", "PGRST204"].includes(error.code)) return res.status(503).json({ error: "Unable to save analysis actions." });
if (error) {
const legacyRows = rows.map(({ start_time_seconds: _start, end_time_seconds: _end, ...row }) => row);
const fallback = await db.from("actions").upsert(legacyRows, { onConflict: "action_id" });
if (fallback.error) return res.status(503).json({ error: "Unable to save analysis actions." });
}
}
if (onCompleted && session.user_id) void onCompleted(sessionId, session.user_id).catch((error) => console.error("Automatic Agent feedback generation failed", { sessionId, error }));
}
return res.json({ ok: true });
}
const { error } = await db.rpc("finish_analysis", {
p_session_id: sessionId, p_user_id: res.locals.userId, p_status: input.status,
p_path: input.status === "completed" ? input.path : null, p_actions: actions,
});
if (error) {
console.error("Unable to save analysis results", { code: error.code, message: error.message });
return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
}
if (input.status === "completed" && onCompleted) {
const { data: completedSession } = await db.from("sessions").select("user_id").eq("session_id", sessionId).maybeSingle();
if (completedSession?.user_id) void onCompleted(sessionId, completedSession.user_id).catch((generationError) => console.error("Automatic Agent feedback generation failed", { sessionId, generationError }));
}
return res.json({ ok: true });
} catch (error) { next(error); }
});
return router;
}
|