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 ? (
);
}
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