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:
@@ -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) {
|
||||
<p className="text-muted-foreground text-sm">@{user.username}</p>
|
||||
</div>
|
||||
{!isOwnProfile && session && (
|
||||
<FollowButton
|
||||
targetUserId={user.id}
|
||||
targetUsername={user.username!}
|
||||
initialFollowing={isFollowing}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<FollowButton
|
||||
targetUserId={user.id}
|
||||
targetUsername={user.username!}
|
||||
initialFollowing={isFollowing}
|
||||
/>
|
||||
<BlockButton targetUsername={user.username!} initialBlocked={isBlocked} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Reports</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Content flagged by users. Dismiss if unfounded, or review and take action manually.
|
||||
</p>
|
||||
</div>
|
||||
<ReportsQueue
|
||||
reports={rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <p className="text-muted-foreground text-sm">No pending reports.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="capitalize">{r.targetType}</Badge>
|
||||
<span className="text-xs text-muted-foreground font-mono">{r.targetId}</span>
|
||||
</div>
|
||||
<p className="text-sm">{r.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reported by {r.reporterName} ({r.reporterEmail}) · {new Date(r.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button size="sm" variant="outline" onClick={() => { void resolve(r.id, "dismissed"); }}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { void resolve(r.id, "reviewed"); }}>
|
||||
Mark reviewed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void toggle(); }}
|
||||
disabled={loading}
|
||||
className={blocked ? "" : "text-destructive hover:text-destructive"}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
{loading ? "…" : blocked ? "Unblock" : "Block"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 className="h-3 w-3" /> Reply
|
||||
</button>
|
||||
)}
|
||||
{currentUserId && !isOwn && (
|
||||
<ReportButton targetType="comment" targetId={comment.id} />
|
||||
)}
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={deleteComment}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Flag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function ReportButton({
|
||||
targetType,
|
||||
targetId,
|
||||
variant = "icon",
|
||||
}: {
|
||||
targetType: "recipe" | "comment" | "user";
|
||||
targetId: string;
|
||||
variant?: "icon" | "text";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reason.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/reports", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetType, targetId, reason: reason.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
toast.error(err.error ?? "Failed to submit report");
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
toast.success("Report submitted — thanks for flagging this");
|
||||
setTimeout(() => setOpen(false), 800);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "icon" ? (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-xs text-muted-foreground hover:text-destructive flex items-center gap-1"
|
||||
>
|
||||
<Flag className="h-3 w-3" /> Report
|
||||
</button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
|
||||
<Flag className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) { setReason(""); setSubmitted(false); } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader><DialogTitle>Report {targetType}</DialogTitle></DialogHeader>
|
||||
{submitted ? (
|
||||
<p className="text-sm text-muted-foreground py-4">Thanks — an admin will review this.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="report-reason">What's wrong with this?</Label>
|
||||
<Textarea
|
||||
id="report-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Spam, harassment, off-topic…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => { void handleSubmit(); }}
|
||||
disabled={!reason.trim() || submitting}
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit report"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { db, userBlocks, eq, and, or } from "@epicure/db";
|
||||
|
||||
/** True if either user has blocked the other. */
|
||||
export async function isBlockedEitherWay(userIdA: string, userIdB: string): Promise<boolean> {
|
||||
const row = await db.query.userBlocks.findFirst({
|
||||
where: or(
|
||||
and(eq(userBlocks.blockerId, userIdA), eq(userBlocks.blockedId, userIdB)),
|
||||
and(eq(userBlocks.blockerId, userIdB), eq(userBlocks.blockedId, userIdA))
|
||||
),
|
||||
});
|
||||
return !!row;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TYPE "public"."report_status" AS ENUM('pending', 'reviewed', 'dismissed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."report_target_type" AS ENUM('recipe', 'comment', 'user');--> statement-breakpoint
|
||||
CREATE TABLE "user_blocks" (
|
||||
"blocker_id" text NOT NULL,
|
||||
"blocked_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_blocks_blocker_id_blocked_id_pk" PRIMARY KEY("blocker_id","blocked_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reports" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"reporter_id" text NOT NULL,
|
||||
"target_type" "report_target_type" NOT NULL,
|
||||
"target_id" text NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"status" "report_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"reviewed_by_id" text,
|
||||
"reviewed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_blocks" ADD CONSTRAINT "user_blocks_blocker_id_users_id_fk" FOREIGN KEY ("blocker_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_blocks" ADD CONSTRAINT "user_blocks_blocked_id_users_id_fk" FOREIGN KEY ("blocked_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_reviewed_by_id_users_id_fk" FOREIGN KEY ("reviewed_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
||||
"when": 1783108229117,
|
||||
"tag": "0018_mighty_butterfly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1783108786252,
|
||||
"tag": "0019_clumsy_leader",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
boolean,
|
||||
index,
|
||||
unique,
|
||||
pgEnum,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { users, tierEnum, userRoleEnum } from "./users";
|
||||
@@ -73,3 +74,25 @@ export const invitesRelations = relations(invites, ({ one }) => ({
|
||||
createdBy: one(users, { fields: [invites.createdById], references: [users.id] }),
|
||||
usedBy: one(users, { fields: [invites.usedById], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const reportTargetTypeEnum = pgEnum("report_target_type", ["recipe", "comment", "user"]);
|
||||
export const reportStatusEnum = pgEnum("report_status", ["pending", "reviewed", "dismissed"]);
|
||||
|
||||
export const reports = pgTable("reports", {
|
||||
id: text("id").primaryKey(),
|
||||
reporterId: text("reporter_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
targetType: reportTargetTypeEnum("target_type").notNull(),
|
||||
targetId: text("target_id").notNull(),
|
||||
reason: text("reason").notNull(),
|
||||
status: reportStatusEnum("status").notNull().default("pending"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
reviewedById: text("reviewed_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
reviewedAt: timestamp("reviewed_at"),
|
||||
}, (t) => [
|
||||
index("reports_status_idx").on(t.status, t.createdAt),
|
||||
]);
|
||||
|
||||
export const reportsRelations = relations(reports, ({ one }) => ({
|
||||
reporter: one(users, { fields: [reports.reporterId], references: [users.id] }),
|
||||
reviewedBy: one(users, { fields: [reports.reviewedById], references: [users.id] }),
|
||||
}));
|
||||
|
||||
@@ -76,6 +76,19 @@ export const userFollows = pgTable("user_follows", {
|
||||
primaryKey({ columns: [t.followerId, t.followingId] }),
|
||||
]);
|
||||
|
||||
export const userBlocks = pgTable("user_blocks", {
|
||||
blockerId: text("blocker_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
blockedId: text("blocked_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.blockerId, t.blockedId] }),
|
||||
]);
|
||||
|
||||
export const userBlocksRelations = relations(userBlocks, ({ one }) => ({
|
||||
blocker: one(users, { fields: [userBlocks.blockerId], references: [users.id], relationName: "blocker" }),
|
||||
blocked: one(users, { fields: [userBlocks.blockedId], references: [users.id], relationName: "blocked" }),
|
||||
}));
|
||||
|
||||
export const apiKeys = pgTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
Reference in New Issue
Block a user