From 57c29f62b42b5c8af0628e3726c9b2bf6b0ac72b Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 3 Jul 2026 22:09:14 +0200 Subject: [PATCH] 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 --- apps/web/app/(app)/u/[username]/page.tsx | 37 +- apps/web/app/admin/layout.tsx | 3 +- apps/web/app/admin/reports/page.tsx | 37 + .../app/api/v1/admin/reports/[id]/route.ts | 39 + apps/web/app/api/v1/admin/reports/route.ts | 28 + .../app/api/v1/recipes/[id]/comments/route.ts | 19 +- apps/web/app/api/v1/reports/route.ts | 45 + .../api/v1/users/[username]/block/route.ts | 44 + .../api/v1/users/[username]/follow/route.ts | 5 + apps/web/components/admin/reports-queue.tsx | 67 + apps/web/components/social/block-button.tsx | 54 + .../components/social/comments-section.tsx | 4 + apps/web/components/social/report-button.tsx | 91 + apps/web/lib/blocks.ts | 12 + .../db/src/migrations/0019_clumsy_leader.sql | 26 + .../db/src/migrations/meta/0019_snapshot.json | 3901 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/tiers.ts | 23 + packages/db/src/schema/users.ts | 13 + 19 files changed, 4441 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/admin/reports/page.tsx create mode 100644 apps/web/app/api/v1/admin/reports/[id]/route.ts create mode 100644 apps/web/app/api/v1/admin/reports/route.ts create mode 100644 apps/web/app/api/v1/reports/route.ts create mode 100644 apps/web/app/api/v1/users/[username]/block/route.ts create mode 100644 apps/web/components/admin/reports-queue.tsx create mode 100644 apps/web/components/social/block-button.tsx create mode 100644 apps/web/components/social/report-button.tsx create mode 100644 apps/web/lib/blocks.ts create mode 100644 packages/db/src/migrations/0019_clumsy_leader.sql create mode 100644 packages/db/src/migrations/meta/0019_snapshot.json diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx index ab69b39..6cbe689 100644 --- a/apps/web/app/(app)/u/[username]/page.tsx +++ b/apps/web/app/(app)/u/[username]/page.tsx @@ -7,6 +7,7 @@ import { users, recipes, userFollows, + userBlocks, eq, and, desc, @@ -15,6 +16,7 @@ import { import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { FollowButton } from "@/components/social/follow-button"; +import { BlockButton } from "@/components/social/block-button"; import { getPublicUrl } from "@/lib/storage"; type Params = { params: Promise<{ username: string }> }; @@ -61,15 +63,25 @@ export default async function UserProfilePage({ params }: Params) { const recipeCount = recipeCountRow[0]?.count ?? 0; let isFollowing = false; + let isBlocked = false; const isOwnProfile = session?.user.id === user.id; if (session && !isOwnProfile) { - const followRow = await db.query.userFollows.findFirst({ - where: and( - eq(userFollows.followerId, session.user.id), - eq(userFollows.followingId, user.id), - ), - }); + const [followRow, blockRow] = await Promise.all([ + db.query.userFollows.findFirst({ + where: and( + eq(userFollows.followerId, session.user.id), + eq(userFollows.followingId, user.id), + ), + }), + db.query.userBlocks.findFirst({ + where: and( + eq(userBlocks.blockerId, session.user.id), + eq(userBlocks.blockedId, user.id), + ), + }), + ]); isFollowing = !!followRow; + isBlocked = !!blockRow; } const initials = user.name @@ -95,11 +107,14 @@ export default async function UserProfilePage({ params }: Params) {

@{user.username}

{!isOwnProfile && session && ( - +
+ + +
)} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 3763e39..510656a 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, users, eq } from "@epicure/db"; import Link from "next/link"; -import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail } from "lucide-react"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag } from "lucide-react"; import { cn } from "@/lib/utils"; const adminNav = [ @@ -11,6 +11,7 @@ const adminNav = [ { href: "/admin/users", label: "Users", icon: Users }, { href: "/admin/invites", label: "Invites", icon: Mail }, { href: "/admin/recipes", label: "Recipes", icon: BookOpen }, + { href: "/admin/reports", label: "Reports", icon: Flag }, { href: "/admin/tiers", label: "Tier Limits", icon: Gauge }, { href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList }, { href: "/admin/storage", label: "Storage", icon: HardDrive }, diff --git a/apps/web/app/admin/reports/page.tsx b/apps/web/app/admin/reports/page.tsx new file mode 100644 index 0000000..4595f58 --- /dev/null +++ b/apps/web/app/admin/reports/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { db, reports, users, eq, desc } from "@epicure/db"; +import { ReportsQueue } from "@/components/admin/reports-queue"; + +export const metadata: Metadata = { title: "Reports – Admin" }; + +export default async function AdminReportsPage() { + const rows = await db + .select({ + id: reports.id, + targetType: reports.targetType, + targetId: reports.targetId, + reason: reports.reason, + createdAt: reports.createdAt, + 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 ( +
+
+

Reports

+

+ Content flagged by users. Dismiss if unfounded, or review and take action manually. +

+
+ ({ ...r, createdAt: r.createdAt.toISOString() }))} + /> +
+ ); +} diff --git a/apps/web/app/api/v1/admin/reports/[id]/route.ts b/apps/web/app/api/v1/admin/reports/[id]/route.ts new file mode 100644 index 0000000..2c151f0 --- /dev/null +++ b/apps/web/app/api/v1/admin/reports/[id]/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/admin/reports/route.ts b/apps/web/app/api/v1/admin/reports/route.ts new file mode 100644 index 0000000..f41c13d --- /dev/null +++ b/apps/web/app/api/v1/admin/reports/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/recipes/[id]/comments/route.ts b/apps/web/app/api/v1/recipes/[id]/comments/route.ts index 0a879e0..e109edb 100644 --- a/apps/web/app/api/v1/recipes/[id]/comments/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/comments/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/reports/route.ts b/apps/web/app/api/v1/reports/route.ts new file mode 100644 index 0000000..9711cc8 --- /dev/null +++ b/apps/web/app/api/v1/reports/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/users/[username]/block/route.ts b/apps/web/app/api/v1/users/[username]/block/route.ts new file mode 100644 index 0000000..63bd0be --- /dev/null +++ b/apps/web/app/api/v1/users/[username]/block/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/users/[username]/follow/route.ts b/apps/web/app/api/v1/users/[username]/follow/route.ts index a30b19a..19da343 100644 --- a/apps/web/app/api/v1/users/[username]/follow/route.ts +++ b/apps/web/app/api/v1/users/[username]/follow/route.ts @@ -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(); diff --git a/apps/web/components/admin/reports-queue.tsx b/apps/web/components/admin/reports-queue.tsx new file mode 100644 index 0000000..3468f96 --- /dev/null +++ b/apps/web/components/admin/reports-queue.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +type Report = { + id: string; + targetType: "recipe" | "comment" | "user"; + targetId: string; + reason: string; + createdAt: string; + reporterName: string; + reporterEmail: string; +}; + +export function ReportsQueue({ reports }: { reports: Report[] }) { + const router = useRouter(); + + async function resolve(id: string, status: "reviewed" | "dismissed") { + const res = await fetch(`/api/v1/admin/reports/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { + toast.error("Failed to update report"); + return; + } + toast.success(status === "reviewed" ? "Marked reviewed" : "Dismissed"); + router.refresh(); + } + + if (reports.length === 0) { + return

No pending reports.

; + } + + return ( +
+ {reports.map((r) => ( +
+
+
+
+ {r.targetType} + {r.targetId} +
+

{r.reason}

+

+ Reported by {r.reporterName} ({r.reporterEmail}) · {new Date(r.createdAt).toLocaleString()} +

+
+
+ + +
+
+
+ ))} +
+ ); +} diff --git a/apps/web/components/social/block-button.tsx b/apps/web/components/social/block-button.tsx new file mode 100644 index 0000000..eedcc3d --- /dev/null +++ b/apps/web/components/social/block-button.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Ban } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function BlockButton({ + targetUsername, + initialBlocked = false, +}: { + targetUsername: string; + initialBlocked?: boolean; +}) { + const router = useRouter(); + const [blocked, setBlocked] = useState(initialBlocked); + const [loading, setLoading] = useState(false); + + async function toggle() { + if (!blocked && !confirm(`Block @${targetUsername}? They won't be able to follow you or comment on your recipes.`)) { + return; + } + setLoading(true); + try { + const res = await fetch(`/api/v1/users/${targetUsername}/block`, { + method: blocked ? "DELETE" : "POST", + }); + if (!res.ok) { + const err = await res.json() as { error?: string }; + toast.error(err.error ?? "Failed"); + return; + } + setBlocked(!blocked); + toast.success(blocked ? "Unblocked" : "Blocked"); + router.refresh(); + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/apps/web/components/social/comments-section.tsx b/apps/web/components/social/comments-section.tsx index a6bb128..bd17a07 100644 --- a/apps/web/components/social/comments-section.tsx +++ b/apps/web/components/social/comments-section.tsx @@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { CommentReactions } from "@/components/social/comment-reactions"; +import { ReportButton } from "@/components/social/report-button"; import { cn } from "@/lib/utils"; type Comment = { @@ -136,6 +137,9 @@ function CommentItem({ Reply )} + {currentUserId && !isOwn && ( + + )} {isOwn && ( + ) : ( + + )} + { setOpen(next); if (!next) { setReason(""); setSubmitted(false); } }}> + + Report {targetType} + {submitted ? ( +

Thanks — an admin will review this.

+ ) : ( +
+
+ +