From 09fde413b96773326e7f4cc84dad41ec1d45d47b Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Thu, 30 Apr 2026 19:38:44 +0800 Subject: [PATCH] feat: add community suggestions API with CRUD, moderation, and Discord webhook notifications - POST /api/community-suggestions - submit suggestion (auth required) - GET /api/community-suggestions/game/:gameId - list suggestions for a game - GET /api/community-suggestions/pending - moderation view (contributor/admin) - PATCH /api/community-suggestions/:suggestionId/review - approve/reject - Discord webhook notification on new suggestions - Route registered in API index and app router --- app/api/[[...slugs]]/route.ts | 3 + lib/api/community-suggestions.ts | 284 +++++++++++++++++++++++++++++++ lib/api/index.ts | 1 + 3 files changed, 288 insertions(+) create mode 100644 lib/api/community-suggestions.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index 13fedda..8ab6922 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -28,6 +28,7 @@ import { gamesManualRoutes } from "@/lib/api/games-manual" import { compareRoutes } from "@/lib/api/compare" import { playabilityRoutes } from "@/lib/api/playability" import { steamReviewRoutes } from "@/lib/api/steam-reviews" +import { communitySuggestionRoutes } from "@/lib/api/community-suggestions" import { gamesListingRoutes } from "@/lib/api/games-listing" import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy" @@ -107,6 +108,8 @@ export const app = new Elysia({ prefix: "/api" }) .use(playabilityRoutes) // Steam reviews .use(steamReviewRoutes) + // Community suggestions + .use(communitySuggestionRoutes) // Root .get("/", () => ({ name: "DeckyVault API", diff --git a/lib/api/community-suggestions.ts b/lib/api/community-suggestions.ts new file mode 100644 index 0000000..73bb66c --- /dev/null +++ b/lib/api/community-suggestions.ts @@ -0,0 +1,284 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { communitySuggestions, games, user } from "@/lib/db/schema" +import { eq, and, desc } from "drizzle-orm" +import { requireAuth, requireContributorOrAdmin } from "@/lib/auth/guard" + +const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL + +async function sendDiscordNotification(suggestion: { + gameTitle: string + fieldName: string + proposedValue: string + userName: string +}) { + if (!DISCORD_WEBHOOK_URL) return + + try { + await fetch(DISCORD_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + embeds: [ + { + title: "📝 New Community Suggestion", + color: 0x5865f2, + fields: [ + { name: "Game", value: suggestion.gameTitle, inline: true }, + { name: "Field", value: suggestion.fieldName, inline: true }, + { + name: "Suggested By", + value: suggestion.userName, + inline: true, + }, + { + name: "Proposed Value", + value: suggestion.proposedValue.slice(0, 1000), + }, + ], + timestamp: new Date().toISOString(), + }, + ], + }), + }) + } catch (err) { + console.error("Discord webhook failed:", err) + } +} + +const allowedFields = [ + "title", + "description", + "developer", + "publisher", + "genres", + "storeUrl", + "releaseDate", +] + +export const communitySuggestionRoutes = new Elysia({ + prefix: "/api/community-suggestions", +}) + + // Submit a suggestion + .post( + "/", + async ({ body, request, set }) => { + const guard = await requireAuth(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const { gameId, fieldName, proposedValue, reason } = body + + // Validate field name is editable + if (!allowedFields.includes(fieldName)) { + set.status = 400 + return { error: `Field '${fieldName}' cannot be suggested` } + } + + // Get current game value + const [game] = await db + .select() + .from(games) + .where(eq(games.id, gameId)) + .limit(1) + + if (!game) { + set.status = 404 + return { error: "Game not found" } + } + + // Check for existing pending suggestion on same field + const [existing] = await db + .select() + .from(communitySuggestions) + .where( + and( + eq(communitySuggestions.gameId, gameId), + eq(communitySuggestions.fieldName, fieldName), + eq(communitySuggestions.userId, guard.user.id), + eq(communitySuggestions.status, "pending"), + ), + ) + .limit(1) + + if (existing) { + set.status = 409 + return { error: "You already have a pending suggestion for this field" } + } + + const currentValue = String((game as any)[fieldName] ?? "") + + const [suggestion] = await db + .insert(communitySuggestions) + .values({ + gameId, + userId: guard.user.id, + fieldName, + currentValue, + proposedValue, + reason, + }) + .returning() + + // Send Discord notification + await sendDiscordNotification({ + gameTitle: game.title, + fieldName, + proposedValue, + userName: guard.user.name || "Anonymous", + }) + + return suggestion + }, + { + body: t.Object({ + gameId: t.String(), + fieldName: t.String(), + proposedValue: t.String(), + reason: t.Optional(t.String()), + }), + }, + ) + + // Get suggestions for a game + .get( + "/game/:gameId", + async ({ params, query }) => { + const status = query.status // optional filter + + let conditions = [eq(communitySuggestions.gameId, params.gameId)] + if (status) { + conditions.push(eq(communitySuggestions.status, status as any)) + } + + const suggestions = await db + .select({ + id: communitySuggestions.id, + fieldName: communitySuggestions.fieldName, + currentValue: communitySuggestions.currentValue, + proposedValue: communitySuggestions.proposedValue, + reason: communitySuggestions.reason, + status: communitySuggestions.status, + createdAt: communitySuggestions.createdAt, + reviewedAt: communitySuggestions.reviewedAt, + reviewNote: communitySuggestions.reviewNote, + userName: user.name, + }) + .from(communitySuggestions) + .leftJoin(user, eq(communitySuggestions.userId, user.id)) + .where(and(...conditions)) + .orderBy(desc(communitySuggestions.createdAt)) + + return suggestions + }, + { + params: t.Object({ gameId: t.String() }), + query: t.Object({ + status: t.Optional(t.String()), + }), + }, + ) + + // Get all pending suggestions (admin/moderation view) + .get( + "/pending", + async ({ request, set }) => { + const guard = await requireContributorOrAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const suggestions = await db + .select({ + id: communitySuggestions.id, + gameId: communitySuggestions.gameId, + gameTitle: games.title, + fieldName: communitySuggestions.fieldName, + currentValue: communitySuggestions.currentValue, + proposedValue: communitySuggestions.proposedValue, + reason: communitySuggestions.reason, + status: communitySuggestions.status, + createdAt: communitySuggestions.createdAt, + userName: user.name, + }) + .from(communitySuggestions) + .leftJoin(games, eq(communitySuggestions.gameId, games.id)) + .leftJoin(user, eq(communitySuggestions.userId, user.id)) + .where(eq(communitySuggestions.status, "pending")) + .orderBy(desc(communitySuggestions.createdAt)) + + return suggestions + }, + ) + + // Approve/reject suggestion (admin/contributor) + .patch( + "/:suggestionId/review", + async ({ params, body, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const { status: rawStatus, reviewNote } = body + + if (!["approved", "rejected"].includes(rawStatus)) { + set.status = 400 + return { error: "Status must be 'approved' or 'rejected'" } + } + + const status = rawStatus as "approved" | "rejected" + + const [suggestion] = await db + .select() + .from(communitySuggestions) + .where(eq(communitySuggestions.id, params.suggestionId)) + .limit(1) + + if (!suggestion) { + set.status = 404 + return { error: "Suggestion not found" } + } + + if (suggestion.status !== "pending") { + set.status = 400 + return { error: "Suggestion already reviewed" } + } + + // Update suggestion status + await db + .update(communitySuggestions) + .set({ + status: status as typeof communitySuggestions.$inferInsert.status, + reviewedBy: guard.user.id, + reviewedAt: new Date(), + reviewNote, + }) + .where(eq(communitySuggestions.id, params.suggestionId)) + + // If approved, apply the change to the game + if (status === "approved") { + const updateData: Record = {} + updateData[suggestion.fieldName] = suggestion.proposedValue + + await db + .update(games) + .set(updateData) + .where(eq(games.id, suggestion.gameId)) + } + + return { success: true } + }, + { + params: t.Object({ suggestionId: t.String() }), + body: t.Object({ + status: t.String(), + reviewNote: t.Optional(t.String()), + }), + }, + ) \ No newline at end of file diff --git a/lib/api/index.ts b/lib/api/index.ts index a00af55..e3f2ca3 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -19,3 +19,4 @@ export { gamesManualRoutes } from "./games-manual" export { compareRoutes } from "./compare" export { playabilityRoutes } from "./playability" export { steamReviewRoutes } from "./steam-reviews" +export { communitySuggestionRoutes } from "./community-suggestions"