Shoraky commited on
Commit
2cd157b
·
verified ·
1 Parent(s): 08f64c8

Allow signed-in users to read shared sessions

Browse files
Files changed (5) hide show
  1. README.md +1 -1
  2. package.json +30 -30
  3. src/analysis.ts +1 -1
  4. src/subscriptions.ts +72 -72
  5. start.py +55 -55
README.md CHANGED
@@ -43,7 +43,7 @@ Node health: `GET /health`. AI health is internal at `http://127.0.0.1:8000/heal
43
 
44
  Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
45
 
46
- Apply `../Database/migrations/20261016_node_analysis.sql` before analyses. It assumes the legacy actions table is empty. Secret-protected `/internal/analysis` routes authorize sessions and persist completion/failure.
47
 
48
  ## Supabase dashboard setup
49
 
 
43
 
44
  Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
45
 
46
+ Apply `../Database/migrations/20261016_node_analysis.sql` before analyses. It assumes the legacy actions table is empty. Apply `../Database/migrations/20261016_action_reviews.sql` to enable saving coach scores and per-action annotation notes. Secret-protected `/internal/analysis` routes authorize sessions and persist completion/failure.
47
 
48
  ## Supabase dashboard setup
49
 
package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "sporalize-backend",
3
- "private": true,
4
- "version": "1.0.0",
5
- "type": "module",
6
- "scripts": {
7
- "dev": "tsx watch src/server.ts",
8
- "build": "tsc -p tsconfig.json",
9
- "start": "python start.py"
10
- },
11
- "dependencies": {
12
- "@supabase/supabase-js": "^2.57.0",
13
- "bcryptjs": "^3.0.2",
14
- "cookie-parser": "^1.4.7",
15
- "cors": "^2.8.5",
16
- "dotenv": "^16.6.1",
17
- "express": "^5.1.0",
18
- "express-rate-limit": "^8.1.0",
19
- "helmet": "^8.1.0",
20
- "zod": "^4.1.5"
21
- },
22
- "devDependencies": {
23
- "@types/cookie-parser": "^1.4.9",
24
- "@types/cors": "^2.8.19",
25
- "@types/express": "^5.0.3",
26
- "@types/node": "^22.16.5",
27
- "tsx": "^4.20.5",
28
- "typescript": "^5.8.3"
29
- }
30
- }
 
1
+ {
2
+ "name": "sporalize-backend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx watch src/server.ts",
8
+ "build": "tsc -p tsconfig.json",
9
+ "start": "python start.py"
10
+ },
11
+ "dependencies": {
12
+ "@supabase/supabase-js": "^2.57.0",
13
+ "bcryptjs": "^3.0.2",
14
+ "cookie-parser": "^1.4.7",
15
+ "cors": "^2.8.5",
16
+ "dotenv": "^16.6.1",
17
+ "express": "^5.1.0",
18
+ "express-rate-limit": "^8.1.0",
19
+ "helmet": "^8.1.0",
20
+ "zod": "^4.1.5"
21
+ },
22
+ "devDependencies": {
23
+ "@types/cookie-parser": "^1.4.9",
24
+ "@types/cors": "^2.8.19",
25
+ "@types/express": "^5.0.3",
26
+ "@types/node": "^22.16.5",
27
+ "tsx": "^4.20.5",
28
+ "typescript": "^5.8.3"
29
+ }
30
+ }
src/analysis.ts CHANGED
@@ -62,7 +62,7 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
62
  try {
63
  const sessionId = z.uuid().parse(req.params.sessionId);
64
  const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
65
- .eq("session_id", sessionId).eq("user_id", res.locals.userId).maybeSingle();
66
  if (error) return res.status(503).json({ error: "Unable to load session" });
67
  return data ? res.json(data) : res.sendStatus(404);
68
  } catch (error) { next(error); }
 
62
  try {
63
  const sessionId = z.uuid().parse(req.params.sessionId);
64
  const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
65
+ .eq("session_id", sessionId).maybeSingle();
66
  if (error) return res.status(503).json({ error: "Unable to load session" });
67
  return data ? res.json(data) : res.sendStatus(404);
68
  } catch (error) { next(error); }
src/subscriptions.ts CHANGED
@@ -1,72 +1,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
- accessCache.delete(userId);
37
- accessRequests.delete(userId);
38
- return data;
39
- }
40
-
41
- async function loadActiveSubscription(userId: string) {
42
- const today = new Date().toISOString().slice(0, 10);
43
- const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
44
- .select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
45
- if (error) throw new Error(`Unable to check subscription: ${error.message}`);
46
- if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
47
-
48
- const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
49
- .select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
50
- if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
51
- return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
52
- }
53
-
54
- // Brief, per-user cache shared by polling, media and session requests.
55
- const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
56
- const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
57
- export async function activeSubscriptionFor(userId: string) {
58
- const cached = accessCache.get(userId);
59
- if (cached && cached.expires > Date.now()) return cached.value;
60
- const existing = accessRequests.get(userId);
61
- if (existing) return existing;
62
- const pending = loadActiveSubscription(userId).then((value) => {
63
- if (accessRequests.get(userId) === pending) {
64
- if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
65
- const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
66
- accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
67
- }
68
- return value;
69
- }).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
70
- accessRequests.set(userId, pending);
71
- return pending;
72
- }
 
1
+ import { supabaseAdmin } from "./supabase.js";
2
+
3
+ type AssignSubscriptionInput = {
4
+ userId: string;
5
+ subscriptionId: string;
6
+ startDate: string;
7
+ endDate: string;
8
+ };
9
+
10
+ /**
11
+ * Persist a paid subscription after a payment provider's webhook has been
12
+ * authenticated and its payment has been confirmed. Never call this directly
13
+ * from a browser request.
14
+ */
15
+ export async function assignPaidSubscription(input: AssignSubscriptionInput) {
16
+ const { userId, subscriptionId, startDate, endDate } = input;
17
+ const isoDate = /^\d{4}-\d{2}-\d{2}$/;
18
+ if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
19
+ throw new Error("Invalid subscription period.");
20
+ }
21
+
22
+ const { data: plan, error: planError } = await supabaseAdmin
23
+ .from("subscriptions")
24
+ .select("subscription_id")
25
+ .eq("subscription_id", subscriptionId)
26
+ .maybeSingle();
27
+ if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
28
+ if (!plan) throw new Error("Subscription plan was not found.");
29
+
30
+ const { data, error } = await supabaseAdmin
31
+ .from("users_subscriptions")
32
+ .upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
33
+ .select("user_id,subscription_id,startdate,enddate")
34
+ .single();
35
+ if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
36
+ accessCache.delete(userId);
37
+ accessRequests.delete(userId);
38
+ return data;
39
+ }
40
+
41
+ async function loadActiveSubscription(userId: string) {
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
44
+ .select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
45
+ if (error) throw new Error(`Unable to check subscription: ${error.message}`);
46
+ if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
47
+
48
+ const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
49
+ .select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
50
+ if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
51
+ return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
52
+ }
53
+
54
+ // Brief, per-user cache shared by polling, media and session requests.
55
+ const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
56
+ const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
57
+ export async function activeSubscriptionFor(userId: string) {
58
+ const cached = accessCache.get(userId);
59
+ if (cached && cached.expires > Date.now()) return cached.value;
60
+ const existing = accessRequests.get(userId);
61
+ if (existing) return existing;
62
+ const pending = loadActiveSubscription(userId).then((value) => {
63
+ if (accessRequests.get(userId) === pending) {
64
+ if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
65
+ const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
66
+ accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
67
+ }
68
+ return value;
69
+ }).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
70
+ accessRequests.set(userId, pending);
71
+ return pending;
72
+ }
start.py CHANGED
@@ -1,55 +1,55 @@
1
- """Run the public Node API and private Python worker as one service."""
2
- import os
3
- from pathlib import Path
4
- import signal
5
- import subprocess
6
- import sys
7
- import time
8
-
9
- from dotenv import load_dotenv
10
-
11
-
12
- def main():
13
- root = Path(__file__).resolve().parent
14
- load_dotenv(root / ".env")
15
- port = int(os.environ.get("PORT", "4000"))
16
- if not 1 <= port <= 65535 or port == 8000:
17
- raise ValueError("PORT must be between 1 and 65535 and differ from AI port 8000")
18
- env = dict(os.environ, PORT=str(port),
19
- ANALYSIS_API_URL="http://127.0.0.1:8000",
20
- BACKEND_INTERNAL_URL=f"http://127.0.0.1:{port}")
21
- children = []
22
- stopping = False
23
-
24
- def stop(signum, frame):
25
- nonlocal stopping
26
- stopping = True
27
-
28
- for sig in (signal.SIGINT, signal.SIGTERM):
29
- signal.signal(sig, stop)
30
- try:
31
- children.append(subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
32
- "--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env))
33
- children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
34
- while not stopping:
35
- for child in children:
36
- if child.poll() is not None:
37
- print(f"Backend process {child.pid} exited ({child.returncode}); stopping service", flush=True)
38
- return child.returncode or 1
39
- time.sleep(0.25)
40
- return 0
41
- finally:
42
- for child in children:
43
- if child.poll() is None:
44
- child.terminate()
45
- deadline = time.monotonic() + 15
46
- for child in children:
47
- try:
48
- child.wait(timeout=max(0, deadline - time.monotonic()))
49
- except subprocess.TimeoutExpired:
50
- child.kill()
51
- child.wait()
52
-
53
-
54
- if __name__ == "__main__":
55
- sys.exit(main())
 
1
+ """Run the public Node API and private Python worker as one service."""
2
+ import os
3
+ from pathlib import Path
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import time
8
+
9
+ from dotenv import load_dotenv
10
+
11
+
12
+ def main():
13
+ root = Path(__file__).resolve().parent
14
+ load_dotenv(root / ".env")
15
+ port = int(os.environ.get("PORT", "4000"))
16
+ if not 1 <= port <= 65535 or port == 8000:
17
+ raise ValueError("PORT must be between 1 and 65535 and differ from AI port 8000")
18
+ env = dict(os.environ, PORT=str(port),
19
+ ANALYSIS_API_URL="http://127.0.0.1:8000",
20
+ BACKEND_INTERNAL_URL=f"http://127.0.0.1:{port}")
21
+ children = []
22
+ stopping = False
23
+
24
+ def stop(signum, frame):
25
+ nonlocal stopping
26
+ stopping = True
27
+
28
+ for sig in (signal.SIGINT, signal.SIGTERM):
29
+ signal.signal(sig, stop)
30
+ try:
31
+ children.append(subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
32
+ "--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env))
33
+ children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
34
+ while not stopping:
35
+ for child in children:
36
+ if child.poll() is not None:
37
+ print(f"Backend process {child.pid} exited ({child.returncode}); stopping service", flush=True)
38
+ return child.returncode or 1
39
+ time.sleep(0.25)
40
+ return 0
41
+ finally:
42
+ for child in children:
43
+ if child.poll() is None:
44
+ child.terminate()
45
+ deadline = time.monotonic() + 15
46
+ for child in children:
47
+ try:
48
+ child.wait(timeout=max(0, deadline - time.monotonic()))
49
+ except subprocess.TimeoutExpired:
50
+ child.kill()
51
+ child.wait()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())