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 && (