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.

+ ) : ( +
+
+ +