Files
Epicure/apps/web/app/api/v1/recipes/[id]/comments/route.ts
T
Arnaud 57c29f62b4 feat: user blocking and content reporting/flagging
- user_blocks table (composite PK), Block/Unblock button on profiles.
  Blocking severs any existing follow relationship both ways and
  prevents the blocked party from following, commenting on the
  blocker's recipes, or the blocker from seeing their comments.
- reports table (recipe/comment/user targets, pending/reviewed/
  dismissed status). ReportButton on comments, admin review queue at
  /admin/reports with dismiss/mark-reviewed actions, audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:09:14 +02:00

112 lines
4.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, comments, users, userBlocks, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { dispatchWebhook } from "@/lib/webhooks";
import { sendPushNotification } from "@/lib/push";
import { createNotification } from "@/lib/notifications";
import { isBlockedEitherWay } from "@/lib/blocks";
const Schema = z.object({
content: z.string().min(1).max(5000),
parentId: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || recipe.visibility === "private") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const { session } = await requireSession();
const rows = await db
.select({
id: comments.id,
content: comments.content,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
userId: comments.userId,
userName: users.name,
userUsername: users.username,
userAvatarUrl: users.avatarUrl,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(eq(comments.recipeId, id))
.orderBy(comments.createdAt);
if (!session) return NextResponse.json(rows);
const blocked = await db
.select({ blockedId: userBlocks.blockedId })
.from(userBlocks)
.where(eq(userBlocks.blockerId, session.user.id));
const blockedIds = new Set(blocked.map((b) => b.blockedId));
return NextResponse.json(rows.filter((r) => !blockedIds.has(r.userId)));
}
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const limited = await applyRateLimit(`rl:comment:${session!.user.id}`, 20, 60);
if (limited) return limited;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (recipe.authorId !== session!.user.id && await isBlockedEitherWay(session!.user.id, recipe.authorId)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
let parent: { id: string; userId: string } | undefined;
if (parsed.data.parentId) {
parent = await db.query.comments.findFirst({
where: and(eq(comments.id, parsed.data.parentId), eq(comments.recipeId, id)),
});
if (!parent) return NextResponse.json({ error: "Parent comment not found" }, { status: 404 });
}
const commentId = crypto.randomUUID();
await db.insert(comments).values({
id: commentId,
recipeId: id,
userId: session!.user.id,
parentId: parsed.data.parentId,
content: parsed.data.content,
});
// Dispatch to recipe author's webhooks (not to the commenter themselves)
if (recipe.authorId !== session!.user.id) {
void dispatchWebhook(recipe.authorId, "comment.added", { commentId, recipeId: id, recipeTitle: recipe.title });
void sendPushNotification(recipe.authorId, {
title: "New comment on your recipe",
body: `${session!.user.name} commented on "${recipe.title}"`,
url: `/recipes/${id}`,
});
}
if (parent && parent.userId !== session!.user.id) {
void createNotification({ userId: parent.userId, type: "reply", actorId: session!.user.id, recipeId: id, commentId });
}
if (recipe.authorId !== parent?.userId) {
void createNotification({ userId: recipe.authorId, type: "comment", actorId: session!.user.id, recipeId: id, commentId });
}
return NextResponse.json({ id: commentId }, { status: 201 });
}