diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx index 03785e8..f328bbc 100644 --- a/apps/www/src/client/Moderation.tsx +++ b/apps/www/src/client/Moderation.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { KickReportCategory } from '../../../notify/src/notification-payloads' -import { accountIdForUsername, call, isAdmin, useAction, usernamesFor } from './api' +import { accountIdForUsername, call, isAdmin, useAction, usernamesFor, where } from './api' import type { PublicAccount } from './api' @@ -95,6 +95,11 @@ interface ReportRow { banned_at: string | null chat_message_id: number | null image_id: number | null + /** + * The reported photo's key on `img`, joined on by `www` (the `report` table stores only + * the id). Null for a report that isn't against a photo, or one whose photo is gone. + */ + image_name?: string | null /** * Who lifted the ban that once stood on this row, and when — null until one is lifted, * and cleared again by a re-ban. A lift wipes the four ban columns, so this pair is the @@ -925,7 +930,20 @@ function ReportTable({ - {reportKind(report)} + + {report.image_name ? ( + + {reportKind(report)} + + ) : ( + reportKind(report) + )} + {categoryLabel(report.report_category)} {/* The reporter's own words, untruncated in the title so a long description is readable without leaving the table. */} diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 84d07bb..07bef92 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -5,6 +5,7 @@ import { clearPasswordHash, createGift, getAccount, + getImageById, getOnlinePlayerIds, getPlayerIdsInRoom, getPresences, @@ -340,6 +341,22 @@ async function recordBanAudit( /** The evasion arms in force, read from the same knob `api` and `match` read. */ const armsFor = (env: Env) => banEvasionMatch(env.BAN_EVASION_MATCH) +/** + * Reports with the reported photo's `image_name` alongside its `image_id`, so the panel can + * link to the picture — `img` serves by name, and the row only stores the id. Read whatever + * the photo's accessibility: one made private after it was reported is still evidence. A + * photo since deleted gets a null name and the panel shows the bare id. + */ +async function withImageNames(db: D1Database, reports: ReportRow[]) { + const ids = [...new Set(reports.flatMap((r) => (r.image_id == null ? [] : [r.image_id])))] + const images = await Promise.all(ids.map((id) => getImageById(db, id))) + const names = new Map(ids.map((id, i) => [id, images[i]?.ImageName ?? null])) + return reports.map((r) => ({ + ...r, + image_name: r.image_id == null ? null : (names.get(r.image_id) ?? null), + })) +} + // ---- Handlers --------------------------------------------------------------- /** @@ -361,7 +378,7 @@ export async function searchReportsHandler(c: Context) { skip: intQuery(c, 'skip') ?? 0, take: Math.min(intQuery(c, 'take') ?? 50, MAX_TAKE), }) - return c.json(page) + return c.json({ ...page, reports: await withImageNames(c.env.DB, page.reports) }) } /** One report by id — the detail behind a search row. */ @@ -370,7 +387,8 @@ export async function getReportHandler(c: Context) { if (Number.isNaN(id)) return c.json({ error: 'A numeric report id is required' }, 400) const report = await getReportById(c.env.DB, id) if (report === null) return c.json({ error: 'No such report' }, 404) - return c.json(report) + const [withName] = await withImageNames(c.env.DB, [report]) + return c.json(withName) } /** @@ -525,7 +543,12 @@ export async function playerHistoryHandler(c: Context) { getWarningsAgainst(c.env.DB, playerId), getActiveBan(c.env.DB, playerId), ]) - return c.json({ playerId, reports, warnings, activeBan }) + return c.json({ + playerId, + reports: await withImageNames(c.env.DB, reports), + warnings, + activeBan, + }) } /**