Spaces:
Running
Running
Track subscription usage and gate new analyses
Browse files- src/analysis.ts +7 -2
- src/server.ts +15 -13
src/analysis.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
|
| 2 |
import express from "express";
|
| 3 |
import type { SupabaseClient } from "@supabase/supabase-js";
|
| 4 |
import { z } from "zod";
|
|
|
|
| 5 |
|
| 6 |
export function stableId(value: string) {
|
| 7 |
const hex = createHash("sha256").update(value).digest("hex");
|
|
@@ -44,10 +45,14 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
|
|
| 44 |
router.post("/", async (req, res, next) => {
|
| 45 |
try {
|
| 46 |
const input = beginSchema.parse(req.body);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
const playerId = z.uuid().safeParse(input.playerId);
|
| 48 |
const { data, error } = await db.rpc("begin_analysis", {
|
| 49 |
-
p_session_id: input.clientId, p_user_id:
|
| 50 |
-
p_player_id: playerId.success ? playerId.data : stableId(`${
|
| 51 |
p_reference: playerId.success ? null : input.playerId,
|
| 52 |
p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
|
| 53 |
});
|
|
|
|
| 2 |
import express from "express";
|
| 3 |
import type { SupabaseClient } from "@supabase/supabase-js";
|
| 4 |
import { z } from "zod";
|
| 5 |
+
import { activeSubscriptionFor } from "./subscriptions.js";
|
| 6 |
|
| 7 |
export function stableId(value: string) {
|
| 8 |
const hex = createHash("sha256").update(value).digest("hex");
|
|
|
|
| 45 |
router.post("/", async (req, res, next) => {
|
| 46 |
try {
|
| 47 |
const input = beginSchema.parse(req.body);
|
| 48 |
+
const userId = res.locals.userId as string;
|
| 49 |
+
if (!await activeSubscriptionFor(userId, { refresh: true })) {
|
| 50 |
+
return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
|
| 51 |
+
}
|
| 52 |
const playerId = z.uuid().safeParse(input.playerId);
|
| 53 |
const { data, error } = await db.rpc("begin_analysis", {
|
| 54 |
+
p_session_id: input.clientId, p_user_id: userId,
|
| 55 |
+
p_player_id: playerId.success ? playerId.data : stableId(`${userId}:player:${input.playerId}`),
|
| 56 |
p_reference: playerId.success ? null : input.playerId,
|
| 57 |
p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
|
| 58 |
});
|
src/server.ts
CHANGED
|
@@ -114,7 +114,7 @@ async function requireActiveSubscription(req: Request, res: Response) {
|
|
| 114 |
res.status(401).json({ error: "Authentication required." });
|
| 115 |
return null;
|
| 116 |
}
|
| 117 |
-
if (!await activeSubscriptionFor(current.user.id)) {
|
| 118 |
res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
|
| 119 |
return null;
|
| 120 |
}
|
|
@@ -153,14 +153,9 @@ app.get("/api/plan-billing", async (req, res, next) => {
|
|
| 153 |
const current = await authenticatedUser(req, res);
|
| 154 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 155 |
const userId = current.user.id;
|
| 156 |
-
const
|
| 157 |
-
|
| 158 |
-
supabaseAdmin.from("players").select("player_id", { count: "exact", head: true }).eq("user_id", userId),
|
| 159 |
-
supabaseAdmin.from("sessions").select("session_id", { count: "exact", head: true }).eq("user_id", userId),
|
| 160 |
-
]);
|
| 161 |
if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
|
| 162 |
-
if (playersResult.error) throw new Error(`Unable to count players: ${playersResult.error.message}`);
|
| 163 |
-
if (sessionsResult.error) throw new Error(`Unable to count sessions: ${sessionsResult.error.message}`);
|
| 164 |
const assignment = assignmentResult.data;
|
| 165 |
let subscription = null;
|
| 166 |
if (assignment?.subscription_id) {
|
|
@@ -180,7 +175,7 @@ app.get("/api/plan-billing", async (req, res, next) => {
|
|
| 180 |
}
|
| 181 |
}
|
| 182 |
res.set("Cache-Control", "no-store");
|
| 183 |
-
return res.json({ subscription, usage: { players:
|
| 184 |
} catch (error) { next(error); }
|
| 185 |
});
|
| 186 |
|
|
@@ -421,8 +416,8 @@ app.get("/api/sessions-overview", async (req, res, next) => {
|
|
| 421 |
|
| 422 |
app.get("/api/sessions/:sessionId/reviews", async (req, res, next) => {
|
| 423 |
try {
|
| 424 |
-
const current = await
|
| 425 |
-
if (!current) return;
|
| 426 |
const sessionId = z.uuid().safeParse(req.params.sessionId);
|
| 427 |
if (!sessionId.success) return res.sendStatus(404);
|
| 428 |
|
|
@@ -732,8 +727,15 @@ app.delete("/api/auth/account", async (req, res, next) => {
|
|
| 732 |
});
|
| 733 |
|
| 734 |
async function proxyAnalysisRequest(req: Request, res: Response) {
|
| 735 |
-
const current = await
|
| 736 |
-
if (!current) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
|
| 738 |
const target = new URL(req.originalUrl, env.ANALYSIS_API_URL);
|
| 739 |
const headers = new Headers();
|
|
|
|
| 114 |
res.status(401).json({ error: "Authentication required." });
|
| 115 |
return null;
|
| 116 |
}
|
| 117 |
+
if (!await activeSubscriptionFor(current.user.id, { refresh: true })) {
|
| 118 |
res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
|
| 119 |
return null;
|
| 120 |
}
|
|
|
|
| 153 |
const current = await authenticatedUser(req, res);
|
| 154 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 155 |
const userId = current.user.id;
|
| 156 |
+
const assignmentResult = await supabaseAdmin.from("users_subscriptions")
|
| 157 |
+
.select("subscription_id,startdate,enddate,num_players,num_sessions").eq("user_id", userId).maybeSingle();
|
|
|
|
|
|
|
|
|
|
| 158 |
if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
|
|
|
|
|
|
|
| 159 |
const assignment = assignmentResult.data;
|
| 160 |
let subscription = null;
|
| 161 |
if (assignment?.subscription_id) {
|
|
|
|
| 175 |
}
|
| 176 |
}
|
| 177 |
res.set("Cache-Control", "no-store");
|
| 178 |
+
return res.json({ subscription, usage: { players: assignment?.num_players ?? 0, sessions: assignment?.num_sessions ?? 0 }, billingHistoryAvailable: false });
|
| 179 |
} catch (error) { next(error); }
|
| 180 |
});
|
| 181 |
|
|
|
|
| 416 |
|
| 417 |
app.get("/api/sessions/:sessionId/reviews", async (req, res, next) => {
|
| 418 |
try {
|
| 419 |
+
const current = await authenticatedUser(req, res);
|
| 420 |
+
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 421 |
const sessionId = z.uuid().safeParse(req.params.sessionId);
|
| 422 |
if (!sessionId.success) return res.sendStatus(404);
|
| 423 |
|
|
|
|
| 727 |
});
|
| 728 |
|
| 729 |
async function proxyAnalysisRequest(req: Request, res: Response) {
|
| 730 |
+
const current = await authenticatedUser(req, res);
|
| 731 |
+
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 732 |
+
// Archived sessions and results remain available after expiry. Only starting
|
| 733 |
+
// a new analysis is gated here; the internal begin-analysis route repeats
|
| 734 |
+
// this check before creating its player/session rows.
|
| 735 |
+
if (req.method === "POST" && req.path === "/analyze") {
|
| 736 |
+
const active = await activeSubscriptionFor(current.user.id, { refresh: true });
|
| 737 |
+
if (!active) return res.status(402).json({ error: "An active subscription is required to create or change Labs data.", code: "subscription_required" });
|
| 738 |
+
}
|
| 739 |
|
| 740 |
const target = new URL(req.originalUrl, env.ANALYSIS_API_URL);
|
| 741 |
const headers = new Headers();
|