Spaces:
Running
Running
Fix feedback author attribution and stage one-time repair
Browse files- src/admin.ts +68 -13
src/admin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 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,6 +72,56 @@ async function loadRelatedCounts(table: "players" | "sessions" | "actions", key:
|
|
| 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);
|
|
@@ -335,22 +386,26 @@ export function registerAdminRoutes(app: Express) {
|
|
| 335 |
});
|
| 336 |
const remoteResult = await remote.json().catch(() => ({}));
|
| 337 |
if (!remote.ok) return fail(res, remote.status, (remoteResult as { detail?: string }).detail ?? "Unable to update action data.");
|
| 338 |
-
const { data: existing, error: readError } = await supabaseAdmin.from("annotations")
|
|
|
|
| 339 |
if (readError) throw new Error(`Unable to load action review: ${readError.message}`);
|
| 340 |
-
if (input.data.score
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
if (error) throw new Error(`Unable to remove action review: ${error.message}`);
|
| 346 |
return res.json({ ok: true });
|
| 347 |
}
|
| 348 |
-
const
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
return res.json({ ok: true });
|
| 355 |
}));
|
| 356 |
|
|
|
|
| 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 |
}
|
| 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);
|
|
|
|
| 386 |
});
|
| 387 |
const remoteResult = await remote.json().catch(() => ({}));
|
| 388 |
if (!remote.ok) return fail(res, remote.status, (remoteResult as { detail?: string }).detail ?? "Unable to update action data.");
|
| 389 |
+
const { data: existing, error: readError } = await supabaseAdmin.from("annotations")
|
| 390 |
+
.select("score,annotations_notes(note)").eq("action_id", actionId.data).maybeSingle();
|
| 391 |
if (readError) throw new Error(`Unable to load action review: ${readError.message}`);
|
| 392 |
+
if (input.data.score === null && notes.length === 0) {
|
| 393 |
+
if (existing) {
|
| 394 |
+
const { error } = await supabaseAdmin.from("annotations").delete().eq("action_id", actionId.data);
|
| 395 |
+
if (error) throw new Error(`Unable to remove action review: ${error.message}`);
|
| 396 |
+
}
|
|
|
|
| 397 |
return res.json({ ok: true });
|
| 398 |
}
|
| 399 |
+
const previousNotes = (existing?.annotations_notes ?? []).map((row) => row.note);
|
| 400 |
+
const previousScore = existing?.score == null ? null : Number(existing.score);
|
| 401 |
+
const reviewUnchanged = existing && previousScore === input.data.score
|
| 402 |
+
&& previousNotes.length === notes.length && previousNotes.every((note) => notes.includes(note));
|
| 403 |
+
if (reviewUnchanged) return res.json({ ok: true });
|
| 404 |
+
const { error } = await supabaseAdmin.rpc("save_action_review", {
|
| 405 |
+
p_session_id: sessionId.data, p_action_id: actionId.data,
|
| 406 |
+
p_user_id: current.user.id, p_score: input.data.score, p_notes: notes,
|
| 407 |
+
});
|
| 408 |
+
if (error) throw new Error(`Unable to save action review: ${error.message}`);
|
| 409 |
return res.json({ ok: true });
|
| 410 |
}));
|
| 411 |
|