diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index e0c9956..bb93e0a 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -16,6 +16,7 @@ import { reportRoutes, contactRoutes, adminReportRoutes, + adminPerformanceRoutes, } from "@/lib/api" import { steamSearchRoutes } from "@/lib/api/steam-search" import { searchUnifiedRoutes } from "@/lib/api/search-unified" @@ -72,6 +73,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(performanceSubmitRoutes) .use(reportRoutes) .use(adminReportRoutes) + .use(adminPerformanceRoutes) // Comments .use(commentsRoutes) // Steam search proxy diff --git a/lib/api/admin-performance.ts b/lib/api/admin-performance.ts new file mode 100644 index 0000000..e5b5099 --- /dev/null +++ b/lib/api/admin-performance.ts @@ -0,0 +1,258 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + performanceEntries, + gameVersions, + games, + hardware, + user, +} from "@/lib/db/schema" +import { eq, desc, sql, and, ilike, isNull, isNotNull } from "drizzle-orm" +import { + requireContributorOrAdmin, + requireAdmin, +} from "@/lib/auth/guard" + +export const adminPerformanceRoutes = new Elysia({ prefix: "/admin" }) + .get( + "/performance", + 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 verifiedFilter = query.verified + const searchTerm = query.search + + const conditions: (ReturnType | ReturnType | ReturnType | ReturnType | ReturnType)[] = [] + + if (removedFilter === "true") { + conditions.push(eq(performanceEntries.isRemoved, true)) + } else if (removedFilter === "false") { + conditions.push(eq(performanceEntries.isRemoved, false)) + } + + if (verifiedFilter === "true") { + conditions.push(isNotNull(performanceEntries.verifiedAt)) + } else if (verifiedFilter === "false") { + conditions.push(isNull(performanceEntries.verifiedAt)) + } + + if (searchTerm) { + conditions.push(ilike(user.name, `%${searchTerm}%`)) + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const baseQuery = db + .select({ + id: performanceEntries.id, + versionId: performanceEntries.versionId, + hardwareSlug: performanceEntries.hardwareSlug, + userId: performanceEntries.userId, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, + frameGenMethod: performanceEntries.frameGenMethod, + loadTimeSsd: performanceEntries.loadTimeSsd, + loadTimeSd: performanceEntries.loadTimeSd, + launchOptions: performanceEntries.launchOptions, + settingsJson: performanceEntries.settingsJson, + userNotes: performanceEntries.userNotes, + estimatedBatteryMin: performanceEntries.estimatedBatteryMin, + customSystem: performanceEntries.customSystem, + isRemoved: performanceEntries.isRemoved, + removedReason: performanceEntries.removedReason, + upvotes: performanceEntries.upvotes, + downvotes: performanceEntries.downvotes, + verifiedAt: performanceEntries.verifiedAt, + verifiedBy: performanceEntries.verifiedBy, + createdAt: performanceEntries.createdAt, + updatedAt: performanceEntries.updatedAt, + gameTitle: games.title, + versionString: gameVersions.versionString, + hardwareName: hardware.name, + authorName: user.name, + authorImage: user.image, + }) + .from(performanceEntries) + .innerJoin( + gameVersions, + eq(performanceEntries.versionId, gameVersions.id), + ) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .innerJoin( + hardware, + eq(performanceEntries.hardwareSlug, hardware.slug), + ) + .innerJoin(user, eq(performanceEntries.userId, user.id)) + .orderBy(desc(performanceEntries.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(performanceEntries) + .innerJoin( + gameVersions, + eq(performanceEntries.versionId, gameVersions.id), + ) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .innerJoin( + hardware, + eq(performanceEntries.hardwareSlug, hardware.slug), + ) + .innerJoin(user, eq(performanceEntries.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")])), + verified: 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( + "/performance/:id/verify", + async ({ params, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [entry] = await db + .select() + .from(performanceEntries) + .where(eq(performanceEntries.id, params.id)) + .limit(1) + + if (!entry) { + set.status = 404 + return { error: "Performance entry not found" } + } + + if (entry.verifiedAt) { + set.status = 409 + return { error: "Entry already verified" } + } + + const [updated] = await db + .update(performanceEntries) + .set({ + verifiedAt: new Date(), + verifiedBy: guard.user.id, + updatedAt: new Date(), + }) + .where(eq(performanceEntries.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ id: t.String() }), + }, + ) + .patch( + "/performance/:id/remove", + async ({ params, body, request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [entry] = await db + .select() + .from(performanceEntries) + .where(eq(performanceEntries.id, params.id)) + .limit(1) + + if (!entry) { + set.status = 404 + return { error: "Performance entry not found" } + } + + const [updated] = await db + .update(performanceEntries) + .set({ + isRemoved: true, + removedReason: body?.reason ?? "Admin removed", + updatedAt: new Date(), + }) + .where(eq(performanceEntries.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ id: t.String() }), + body: t.Optional( + t.Object({ + reason: t.Optional(t.String()), + }), + ), + }, + ) + .patch( + "/performance/:id/restore", + async ({ params, request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [entry] = await db + .select() + .from(performanceEntries) + .where(eq(performanceEntries.id, params.id)) + .limit(1) + + if (!entry) { + set.status = 404 + return { error: "Performance entry not found" } + } + + const [updated] = await db + .update(performanceEntries) + .set({ + isRemoved: false, + removedReason: null, + updatedAt: new Date(), + }) + .where(eq(performanceEntries.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 4ddb334..762b4cd 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -12,3 +12,4 @@ export { savedGamesRoutes } from "./saved-games" export { reportRoutes } from "./reports" export { contactRoutes } from "./contact" export { adminReportRoutes } from "./admin-reports" +export { adminPerformanceRoutes } from "./admin-performance"