import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getAuthUser } from "@/lib/auth"; import { createCommentSchema, updateCommentSchema, parseBody } from "@/lib/validations"; import { checkRateLimit, getClientIdentifier } from "@/lib/rate-limit"; import { sanitizeText } from "@/lib/api-utils"; // GET /api/comments?promptId=xxx — public, with pagination export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const promptId = searchParams.get("promptId"); const limit = Math.min(parseInt(searchParams.get("limit") || "20"), 50); const offset = Math.max(parseInt(searchParams.get("offset") || "0"), 0); if (!promptId) { return NextResponse.json( { error: "promptId is required" }, { status: 400 } ); } const [comments, total] = await Promise.all([ prisma.comment.findMany({ where: { promptId, parentId: null, // Only top-level comments }, include: { user: { select: { id: true, name: true, username: true, image: true, rank: true, }, }, replies: { include: { user: { select: { id: true, name: true, username: true, image: true, rank: true, }, }, }, orderBy: { createdAt: "asc" }, take: 50, // Limit nested replies }, }, orderBy: { createdAt: "desc" }, take: limit, skip: offset, }), prisma.comment.count({ where: { promptId, parentId: null } }), ]); return NextResponse.json({ comments, total, hasMore: offset + comments.length < total }); } catch (error) { console.error("Error fetching comments:", error); return NextResponse.json( { error: "Failed to fetch comments" }, { status: 500 } ); } } // POST /api/comments — requires authentication + rate limited export async function POST(request: NextRequest) { try { const authUser = await getAuthUser(); if (!authUser) { return NextResponse.json( { error: "Authentication required" }, { status: 401 } ); } // Rate limit const identifier = getClientIdentifier(request, authUser.id); const rateLimit = await checkRateLimit(`comments:${identifier}`, true); if (!rateLimit.success) { return NextResponse.json({ error: rateLimit.error }, { status: 429 }); } const body = await request.json(); const parsed = parseBody(createCommentSchema, body); if (!parsed.success) return parsed.response; const { promptId, content, parentId } = parsed.data; // Sanitize content — strip HTML tags, limit length const sanitizedContent = sanitizeText(content, 5000); if (!sanitizedContent) { return NextResponse.json( { error: "Comment content cannot be empty" }, { status: 400 } ); } const comment = await prisma.comment.create({ data: { promptId, userId: authUser.id, content: sanitizedContent, parentId: parentId || null, }, include: { user: { select: { id: true, name: true, username: true, image: true, rank: true, }, }, }, }); return NextResponse.json(comment); } catch (error) { console.error("Error creating comment:", error); return NextResponse.json( { error: "Failed to create comment" }, { status: 500 } ); } } // PATCH /api/comments — requires authentication // Like/unlike uses a Redis-based per-user tracking to prevent spam export async function PATCH(request: NextRequest) { try { const authUser = await getAuthUser(); if (!authUser) { return NextResponse.json( { error: "Authentication required" }, { status: 401 } ); } const body = await request.json(); const parsed = parseBody(updateCommentSchema, body); if (!parsed.success) return parsed.response; const { commentId, action, content } = parsed.data; if (action === "like" || action === "unlike") { if (action === "like") { try { // Create a like record — unique constraint prevents double-liking await prisma.commentLike.create({ data: { commentId, userId: authUser.id }, }) } catch { // Unique constraint violation = already liked return NextResponse.json({ error: "Already liked" }, { status: 409 }) } // Return updated like count const count = await prisma.commentLike.count({ where: { commentId } }) return NextResponse.json({ liked: true, likes: count }) } if (action === "unlike") { const deleted = await prisma.commentLike.deleteMany({ where: { commentId, userId: authUser.id }, }) if (deleted.count === 0) { return NextResponse.json({ error: "Not liked" }, { status: 409 }) } const count = await prisma.commentLike.count({ where: { commentId } }) return NextResponse.json({ liked: false, likes: count }) } } if (action === "edit" && content) { // Verify ownership before editing const existingComment = await prisma.comment.findUnique({ where: { id: commentId }, select: { userId: true }, }); if (!existingComment || existingComment.userId !== authUser.id) { return NextResponse.json( { error: "You can only edit your own comments" }, { status: 403 } ); } const sanitizedContent = sanitizeText(content, 5000); const comment = await prisma.comment.update({ where: { id: commentId }, data: { content: sanitizedContent }, }); return NextResponse.json(comment); } return NextResponse.json( { error: "Invalid action" }, { status: 400 } ); } catch (error) { console.error("Error updating comment:", error); return NextResponse.json( { error: "Failed to update comment" }, { status: 500 } ); } } // DELETE /api/comments — requires authentication, delete only own comments export async function DELETE(request: NextRequest) { try { const authUser = await getAuthUser(); if (!authUser) { return NextResponse.json( { error: "Authentication required" }, { status: 401 } ); } const { searchParams } = new URL(request.url); const commentId = searchParams.get("commentId"); if (!commentId) { return NextResponse.json( { error: "commentId is required" }, { status: 400 } ); } // Verify ownership before deleting const existingComment = await prisma.comment.findUnique({ where: { id: commentId }, select: { userId: true }, }); if (!existingComment || existingComment.userId !== authUser.id) { return NextResponse.json( { error: "You can only delete your own comments" }, { status: 403 } ); } await prisma.comment.delete({ where: { id: commentId }, }); return NextResponse.json({ success: true }); } catch (error) { console.error("Error deleting comment:", error); return NextResponse.json( { error: "Failed to delete comment" }, { status: 500 } ); } }