Spaces:
Running
Running
Keep feedback attribution tied to the editor
Browse files- src/admin.ts +0 -51
- 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 |
+
});
|