From 1108264e4c897b3887eaf81d0325d45583dbd56d Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 07:44:39 +0800 Subject: [PATCH] feat: add admin reports API route - GET /admin/reports with pagination, status filtering, joins, and batch author name lookup - PATCH /admin/reports/:id/status to review (soft-delete entry) or dismiss - Register route in lib/api/index.ts and app/api/[[...slugs]]/route.ts --- app/api/[[...slugs]]/route.ts | 2 + lib/api/admin-reports.ts | 194 ++++++++++++++++++++++++++++++++++ lib/api/index.ts | 1 + 3 files changed, 197 insertions(+) create mode 100644 lib/api/admin-reports.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index bf042c2..e0c9956 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -15,6 +15,7 @@ import { savedGamesRoutes, reportRoutes, contactRoutes, + adminReportRoutes, } from "@/lib/api" import { steamSearchRoutes } from "@/lib/api/steam-search" import { searchUnifiedRoutes } from "@/lib/api/search-unified" @@ -70,6 +71,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(performanceVerifyRoutes) .use(performanceSubmitRoutes) .use(reportRoutes) + .use(adminReportRoutes) // Comments .use(commentsRoutes) // Steam search proxy diff --git a/lib/api/admin-reports.ts b/lib/api/admin-reports.ts new file mode 100644 index 0000000..85d675b --- /dev/null +++ b/lib/api/admin-reports.ts @@ -0,0 +1,194 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + reports, + performanceEntries, + gameVersions, + games, + user, +} from "@/lib/db/schema" +import { eq, desc, sql, and, inArray } from "drizzle-orm" +import { requireContributorOrAdmin } from "@/lib/auth/guard" + +export const adminReportRoutes = new Elysia({ prefix: "/admin" }) + .get( + "/reports", + async ({ query, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const limit = Math.min(Number(query.limit) || 20, 100) + const offset = Number(query.offset) || 0 + const statusFilter = query.status + + const conditions = [] + if ( + statusFilter && + ["open", "reviewed", "dismissed"].includes(statusFilter) + ) { + conditions.push( + eq(reports.status, statusFilter as "open" | "reviewed" | "dismissed"), + ) + } + + const whereClause = + conditions.length > 0 ? and(...conditions) : undefined + + const baseQuery = db + .select({ + report: { + id: reports.id, + entryId: reports.entryId, + reporterId: reports.reporterId, + reason: reports.reason, + details: reports.details, + status: reports.status, + createdAt: reports.createdAt, + }, + reporterName: user.name, + entry: { + id: performanceEntries.id, + userId: performanceEntries.userId, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + upscalerType: performanceEntries.upscalerType, + userNotes: performanceEntries.userNotes, + isRemoved: performanceEntries.isRemoved, + }, + gameVersion: { + id: gameVersions.id, + versionString: gameVersions.versionString, + }, + game: { + id: games.id, + title: games.title, + headerImage: games.headerImage, + }, + }) + .from(reports) + .innerJoin( + performanceEntries, + eq(reports.entryId, performanceEntries.id), + ) + .innerJoin( + gameVersions, + eq(performanceEntries.versionId, gameVersions.id), + ) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .innerJoin(user, eq(reports.reporterId, user.id)) + .orderBy(desc(reports.createdAt)) + + const items = whereClause + ? await baseQuery.where(whereClause).limit(limit).offset(offset) + : await baseQuery.limit(limit).offset(offset) + + const countResult = whereClause + ? await db + .select({ count: sql`count(*)::int` }) + .from(reports) + .where(whereClause) + : await db.select({ count: sql`count(*)::int` }).from(reports) + + const total = countResult[0]?.count ?? 0 + + // Batch-fetch entry author names + const authorIds = [...new Set(items.map((i) => i.entry.userId))] + const authorNames: Record = {} + + if (authorIds.length > 0) { + const authors = await db + .select({ id: user.id, name: user.name }) + .from(user) + .where(inArray(user.id, authorIds)) + + for (const a of authors) { + authorNames[a.id] = a.name + } + } + + return { + data: items.map((item) => ({ + ...item.report, + createdAt: item.report.createdAt.toISOString(), + reporterName: item.reporterName, + entry: { + ...item.entry, + authorName: authorNames[item.entry.userId] ?? null, + }, + gameVersion: item.gameVersion, + game: item.game, + })), + total, + limit, + offset, + } + }, + { + query: t.Object({ + status: t.Optional( + t.Union([ + t.Literal("open"), + t.Literal("reviewed"), + t.Literal("dismissed"), + ]), + ), + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + .patch( + "/reports/:id/status", + async ({ params, body, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [existing] = await db + .select() + .from(reports) + .where(eq(reports.id, params.id)) + .limit(1) + + if (!existing) { + set.status = 404 + return { error: "Report not found" } + } + + if (existing.status === body.status) { + set.status = 409 + return { error: "Report already has this status" } + } + + if (body.status === "reviewed") { + await db + .update(performanceEntries) + .set({ + isRemoved: true, + removedReason: `Reported: ${existing.reason}${existing.details ? ` — ${existing.details}` : ""}`, + updatedAt: new Date(), + }) + .where(eq(performanceEntries.id, existing.entryId)) + } + + const [updated] = await db + .update(reports) + .set({ status: body.status }) + .where(eq(reports.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + status: t.Union([t.Literal("reviewed"), t.Literal("dismissed")]), + }), + }, + ) diff --git a/lib/api/index.ts b/lib/api/index.ts index 7f29450..4ddb334 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -11,3 +11,4 @@ export { hardwareStatsRoutes } from "./hardware-stats" export { savedGamesRoutes } from "./saved-games" export { reportRoutes } from "./reports" export { contactRoutes } from "./contact" +export { adminReportRoutes } from "./admin-reports"