Shoraky commited on
Commit
f5af86b
·
1 Parent(s): 997abf7

Keep feedback attribution tied to the editor

Browse files
Files changed (2) hide show
  1. src/admin.ts +0 -51
  2. tests/admin-review-author.test.ts +80 -0
src/admin.ts CHANGED
@@ -1,6 +1,5 @@
1
  import { readBatches } from "./read-batches.js";
2
  import { readPages } from "./read-pages.js";
3
- import { createHash } from "node:crypto";
4
  import type { Express, Request, Response, NextFunction } from "express";
5
  import { z } from "zod";
6
  import { env } from "./config.js";
@@ -72,56 +71,6 @@ async function loadRelatedCounts(table: "players" | "sessions" | "actions", key:
72
  }
73
 
74
  export function registerAdminRoutes(app: Express) {
75
- // Temporary one-time repair endpoint. Remove after the production attribution repair.
76
- const feedbackAuthorId = "474bbc30-5535-4c06-8010-6d59d01949c0";
77
- async function feedbackAuthorSnapshot() {
78
- const notes = await readPages(async (from, to) => {
79
- const { data, error } = await supabaseAdmin.from("annotations_notes").select("action_id")
80
- .order("action_id").order("note").range(from, to);
81
- if (error) throw new Error(`Unable to list feedback notes: ${error.message}`);
82
- return data ?? [];
83
- });
84
- const ids = [...new Set(notes.map((note) => note.action_id))];
85
- const rows: { action_id: string; user_id: string | null }[] = [];
86
- for (let index = 0; index < ids.length; index += 100) {
87
- const { data, error } = await supabaseAdmin.from("annotations").select("action_id,user_id")
88
- .in("action_id", ids.slice(index, index + 100));
89
- if (error) throw new Error(`Unable to list feedback authors: ${error.message}`);
90
- rows.push(...(data ?? []));
91
- }
92
- if (rows.length !== ids.length) throw new Error("Feedback author snapshot is incomplete.");
93
- rows.sort((a, b) => a.action_id.localeCompare(b.action_id));
94
- const fingerprint = createHash("sha256").update(JSON.stringify(rows)).digest("hex");
95
- return { rows, noteCount: notes.length, fingerprint };
96
- }
97
- app.get("/api/admin/repair-feedback-authors", asyncRoute(async (req, res) => {
98
- if (!await admin(req, res)) return;
99
- const { data: author, error } = await supabaseAdmin.from("users").select("fname,lname")
100
- .eq("user_id", feedbackAuthorId).maybeSingle();
101
- if (error || author?.fname !== "Ahmed" || author?.lname !== "Gad") throw new Error("Target feedback author could not be verified.");
102
- const snapshot = await feedbackAuthorSnapshot();
103
- return res.json({ ...snapshot, targetUserId: feedbackAuthorId });
104
- }));
105
- app.post("/api/admin/repair-feedback-authors", asyncRoute(async (req, res) => {
106
- if (!await admin(req, res)) return;
107
- const input = z.object({ fingerprint: z.string().regex(/^[a-f0-9]{64}$/) }).strict().safeParse(req.body);
108
- if (!input.success) return fail(res, 400, "A current feedback snapshot is required.");
109
- const { data: author, error } = await supabaseAdmin.from("users").select("fname,lname")
110
- .eq("user_id", feedbackAuthorId).maybeSingle();
111
- if (error || author?.fname !== "Ahmed" || author?.lname !== "Gad") throw new Error("Target feedback author could not be verified.");
112
- const snapshot = await feedbackAuthorSnapshot();
113
- if (snapshot.fingerprint !== input.data.fingerprint) return fail(res, 409, "Feedback authors changed. Refresh the snapshot.");
114
- const ids = snapshot.rows.filter((row) => row.user_id !== feedbackAuthorId).map((row) => row.action_id);
115
- for (let index = 0; index < ids.length; index += 100) {
116
- const { error: updateError } = await supabaseAdmin.from("annotations")
117
- .update({ user_id: feedbackAuthorId }).in("action_id", ids.slice(index, index + 100));
118
- if (updateError) throw new Error(`Unable to update feedback authors: ${updateError.message}`);
119
- }
120
- const verified = await feedbackAuthorSnapshot();
121
- if (verified.rows.some((row) => row.user_id !== feedbackAuthorId)) throw new Error("Feedback author verification failed.");
122
- return res.json({ updatedAnnotations: ids.length, totalAnnotations: verified.rows.length,
123
- noteCount: verified.noteCount, targetUserId: feedbackAuthorId, fingerprint: verified.fingerprint });
124
- }));
125
  app.get("/api/admin/users", asyncRoute(async (req, res) => {
126
  if (!await admin(req, res)) return;
127
  const search = String(req.query.search ?? "").replace(/[^\p{L}\p{N}@._ -]/gu, "").trim().slice(0, 100);
 
1
  import { readBatches } from "./read-batches.js";
2
  import { readPages } from "./read-pages.js";
 
3
  import type { Express, Request, Response, NextFunction } from "express";
4
  import { z } from "zod";
5
  import { env } from "./config.js";
 
71
  }
72
 
73
  export function registerAdminRoutes(app: Express) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  app.get("/api/admin/users", asyncRoute(async (req, res) => {
75
  if (!await admin(req, res)) return;
76
  const search = String(req.query.search ?? "").replace(/[^\p{L}\p{N}@._ -]/gu, "").trim().slice(0, 100);
tests/admin-review-author.test.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { createServer } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { test } from "node:test";
7
+
8
+ test("admin action edits attribute changed feedback to the editor", { timeout: 30_000 }, async () => {
9
+ const adminId = "11111111-1111-4111-8111-111111111111";
10
+ const ownerId = "22222222-2222-4222-8222-222222222222";
11
+ const sessionId = "33333333-3333-4333-8333-333333333333";
12
+ const actionId = "44444444-4444-4444-8444-444444444444";
13
+ const user = { id: adminId, email: "admin@example.com", email_confirmed_at: new Date().toISOString(), user_metadata: {} };
14
+ const savedReviews: Array<Record<string, unknown>> = [];
15
+ let oldNotes = ["Original feedback"];
16
+ const upstream = createServer(async (req, res) => {
17
+ const url = new URL(req.url!, "http://stub");
18
+ res.setHeader("Content-Type", "application/json");
19
+ if (url.pathname === "/auth/v1/user") return res.end(JSON.stringify(user));
20
+ if (url.pathname === "/rest/v1/users") return res.end(JSON.stringify({ user_id: adminId, account_role: "admin" }));
21
+ if (url.pathname === "/rest/v1/sessions") return res.end(JSON.stringify({ status: "completed" }));
22
+ if (url.pathname === "/rest/v1/annotations" && req.method === "GET") {
23
+ return res.end(JSON.stringify({ user_id: ownerId, score: 5, annotations_notes: oldNotes.map((note) => ({ note })) }));
24
+ }
25
+ if (url.pathname === "/rest/v1/rpc/save_action_review") {
26
+ const chunks: Buffer[] = [];
27
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
28
+ const body = JSON.parse(Buffer.concat(chunks).toString()) as Record<string, unknown>;
29
+ savedReviews.push(body);
30
+ oldNotes = body.p_notes as string[];
31
+ return res.end("null");
32
+ }
33
+ if (url.pathname === `/api/sessions/${sessionId}/actions/${actionId}` && req.method === "PATCH") return res.end("{}");
34
+ res.statusCode = 404;
35
+ return res.end(JSON.stringify({ error: "Unexpected mock request", path: url.pathname }));
36
+ });
37
+ upstream.listen(0, "127.0.0.1");
38
+ await once(upstream, "listening");
39
+ const portProbe = createServer();
40
+ portProbe.listen(0, "127.0.0.1");
41
+ await once(portProbe, "listening");
42
+ const port = (portProbe.address() as AddressInfo).port;
43
+ await new Promise<void>((resolve) => portProbe.close(() => resolve()));
44
+ const origin = `http://127.0.0.1:${port}`;
45
+ const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
46
+ const child = spawn(process.execPath, ["--import", "tsx", "src/server.ts"], {
47
+ env: { ...process.env, NODE_ENV: "test", PORT: String(port), FRONTEND_URL: origin, BACKEND_URL: origin,
48
+ ANALYSIS_API_URL: upstreamOrigin, SUPABASE_URL: upstreamOrigin,
49
+ SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_PROXY_SECRET: "x".repeat(32) },
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ });
52
+ try {
53
+ await new Promise<void>((resolve, reject) => {
54
+ child.stdout.on("data", (data) => { if (String(data).includes("API listening")) resolve(); });
55
+ child.once("exit", (code) => reject(new Error(`API exited: ${code}`)));
56
+ child.once("error", reject);
57
+ });
58
+ const save = (notes: string[]) => fetch(`${origin}/api/admin/sessions/${sessionId}/actions/${actionId}`, {
59
+ method: "PATCH", signal: AbortSignal.timeout(10000),
60
+ headers: { Cookie: "sp_access=access", Origin: origin, "Content-Type": "application/json" },
61
+ body: JSON.stringify({ startFrame: 0, endFrame: 10, type: "Shoot", score: 5, notes }),
62
+ });
63
+ assert.equal((await save(["Original feedback"])).status, 200);
64
+ assert.equal(savedReviews.length, 0, "an action-only edit must preserve the previous feedback author");
65
+ assert.equal((await save(["Updated feedback"])).status, 200);
66
+ assert.equal(savedReviews.length, 1);
67
+ assert.equal(savedReviews[0].p_user_id, adminId);
68
+ assert.deepEqual(savedReviews[0].p_notes, ["Updated feedback"]);
69
+ assert.equal(savedReviews[0].p_session_id, sessionId);
70
+ assert.equal(savedReviews[0].p_action_id, actionId);
71
+ } finally {
72
+ if (child.exitCode === null && child.signalCode === null) {
73
+ const exited = once(child, "exit");
74
+ child.kill();
75
+ await exited;
76
+ }
77
+ upstream.closeAllConnections();
78
+ await new Promise<void>((resolve) => upstream.close(() => resolve()));
79
+ }
80
+ });