From 2ab4ac5ebc6fd9762638ce038a08a526e7561f62 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 07:55:57 +0800 Subject: [PATCH] feat: add admin comments API route - GET /admin/comments: list all comments with pagination, removed filter, and author search - PATCH /admin/comments/:id/remove: admin-only soft delete - PATCH /admin/comments/:id/restore: admin-only restore --- app/api/[[...slugs]]/route.ts | 2 + lib/api/admin-comments.ts | 164 ++++++++++++++++++++++++++++++++++ lib/api/index.ts | 1 + 3 files changed, 167 insertions(+) create mode 100644 lib/api/admin-comments.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index bb93e0a..594ddeb 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -17,6 +17,7 @@ import { contactRoutes, adminReportRoutes, adminPerformanceRoutes, + adminCommentRoutes, } from "@/lib/api" import { steamSearchRoutes } from "@/lib/api/steam-search" import { searchUnifiedRoutes } from "@/lib/api/search-unified" @@ -74,6 +75,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(reportRoutes) .use(adminReportRoutes) .use(adminPerformanceRoutes) + .use(adminCommentRoutes) // Comments .use(commentsRoutes) // Steam search proxy diff --git a/lib/api/admin-comments.ts b/lib/api/admin-comments.ts new file mode 100644 index 0000000..c6f2266 --- /dev/null +++ b/lib/api/admin-comments.ts @@ -0,0 +1,164 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + gameComments, + games, + user, +} from "@/lib/db/schema" +import { eq, desc, sql, and, ilike } from "drizzle-orm" +import { + requireContributorOrAdmin, + requireAdmin, +} from "@/lib/auth/guard" + +export const adminCommentRoutes = new Elysia({ prefix: "/admin" }) + .get( + "/comments", + 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 removedFilter = query.removed + const searchTerm = query.search + + const conditions: (ReturnType | ReturnType | ReturnType)[] = [] + + if (removedFilter === "true") { + conditions.push(eq(gameComments.isRemoved, true)) + } else if (removedFilter === "false") { + conditions.push(eq(gameComments.isRemoved, false)) + } + + if (searchTerm) { + conditions.push(ilike(user.name, `%${searchTerm}%`)) + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const baseQuery = db + .select({ + id: gameComments.id, + gameId: gameComments.gameId, + content: gameComments.content, + upvotes: gameComments.upvotes, + isRemoved: gameComments.isRemoved, + createdAt: gameComments.createdAt, + updatedAt: gameComments.updatedAt, + userId: gameComments.userId, + userName: user.name, + userImage: user.image, + gameTitle: games.title, + parentId: gameComments.parentId, + }) + .from(gameComments) + .innerJoin(games, eq(gameComments.gameId, games.id)) + .innerJoin(user, eq(gameComments.userId, user.id)) + .orderBy(desc(gameComments.createdAt)) + + const items = whereClause + ? await baseQuery.where(whereClause).limit(limit).offset(offset) + : await baseQuery.limit(limit).offset(offset) + + const countQuery = db + .select({ count: sql`count(*)::int` }) + .from(gameComments) + .innerJoin(games, eq(gameComments.gameId, games.id)) + .innerJoin(user, eq(gameComments.userId, user.id)) + + const countResult = whereClause + ? await countQuery.where(whereClause) + : await countQuery + + const total = countResult[0]?.count ?? 0 + + return { + data: items, + total, + limit, + offset, + } + }, + { + query: t.Object({ + removed: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])), + search: t.Optional(t.String()), + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + .patch( + "/comments/:id/remove", + async ({ params, request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [comment] = await db + .select() + .from(gameComments) + .where(eq(gameComments.id, params.id)) + .limit(1) + + if (!comment) { + set.status = 404 + return { error: "Comment not found" } + } + + const [updated] = await db + .update(gameComments) + .set({ + isRemoved: true, + updatedAt: new Date(), + }) + .where(eq(gameComments.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ id: t.String() }), + }, + ) + .patch( + "/comments/:id/restore", + async ({ params, request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [comment] = await db + .select() + .from(gameComments) + .where(eq(gameComments.id, params.id)) + .limit(1) + + if (!comment) { + set.status = 404 + return { error: "Comment not found" } + } + + const [updated] = await db + .update(gameComments) + .set({ + isRemoved: false, + updatedAt: new Date(), + }) + .where(eq(gameComments.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ id: t.String() }), + }, + ) diff --git a/lib/api/index.ts b/lib/api/index.ts index 762b4cd..9ab8fae 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -13,3 +13,4 @@ export { reportRoutes } from "./reports" export { contactRoutes } from "./contact" export { adminReportRoutes } from "./admin-reports" export { adminPerformanceRoutes } from "./admin-performance" +export { adminCommentRoutes } from "./admin-comments"