feat(admin): warning system, pet verification badges, report severity, admin GDPR erasure
Gitea #10 Phase 2: - Warning model (append-only, no delete — Owner history), issueWarning + listWarnings, surfaced on the Users page with a badge + dialog - Pet.isVerified checkmark (lives on Pet, not Owner — pets are the social identity per CLAUDE.md), toggleable from the Users page, shown on the public profile page - listReports now derives a HIGH/MEDIUM/LOW severity from ReportReason and sorts severity-first; Reports page shows the derived badge - Admin-triggered GDPR Art. 17 erasure: extracted the self-service /api/account/delete logic into a shared deleteOwnerAccount() helper, new admin.deleteUserAccount (SUPER_ADMIN only, requires typing the exact ownerId to confirm, blocks self-deletion) reuses it — Delete button only renders for SUPER_ADMIN/bootstrap callers - all four new/changed mutations log to ModerationLog with role+IP
This commit is contained in:
1 parent
3ea668a137
commit
54fb0d55bc
9 files changed
+423
-56
No files matched your search
+2
-1
@@ -461,7 +461,8 @@
|
||||
"statsPosts": "Posts",
|
||||
"statsFollowers": "Follower",
|
||||
"statsFollowing": "Folgt",
|
||||
"adoptionStory": "Adoptionsgeschichte"
|
||||
"adoptionStory": "Adoptionsgeschichte",
|
||||
"verified": "Verifiziert"
|
||||
},
|
||||
"FollowersPage": {
|
||||
"title": "{count} Follower",
|
||||
|
||||
+2
-1
@@ -461,7 +461,8 @@
|
||||
"statsPosts": "posts",
|
||||
"statsFollowers": "followers",
|
||||
"statsFollowing": "following",
|
||||
"adoptionStory": "Adoption story"
|
||||
"adoptionStory": "Adoption story",
|
||||
"verified": "Verified"
|
||||
},
|
||||
"FollowersPage": {
|
||||
"title": "{count} Followers",
|
||||
|
||||
+17
-2
@@ -18,10 +18,24 @@ model Owner {
|
||||
contentRightsConsentedAt DateTime? // first-pet onboarding: confirms the owner holds all rights to uploaded photos/videos
|
||||
dataProcessingConsentedAt DateTime? // first-pet onboarding: Willenserklärung consenting to data transfer to Clerk/Cloudflare/Mux/Supabase
|
||||
dataExportTokens DataExportToken[]
|
||||
warnings Warning[]
|
||||
|
||||
@@index([createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
// Append-only moderation history — no delete/edit endpoint by design, mirrors
|
||||
// the Gitea #10 permission-matrix spec's "Verwarnungssystem ... mit Historie".
|
||||
model Warning {
|
||||
id String @id @default(cuid())
|
||||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
ownerId String
|
||||
reason String @db.VarChar(500)
|
||||
issuedBy String // ownerId of the moderator who issued it
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([ownerId, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model InviteCode {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
@@ -86,6 +100,7 @@ model Pet {
|
||||
bio String?
|
||||
adoptionStory String?
|
||||
avatarKey String? // Supabase Storage object key, NOT a full URL
|
||||
isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix)
|
||||
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
|
||||
birthday DateTime? @db.Date // actual birthday for anniversary notifications
|
||||
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
|
||||
@@ -637,8 +652,8 @@ model AdRepost {
|
||||
|
||||
model ModerationLog {
|
||||
id String @id @default(cuid())
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE
|
||||
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT
|
||||
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET
|
||||
targetId String
|
||||
reason String? @db.VarChar(500)
|
||||
moderatorId String // ownerId
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createTRPCCaller } from "@/trpc/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { BadgeCheck } from "lucide-react";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions";
|
||||
import { ProfileTabs } from "@/components/profile/ProfileTabs";
|
||||
@@ -67,7 +68,15 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
initials={getInitials(pet.name)}
|
||||
/>
|
||||
|
||||
<h1 className="text-xl font-semibold text-center">{pet.name}</h1>
|
||||
<h1 className="flex items-center gap-1.5 text-xl font-semibold text-center">
|
||||
{pet.name}
|
||||
{pet.isVerified && (
|
||||
<BadgeCheck
|
||||
className="h-4 w-4 fill-blue-500 text-background"
|
||||
aria-label={t("verified")}
|
||||
/>
|
||||
)}
|
||||
</h1>
|
||||
<Badge variant="secondary">{pet.species.name}</Badge>
|
||||
|
||||
{/* Owner attribution — only shown on own pet */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { deleteFiles } from "@/lib/supabase-storage";
|
||||
import { deleteOwnerAccount } from "@/lib/delete-account";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { userId } = await auth();
|
||||
@@ -17,16 +17,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const pets = await prisma.pet.findMany({
|
||||
where: { ownerId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatarKey: true,
|
||||
posts: {
|
||||
where: { type: "PHOTO" },
|
||||
select: { images: { select: { storageKey: true } } },
|
||||
},
|
||||
stories: { select: { storageKey: true } },
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const validNames = pets.map((p) => p.name.toLowerCase());
|
||||
@@ -34,35 +25,7 @@ export async function POST(req: Request) {
|
||||
return Response.json({ error: "Pet name does not match" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Collect all Supabase storage keys across avatars, post images, and stories
|
||||
const storageKeys: string[] = [];
|
||||
for (const pet of pets) {
|
||||
if (pet.avatarKey) storageKeys.push(pet.avatarKey);
|
||||
for (const post of pet.posts) {
|
||||
for (const image of post.images) {
|
||||
storageKeys.push(image.storageKey);
|
||||
}
|
||||
}
|
||||
for (const story of pet.stories) {
|
||||
storageKeys.push(story.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete storage files — non-blocking on failure (logged inside deleteFiles)
|
||||
await deleteFiles(storageKeys);
|
||||
|
||||
// Delete all pets — cascades posts, comments, reactions, follows, notifications, etc.
|
||||
await prisma.pet.deleteMany({ where: { ownerId: userId } });
|
||||
|
||||
// Delete invite codes this owner issued
|
||||
await prisma.inviteCode.deleteMany({ where: { issuedById: userId } });
|
||||
|
||||
// Delete Owner record — cascades AdminRole + UserBan
|
||||
await prisma.owner.delete({ where: { id: userId } });
|
||||
|
||||
// Delete Clerk user account — this invalidates the current session
|
||||
const clerk = await clerkClient();
|
||||
await clerk.users.deleteUser(userId);
|
||||
await deleteOwnerAccount(prisma, userId);
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
@@ -19,6 +19,15 @@ const REASON_LABELS: Record<string, string> = {
|
||||
OTHER: "Sonstiges",
|
||||
};
|
||||
|
||||
// Severity is derived server-side from `reason` (Gitea #10) — list is already
|
||||
// sorted severity-first, this just renders the label/color.
|
||||
const SEVERITY_LABELS: Record<string, string> = { HIGH: "Hoch", MEDIUM: "Mittel", LOW: "Niedrig" };
|
||||
const SEVERITY_CLASS: Record<string, string> = {
|
||||
HIGH: "border-red-600 text-red-400",
|
||||
MEDIUM: "border-yellow-600 text-yellow-400",
|
||||
LOW: "border-zinc-600 text-zinc-400",
|
||||
};
|
||||
|
||||
type ReportAction =
|
||||
| { kind: "dismiss"; reportId: string }
|
||||
| { kind: "deletePost"; postId: string };
|
||||
@@ -93,6 +102,12 @@ export default function AdminReportsPage() {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] ${SEVERITY_CLASS[report.severity] ?? "border-zinc-600 text-zinc-400"}`}
|
||||
>
|
||||
{SEVERITY_LABELS[report.severity] ?? report.severity}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-yellow-600 text-yellow-400 text-[10px]">
|
||||
{REASON_LABELS[report.reason] ?? report.reason}
|
||||
</Badge>
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { ShieldOff, ShieldCheck, Loader2, ChevronDown, MessageCircle, Download } from "lucide-react";
|
||||
import {
|
||||
ShieldOff,
|
||||
ShieldCheck,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
MessageCircle,
|
||||
Download,
|
||||
BadgeCheck,
|
||||
TriangleAlert,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
@@ -29,6 +39,13 @@ export default function AdminUsersPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [banStatus, setBanStatus] = useState<BanStatusFilter>("all");
|
||||
const [role, setRole] = useState<RoleFilter>("all");
|
||||
const [warnTarget, setWarnTarget] = useState<string | null>(null);
|
||||
const [warnReason, setWarnReason] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState("");
|
||||
|
||||
const { data: myRole } = useQuery(trpc.admin.getMyRole.queryOptions());
|
||||
const canDeleteAccounts = myRole === "BOOTSTRAP" || myRole === "SUPER_ADMIN";
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
|
||||
trpc.admin.listUsers.infiniteQueryOptions(
|
||||
@@ -79,6 +96,45 @@ export default function AdminUsersPage() {
|
||||
})
|
||||
);
|
||||
|
||||
const verifyMutation = useMutation(
|
||||
trpc.admin.setPetVerified.mutationOptions({
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }),
|
||||
})
|
||||
);
|
||||
|
||||
const { data: warningHistory = [] } = useQuery(
|
||||
trpc.admin.listWarnings.queryOptions(
|
||||
{ ownerId: warnTarget ?? "" },
|
||||
{ enabled: !!warnTarget }
|
||||
)
|
||||
);
|
||||
|
||||
const issueWarningMutation = useMutation(
|
||||
trpc.admin.issueWarning.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
||||
void qc.invalidateQueries({
|
||||
queryKey: trpc.admin.listWarnings.queryKey({ ownerId: warnTarget ?? "" }),
|
||||
});
|
||||
setWarnReason("");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const deleteAccountMutation = useMutation(
|
||||
trpc.admin.deleteUserAccount.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
||||
setDeleteTarget(null);
|
||||
setDeleteConfirmation("");
|
||||
toast.success("Account gelöscht.");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Löschen fehlgeschlagen: ${err.message}`);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -148,8 +204,34 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
{user._count.pets} pet{user._count.pets !== 1 ? "s" : ""}
|
||||
{user.pets.length > 0 && " · " + user.pets.map((p) => p.name).join(", ")}
|
||||
{user._count.warnings > 0 &&
|
||||
` · ${user._count.warnings} warning${user._count.warnings !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
{user.pets.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
{user.pets.map((pet) => (
|
||||
<button
|
||||
key={pet.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
verifyMutation.mutate({ petId: pet.id, isVerified: !pet.isVerified })
|
||||
}
|
||||
disabled={verifyMutation.isPending}
|
||||
title={pet.isVerified ? "Verified — click to remove" : "Click to verify"}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||
pet.isVerified
|
||||
? "border-blue-700 bg-blue-950/40 text-blue-300"
|
||||
: "border-zinc-700 text-zinc-400 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{pet.name}
|
||||
<BadgeCheck
|
||||
className={`h-3 w-3 ${pet.isVerified ? "fill-blue-400 text-zinc-950" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{user.ban?.reason && (
|
||||
<p className="text-xs text-red-400 mt-0.5">Reason: {user.ban.reason}</p>
|
||||
)}
|
||||
@@ -186,6 +268,26 @@ export default function AdminUsersPage() {
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||||
onClick={() => setWarnTarget(user.id)}
|
||||
title="Warnings"
|
||||
>
|
||||
<TriangleAlert className="h-4 w-4" />
|
||||
</Button>
|
||||
{canDeleteAccounts && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-400"
|
||||
onClick={() => setDeleteTarget(user.id)}
|
||||
title="Delete account permanently (GDPR erasure)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.ban ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -303,6 +405,98 @@ export default function AdminUsersPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Warnings history + issue new */}
|
||||
<Dialog
|
||||
open={!!warnTarget}
|
||||
onOpenChange={(o) => { if (!o) { setWarnTarget(null); setWarnReason(""); } }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Warnings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-zinc-500 font-mono break-all">{warnTarget}</p>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{warningHistory.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No warnings yet.</p>
|
||||
) : (
|
||||
warningHistory.map((w) => (
|
||||
<div key={w.id} className="rounded-lg border border-zinc-800 px-3 py-2">
|
||||
<p className="text-sm text-zinc-200">{w.reason}</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">
|
||||
{new Date(w.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="New warning reason…"
|
||||
value={warnReason}
|
||||
onChange={(e) => setWarnReason(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setWarnTarget(null)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
issueWarningMutation.mutate({ ownerId: warnTarget!, reason: warnReason.trim() })
|
||||
}
|
||||
disabled={issueWarningMutation.isPending || !warnReason.trim()}
|
||||
className="bg-zinc-700 hover:bg-zinc-600"
|
||||
>
|
||||
{issueWarningMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Issue warning
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete account (GDPR erasure, super-admin only) */}
|
||||
<Dialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setDeleteConfirmation(""); } }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-400">Delete account permanently</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">
|
||||
This permanently deletes the owner, all their pets, posts, and storage files, and
|
||||
revokes their login. This cannot be undone.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Type the user ID to confirm:{" "}
|
||||
<span className="font-mono text-zinc-300">{deleteTarget}</span>
|
||||
</p>
|
||||
<Input
|
||||
placeholder="user_…"
|
||||
value={deleteConfirmation}
|
||||
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteAccountMutation.mutate({
|
||||
ownerId: deleteTarget!,
|
||||
confirmation: deleteConfirmation,
|
||||
})
|
||||
}
|
||||
disabled={deleteAccountMutation.isPending || deleteConfirmation !== deleteTarget}
|
||||
>
|
||||
{deleteAccountMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Delete permanently
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import "server-only";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { deleteFiles } from "@/lib/supabase-storage";
|
||||
|
||||
/**
|
||||
* Permanently deletes an Owner and everything cascading from their pets
|
||||
* (posts, comments, reactions, follows, notifications, etc.), storage files,
|
||||
* issued invite codes, and the Clerk account itself. Irreversible — shared
|
||||
* by the self-service delete route and the admin-triggered GDPR erasure
|
||||
* action, so both paths stay in sync.
|
||||
*/
|
||||
export async function deleteOwnerAccount(prisma: PrismaClient, ownerId: string): Promise<void> {
|
||||
const pets = await prisma.pet.findMany({
|
||||
where: { ownerId },
|
||||
select: {
|
||||
avatarKey: true,
|
||||
posts: {
|
||||
where: { type: "PHOTO" },
|
||||
select: { images: { select: { storageKey: true } } },
|
||||
},
|
||||
stories: { select: { storageKey: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const storageKeys: string[] = [];
|
||||
for (const pet of pets) {
|
||||
if (pet.avatarKey) storageKeys.push(pet.avatarKey);
|
||||
for (const post of pet.posts) {
|
||||
for (const image of post.images) {
|
||||
storageKeys.push(image.storageKey);
|
||||
}
|
||||
}
|
||||
for (const story of pet.stories) {
|
||||
storageKeys.push(story.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocking on failure — logged inside deleteFiles.
|
||||
await deleteFiles(storageKeys);
|
||||
|
||||
// Cascades posts, comments, reactions, follows, notifications, etc.
|
||||
await prisma.pet.deleteMany({ where: { ownerId } });
|
||||
|
||||
await prisma.inviteCode.deleteMany({ where: { issuedById: ownerId } });
|
||||
|
||||
// Cascades AdminRole + UserBan + Warning
|
||||
await prisma.owner.delete({ where: { id: ownerId } });
|
||||
|
||||
// Invalidates any active session for this user.
|
||||
const clerk = await clerkClient();
|
||||
await clerk.users.deleteUser(ownerId);
|
||||
}
|
||||
+123
-7
@@ -2,7 +2,7 @@ import "server-only";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AdminRoleType, type PrismaClient } from "@prisma/client";
|
||||
import { AdminRoleType, type PrismaClient, type ReportReason } from "@prisma/client";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { buildDataExport } from "@/lib/gdpr-export";
|
||||
import { uploadExport } from "@/lib/data-export-storage";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { deleteOwnerAccount } from "@/lib/delete-account";
|
||||
|
||||
const DATA_EXPORT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
@@ -85,6 +86,18 @@ function buildBuckets(granularity: Granularity): Date[] {
|
||||
return buckets;
|
||||
}
|
||||
|
||||
type ReportSeverity = "HIGH" | "MEDIUM" | "LOW";
|
||||
|
||||
/** Derived from ReportReason — reporters don't self-rate severity. */
|
||||
const REPORT_SEVERITY: Record<ReportReason, ReportSeverity> = {
|
||||
HARASSMENT: "HIGH",
|
||||
INAPPROPRIATE: "HIGH",
|
||||
MISINFORMATION: "MEDIUM",
|
||||
SPAM: "LOW",
|
||||
OTHER: "LOW",
|
||||
};
|
||||
const SEVERITY_RANK: Record<ReportSeverity, number> = { HIGH: 2, MEDIUM: 1, LOW: 0 };
|
||||
|
||||
/** Counts how many timestamps fall in each bucket (zero-filled — no gaps). */
|
||||
function countByBucket(timestamps: Date[], buckets: Date[], granularity: Granularity): number[] {
|
||||
const counts = new Map<number, number>();
|
||||
@@ -355,10 +368,10 @@ export const adminRouter = router({
|
||||
...(input.role !== "all" && input.role !== "none" ? { adminRole: { role: input.role } } : {}),
|
||||
},
|
||||
include: {
|
||||
pets: { select: { id: true, name: true, avatarKey: true }, take: 3 },
|
||||
pets: { select: { id: true, name: true, avatarKey: true, isVerified: true }, take: 3 },
|
||||
ban: true,
|
||||
adminRole: true,
|
||||
_count: { select: { pets: true } },
|
||||
_count: { select: { pets: true, warnings: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit + 1,
|
||||
@@ -458,6 +471,100 @@ export const adminRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ─── Warnings (append-only, no delete/edit — Gitea #10 permission matrix) ────
|
||||
listWarnings: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
return ctx.prisma.warning.findMany({
|
||||
where: { ownerId: input.ownerId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
issueWarning: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1), reason: z.string().min(1).max(500) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const [warning] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.warning.create({
|
||||
data: { ownerId: input.ownerId, reason: input.reason, issuedBy: ctx.userId },
|
||||
}),
|
||||
ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "WARN_USER",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
reason: input.reason,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return warning;
|
||||
}),
|
||||
|
||||
// ─── Verification checkmark (lives on Pet, not Owner — pets are the social
|
||||
// identity here, see CLAUDE.md) ────────────────────────────────────────────
|
||||
setPetVerified: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1), isVerified: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const [pet] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.pet.update({
|
||||
where: { id: input.petId },
|
||||
data: { isVerified: input.isVerified },
|
||||
}),
|
||||
ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: input.isVerified ? "VERIFY_PET" : "UNVERIFY_PET",
|
||||
targetType: "PET",
|
||||
targetId: input.petId,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return pet;
|
||||
}),
|
||||
|
||||
// ─── Admin-triggered GDPR Art. 17 erasure ("right to be forgotten") ──────────
|
||||
// Reuses the same deleteOwnerAccount() the self-service /api/account/delete
|
||||
// route calls, so both paths stay in sync. `confirmation` must equal the
|
||||
// target ownerId verbatim — mirrors the pet-name-confirmation friction step
|
||||
// on the self-service flow, adapted since an admin acting on someone else's
|
||||
// account can't "prove" they know a pet name the way the owner can.
|
||||
deleteUserAccount: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1), confirmation: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
|
||||
if (input.ownerId === ctx.userId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot delete your own account here." });
|
||||
}
|
||||
if (input.confirmation !== input.ownerId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Confirmation does not match." });
|
||||
}
|
||||
|
||||
await deleteOwnerAccount(ctx.prisma, input.ownerId);
|
||||
|
||||
// Written after deletion succeeds — targetId/moderatorId have no FK to
|
||||
// Owner, so the now-deleted account doesn't block this insert.
|
||||
await ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "DELETE_ACCOUNT",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ─── Team messaging (Gitea #6) ────────────────────────────────────────────────
|
||||
// Reuses the existing pet-to-pet Conversation/Message model instead of a
|
||||
// parallel admin-inbox system: a single "PawFeed Team" system Pet, owned by
|
||||
@@ -633,10 +740,19 @@ export const adminRouter = router({
|
||||
|
||||
const postMap = new Map(posts.map((p) => [p.id, p]));
|
||||
|
||||
return reports.map((r) => ({
|
||||
...r,
|
||||
post: r.targetPostId ? (postMap.get(r.targetPostId) ?? null) : null,
|
||||
}));
|
||||
// Severity-first, newest-within-severity — reports don't carry an
|
||||
// explicit severity column, it's derived from `reason` (Gitea #10).
|
||||
return reports
|
||||
.map((r) => ({
|
||||
...r,
|
||||
severity: REPORT_SEVERITY[r.reason],
|
||||
post: r.targetPostId ? (postMap.get(r.targetPostId) ?? null) : null,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
||||
b.createdAt.getTime() - a.createdAt.getTime()
|
||||
);
|
||||
}),
|
||||
|
||||
dismissReport: protectedProcedure
|
||||
|
||||
Reference in new issue
Block a user