From 5dd30665f7abe7cf351eb07fcba8d4c9962367f3 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 25 Apr 2026 18:55:48 +0800 Subject: [PATCH] feat(api): add upvote/downvote and best-entry endpoints to performance entries --- drizzle/0003_schema_expansion.sql | 2 + lib/api/performance.ts | 167 +++++++++++++++++++++++++++- lib/db/schema/performanceEntries.ts | 5 + 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/drizzle/0003_schema_expansion.sql b/drizzle/0003_schema_expansion.sql index a4d2064..da5fab5 100644 --- a/drizzle/0003_schema_expansion.sql +++ b/drizzle/0003_schema_expansion.sql @@ -13,6 +13,8 @@ CREATE INDEX "games_source_idx" ON "games" ("source"); -- Alter performance_entries table ALTER TABLE "performance_entries" ADD COLUMN "fsr_version" "fsr_version" DEFAULT 'none' NOT NULL; ALTER TABLE "performance_entries" ADD COLUMN "frame_gen_method" "frame_gen_method" DEFAULT 'none' NOT NULL; +ALTER TABLE "performance_entries" ADD COLUMN "upvotes" integer DEFAULT 0 NOT NULL; +ALTER TABLE "performance_entries" ADD COLUMN "downvotes" integer DEFAULT 0 NOT NULL; ALTER TABLE "performance_entries" ADD COLUMN "verified_at" timestamp; ALTER TABLE "performance_entries" ADD COLUMN "verified_by" text; diff --git a/lib/api/performance.ts b/lib/api/performance.ts index 07fb6d6..b142245 100644 --- a/lib/api/performance.ts +++ b/lib/api/performance.ts @@ -1,8 +1,8 @@ import { Elysia, t } from "elysia" import { createCrudRoutes } from "./crud-builder" -import { performanceEntries, games, gameVersions } from "@/lib/db/schema" +import { performanceEntries, games, gameVersions, hardware, user } from "@/lib/db/schema" import { db } from "@/lib/db/index" -import { eq, and, sql } from "drizzle-orm" +import { eq, and, desc, sql } from "drizzle-orm" import { requireRole } from "@/lib/auth/guard" // ── Performance Entries CRUD ────────────────────────────────────── @@ -60,6 +60,169 @@ export const performanceVerifyRoutes = new Elysia({ params: t.Object({ id: t.String() }), }, ) + // ── Upvote ──────────────────────────────────────────────────────── + .post( + "/:id/upvote", + async ({ params, request, set }) => { + const guard = await requireRole(request.headers, [ + "user", + "contributor", + "admin", + ]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [updated] = await db + .update(performanceEntries) + .set({ + upvotes: sql`${performanceEntries.upvotes} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(performanceEntries.id, params.id), + eq(performanceEntries.isRemoved, false), + ), + ) + .returning() + + if (!updated) { + set.status = 404 + return { error: "Performance entry not found" } + } + + return updated + }, + { + params: t.Object({ id: t.String() }), + }, + ) + // ── Downvote ────────────────────────────────────────────────────── + .post( + "/:id/downvote", + async ({ params, request, set }) => { + const guard = await requireRole(request.headers, [ + "user", + "contributor", + "admin", + ]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [updated] = await db + .update(performanceEntries) + .set({ + downvotes: sql`${performanceEntries.downvotes} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(performanceEntries.id, params.id), + eq(performanceEntries.isRemoved, false), + ), + ) + .returning() + + if (!updated) { + set.status = 404 + return { error: "Performance entry not found" } + } + + return updated + }, + { + params: t.Object({ id: t.String() }), + }, + ) + // ── Best entry: highest-rated for latest version ────────────────── + .get( + "/best", + async ({ query, set }) => { + const { gameId, hardwareSlug } = query as { + gameId?: string + hardwareSlug?: string + } + + if (!gameId) { + set.status = 400 + return { error: "gameId query parameter is required" } + } + + // Find the latest version for this game + const [latestVersion] = await db + .select() + .from(gameVersions) + .where( + and(eq(gameVersions.gameId, gameId), eq(gameVersions.isLatest, true)), + ) + .limit(1) + + if (!latestVersion) { + set.status = 404 + return { error: "No versions found for this game" } + } + + const conditions = [ + eq(performanceEntries.versionId, latestVersion.id), + eq(performanceEntries.isRemoved, false), + ] + + if (hardwareSlug) { + conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug)) + } + + const [bestEntry] = await db + .select({ + id: performanceEntries.id, + versionId: performanceEntries.versionId, + hardwareSlug: performanceEntries.hardwareSlug, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fsrVersion: performanceEntries.fsrVersion, + frameGenMethod: performanceEntries.frameGenMethod, + settingsJson: performanceEntries.settingsJson, + userNotes: performanceEntries.userNotes, + upvotes: performanceEntries.upvotes, + downvotes: performanceEntries.downvotes, + verifiedAt: performanceEntries.verifiedAt, + createdAt: performanceEntries.createdAt, + userName: user.name, + userImage: user.image, + hardwareName: hardware.name, + versionString: gameVersions.versionString, + }) + .from(performanceEntries) + .innerJoin(user, eq(performanceEntries.userId, user.id)) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .where(and(...conditions)) + .orderBy( + desc( + sql`${performanceEntries.upvotes} - ${performanceEntries.downvotes}`, + ), + desc(performanceEntries.upvotes), + ) + .limit(1) + + if (!bestEntry) { + set.status = 404 + return { error: "No performance entries found" } + } + + return bestEntry + }, + { + query: t.Object({ + gameId: t.String(), + hardwareSlug: t.Optional(t.String()), + }), + }, + ) // ── Stats endpoint: aggregated performance for a game+hardware combo ── .get( "/stats", diff --git a/lib/db/schema/performanceEntries.ts b/lib/db/schema/performanceEntries.ts index 7ec99a1..9cb10a3 100644 --- a/lib/db/schema/performanceEntries.ts +++ b/lib/db/schema/performanceEntries.ts @@ -1,5 +1,6 @@ import { boolean, + integer, jsonb, pgEnum, pgTable, @@ -68,6 +69,10 @@ export const performanceEntries = pgTable( isRemoved: boolean("is_removed").default(false).notNull(), removedReason: text("removed_reason"), + // Community rating + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), + // Verification (admin/mod workflow) verifiedAt: timestamp("verified_at"), verifiedBy: text("verified_by").references(() => user.id, {