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
This commit is contained in:
2026-04-30 19:38:44 +08:00
parent f9617bf586
commit 09fde413b9
3 changed files with 288 additions and 0 deletions
+284
View File
@@ -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<string, any> = {}
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()),
}),
},
)
+1
View File
@@ -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"