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>
This commit is contained in:
Arnaud
2026-07-03 22:09:14 +02:00
parent a3f387fa2a
commit 57c29f62b4
19 changed files with 4441 additions and 14 deletions
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/api-auth";
import { db, reports, auditLogs, eq } from "@epicure/db";
import { randomUUID } from "crypto";
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const body = (await req.json()) as { status?: string };
if (body.status !== "reviewed" && body.status !== "dismissed") {
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
}
const [updated] = await db
.update(reports)
.set({ status: body.status, reviewedById: session!.user.id, reviewedAt: new Date() })
.where(eq(reports.id, id))
.returning();
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session!.user.id,
action: "admin.report.resolve",
targetType: "report",
targetId: id,
metadata: JSON.stringify({ status: body.status }),
createdAt: new Date(),
});
return NextResponse.json({ report: updated });
}
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/api-auth";
import { db, reports, users, eq, desc } from "@epicure/db";
export async function GET() {
const { response } = await requireAdmin();
if (response) return response;
const rows = await db
.select({
id: reports.id,
targetType: reports.targetType,
targetId: reports.targetId,
reason: reports.reason,
status: reports.status,
createdAt: reports.createdAt,
reporterId: reports.reporterId,
reporterName: users.name,
reporterEmail: users.email,
})
.from(reports)
.innerJoin(users, eq(reports.reporterId, users.id))
.where(eq(reports.status, "pending"))
.orderBy(desc(reports.createdAt))
.limit(100);
return NextResponse.json({ reports: rows });
}
@@ -1,11 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, comments, users, eq, and } from "@epicure/db";
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),
@@ -21,6 +22,8 @@ export async function GET(_req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const { session } = await requireSession();
const rows = await db
.select({
id: comments.id,
@@ -38,7 +41,15 @@ export async function GET(_req: NextRequest, { params }: Params) {
.where(eq(comments.recipeId, id))
.orderBy(comments.createdAt);
return NextResponse.json(rows);
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) {
@@ -54,6 +65,10 @@ export async function POST(req: NextRequest, { params }: Params) {
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 });
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, reports, comments, recipes, users, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { randomUUID } from "crypto";
const Schema = z.object({
targetType: z.enum(["recipe", "comment", "user"]),
targetId: z.string().min(1),
reason: z.string().min(1).max(1000),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const limited = await applyRateLimit(`rl:report:${session!.user.id}`, 10, 60);
if (limited) return limited;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const { targetType, targetId, reason } = parsed.data;
const exists =
targetType === "recipe"
? await db.query.recipes.findFirst({ where: eq(recipes.id, targetId) })
: targetType === "comment"
? await db.query.comments.findFirst({ where: eq(comments.id, targetId) })
: await db.query.users.findFirst({ where: eq(users.id, targetId) });
if (!exists) return NextResponse.json({ error: "Target not found" }, { status: 404 });
await db.insert(reports).values({
id: randomUUID(),
reporterId: session!.user.id,
targetType,
targetId,
reason,
});
return NextResponse.json({ ok: true }, { status: 201 });
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { db, users, userBlocks, userFollows, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ username: string }> };
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { username } = await params;
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot block yourself" }, { status: 400 });
await db.insert(userBlocks)
.values({ blockerId: session!.user.id, blockedId: target.id })
.onConflictDoNothing();
// Blocking severs any existing follow relationship in either direction.
await db.delete(userFollows).where(
or(
and(eq(userFollows.followerId, session!.user.id), eq(userFollows.followingId, target.id)),
and(eq(userFollows.followerId, target.id), eq(userFollows.followingId, session!.user.id))
)
);
return NextResponse.json({ blocked: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { username } = await params;
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(userBlocks).where(
and(eq(userBlocks.blockerId, session!.user.id), eq(userBlocks.blockedId, target.id))
);
return NextResponse.json({ blocked: false });
}
@@ -3,6 +3,7 @@ import { db, users, userFollows, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { createNotification } from "@/lib/notifications";
import { isBlockedEitherWay } from "@/lib/blocks";
type Params = { params: Promise<{ username: string }> };
@@ -19,6 +20,10 @@ export async function POST(_req: NextRequest, { params }: Params) {
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot follow yourself" }, { status: 400 });
if (await isBlockedEitherWay(session!.user.id, target.id)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await db.insert(userFollows)
.values({ followerId: session!.user.id, followingId: target.id })
.onConflictDoNothing();