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:
admin committed 2026-08-12 09:32:33 +02:00
1 parent 3ea668a137
commit 54fb0d55bc
9 files changed
+423 -56

No files matched your search

+2 -1
View File
@@ -461,7 +461,8 @@
"statsPosts": "Posts",
"statsFollowers": "Follower",
"statsFollowing": "Folgt",
"adoptionStory": "Adoptionsgeschichte"
"adoptionStory": "Adoptionsgeschichte",
"verified": "Verifiziert"
},
"FollowersPage": {
"title": "{count} Follower",
+2 -1
View File
@@ -461,7 +461,8 @@
"statsPosts": "posts",
"statsFollowers": "followers",
"statsFollowing": "following",
"adoptionStory": "Adoption story"
"adoptionStory": "Adoption story",
"verified": "Verified"
},
"FollowersPage": {
"title": "{count} Followers",
+17 -2
View File
@@ -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
+10 -1
View File
@@ -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 */}
+4 -41
View File
@@ -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 });
}
+15
View File
@@ -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>
+197 -3
View File
@@ -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>
);
}
+53
View File
@@ -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
View File
@@ -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