open-prompt / src /app /api /comments /route.ts
GitHub Action
Automated sync to Hugging Face
bcce530
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 }
);
}
}