From 54fb0d55bc81fa178242db702917075c31f5473c Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Wed, 12 Aug 2026 09:32:33 +0200 Subject: [PATCH] feat(admin): warning system, pet verification badges, report severity, admin GDPR erasure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 3 +- messages/en.json | 3 +- prisma/schema.prisma | 19 ++- src/app/(app)/pets/[petId]/page.tsx | 11 +- src/app/api/account/delete/route.ts | 45 +------ src/app/p/[secret]/reports/page.tsx | 15 +++ src/app/p/[secret]/users/page.tsx | 200 +++++++++++++++++++++++++++- src/lib/delete-account.ts | 53 ++++++++ src/trpc/routers/admin.ts | 130 +++++++++++++++++- 9 files changed, 423 insertions(+), 56 deletions(-) create mode 100644 src/lib/delete-account.ts diff --git a/messages/de.json b/messages/de.json index cae851b..9178250 100644 --- a/messages/de.json +++ b/messages/de.json @@ -461,7 +461,8 @@ "statsPosts": "Posts", "statsFollowers": "Follower", "statsFollowing": "Folgt", - "adoptionStory": "Adoptionsgeschichte" + "adoptionStory": "Adoptionsgeschichte", + "verified": "Verifiziert" }, "FollowersPage": { "title": "{count} Follower", diff --git a/messages/en.json b/messages/en.json index 66f53b1..885b056 100644 --- a/messages/en.json +++ b/messages/en.json @@ -461,7 +461,8 @@ "statsPosts": "posts", "statsFollowers": "followers", "statsFollowing": "following", - "adoptionStory": "Adoption story" + "adoptionStory": "Adoption story", + "verified": "Verified" }, "FollowersPage": { "title": "{count} Followers", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cc3ba05..f83d920 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 3349d39..73a315d 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -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)} /> -

{pet.name}

+

+ {pet.name} + {pet.isVerified && ( + + )} +

{pet.species.name} {/* Owner attribution — only shown on own pet */} diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 5801f0f..98ae082 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -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 }); } diff --git a/src/app/p/[secret]/reports/page.tsx b/src/app/p/[secret]/reports/page.tsx index 3be3a18..a08008f 100644 --- a/src/app/p/[secret]/reports/page.tsx +++ b/src/app/p/[secret]/reports/page.tsx @@ -19,6 +19,15 @@ const REASON_LABELS: Record = { 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 = { HIGH: "Hoch", MEDIUM: "Mittel", LOW: "Niedrig" }; +const SEVERITY_CLASS: Record = { + 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() {
+ + {SEVERITY_LABELS[report.severity] ?? report.severity} + {REASON_LABELS[report.reason] ?? report.reason} diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index 5abadcf..d4e4455 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -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("all"); const [role, setRole] = useState("all"); + const [warnTarget, setWarnTarget] = useState(null); + const [warnReason, setWarnReason] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(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 (
@@ -148,8 +204,34 @@ export default function AdminUsersPage() { )}

{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" : ""}`}

+ {user.pets.length > 0 && ( +
+ {user.pets.map((pet) => ( + + ))} +
+ )} {user.ban?.reason && (

Reason: {user.ban.reason}

)} @@ -186,6 +268,26 @@ export default function AdminUsersPage() { )} + + {canDeleteAccounts && ( + + )} {user.ban ? ( + + + + + + {/* Delete account (GDPR erasure, super-admin only) */} + { if (!o) { setDeleteTarget(null); setDeleteConfirmation(""); } }} + > + + + Delete account permanently + +

+ This permanently deletes the owner, all their pets, posts, and storage files, and + revokes their login. This cannot be undone. +

+

+ Type the user ID to confirm:{" "} + {deleteTarget} +

+ setDeleteConfirmation(e.target.value)} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm" + /> + + + + +
+
); } diff --git a/src/lib/delete-account.ts b/src/lib/delete-account.ts new file mode 100644 index 0000000..b0b6323 --- /dev/null +++ b/src/lib/delete-account.ts @@ -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 { + 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); +} diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index dc69748..2fa0de9 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -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 = { + HARASSMENT: "HIGH", + INAPPROPRIATE: "HIGH", + MISINFORMATION: "MEDIUM", + SPAM: "LOW", + OTHER: "LOW", +}; +const SEVERITY_RANK: Record = { 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(); @@ -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