diff --git a/lib/api/comments.ts b/lib/api/comments.ts new file mode 100644 index 0000000..c776720 --- /dev/null +++ b/lib/api/comments.ts @@ -0,0 +1,289 @@ +import { Elysia, t } from "elysia" +import { gameComments, user } from "@/lib/db/schema" +import { db } from "@/lib/db/index" +import { eq, and, desc, sql, isNull } from "drizzle-orm" +import { requireRole } from "@/lib/auth/guard" + +export const commentsRoutes = new Elysia({ + prefix: "/games/:gameId/comments", +}) + // LIST top-level comments for a game (paginated) + .get( + "/", + async ({ params, query }) => { + const limit = Math.min(Number(query.limit) || 20, 100) + const offset = Number(query.offset) || 0 + + const conditions = [ + eq(gameComments.gameId, params.gameId), + eq(gameComments.isRemoved, false), + isNull(gameComments.parentId), // top-level only + ] + + const data = await db + .select({ + id: gameComments.id, + gameId: gameComments.gameId, + userId: gameComments.userId, + parentId: gameComments.parentId, + content: gameComments.content, + upvotes: gameComments.upvotes, + createdAt: gameComments.createdAt, + updatedAt: gameComments.updatedAt, + userName: user.name, + userImage: user.image, + }) + .from(gameComments) + .innerJoin(user, eq(gameComments.userId, user.id)) + .where(and(...conditions)) + .orderBy(desc(gameComments.createdAt)) + .limit(limit) + .offset(offset) + + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(gameComments) + .where(and(...conditions)) + + return { data, total: count, limit, offset } + }, + { + params: t.Object({ gameId: t.String() }), + query: t.Object({ + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + // GET replies for a specific comment + .get( + "/:id/replies", + async ({ params, query }) => { + const limit = Math.min(Number(query.limit) || 20, 100) + const offset = Number(query.offset) || 0 + + const data = await db + .select({ + id: gameComments.id, + gameId: gameComments.gameId, + userId: gameComments.userId, + parentId: gameComments.parentId, + content: gameComments.content, + upvotes: gameComments.upvotes, + createdAt: gameComments.createdAt, + updatedAt: gameComments.updatedAt, + userName: user.name, + userImage: user.image, + }) + .from(gameComments) + .innerJoin(user, eq(gameComments.userId, user.id)) + .where( + and( + eq(gameComments.parentId, params.id), + eq(gameComments.isRemoved, false), + ), + ) + .orderBy(desc(gameComments.createdAt)) + .limit(limit) + .offset(offset) + + return data + }, + { + params: t.Object({ gameId: t.String(), id: t.String() }), + query: t.Object({ + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + // CREATE comment + .post( + "/", + async ({ params, body, request, set }) => { + const guard = await requireRole(request.headers, [ + "user", + "contributor", + "admin", + ]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + // If parentId is provided, verify it exists and belongs to the same game + if (body.parentId) { + const [parent] = await db + .select({ id: gameComments.id }) + .from(gameComments) + .where( + and( + eq(gameComments.id, body.parentId), + eq(gameComments.gameId, params.gameId), + ), + ) + .limit(1) + + if (!parent) { + set.status = 404 + return { error: "Parent comment not found" } + } + } + + const [created] = await db + .insert(gameComments) + .values({ + gameId: params.gameId, + userId: guard.user.id, + parentId: body.parentId ?? null, + content: body.content, + }) + .returning() + + set.status = 201 + return created + }, + { + params: t.Object({ gameId: t.String() }), + body: t.Object({ + parentId: t.Optional(t.String()), + content: t.Record(t.String(), t.Any()), // Tiptap JSON + }), + }, + ) + // UPDATE comment (owner or admin) + .patch( + "/:id", + async ({ params, body, request, set }) => { + const guard = await requireRole(request.headers, [ + "user", + "contributor", + "admin", + ]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [comment] = await db + .select() + .from(gameComments) + .where( + and( + eq(gameComments.id, params.id), + eq(gameComments.gameId, params.gameId), + ), + ) + .limit(1) + + if (!comment) { + set.status = 404 + return { error: "Comment not found" } + } + + if (comment.userId !== guard.user.id && guard.user.role !== "admin") { + set.status = 403 + return { error: "Not authorized" } + } + + const [updated] = await db + .update(gameComments) + .set({ content: body.content, updatedAt: new Date() }) + .where(eq(gameComments.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ gameId: t.String(), id: t.String() }), + body: t.Object({ + content: t.Record(t.String(), t.Any()), + }), + }, + ) + // DELETE comment (soft delete — owner or admin) + .delete( + "/:id", + 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 [comment] = await db + .select() + .from(gameComments) + .where( + and( + eq(gameComments.id, params.id), + eq(gameComments.gameId, params.gameId), + ), + ) + .limit(1) + + if (!comment) { + set.status = 404 + return { error: "Comment not found" } + } + + if (comment.userId !== guard.user.id && guard.user.role !== "admin") { + set.status = 403 + return { error: "Not authorized" } + } + + await db + .update(gameComments) + .set({ isRemoved: true, updatedAt: new Date() }) + .where(eq(gameComments.id, params.id)) + + return { success: true } + }, + { + params: t.Object({ gameId: t.String(), id: t.String() }), + }, + ) + // UPVOTE comment + .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(gameComments) + .set({ + upvotes: sql`${gameComments.upvotes} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(gameComments.id, params.id), + eq(gameComments.gameId, params.gameId), + eq(gameComments.isRemoved, false), + ), + ) + .returning() + + if (!updated) { + set.status = 404 + return { error: "Comment not found" } + } + + return updated + }, + { + params: t.Object({ gameId: t.String(), id: t.String() }), + }, + )