From 566a2eedd088eb1159aa5a1b73ca063d75277126 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Fri, 2 Oct 2026 21:03:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Admin-Tierverwaltung=20+=20vollst=C3=A4?= =?UTF-8?q?ndiges=20L=C3=B6schen=20von=20Tieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin /p/[secret]/pets: - Liste mit Suche (Name, Spitzname, Empfehlungscode, ID), Filtern und Sortierung; Detail-Panel mit Kennzahlen, Owner, Profiltexten, Beiträgen - Verifiziert / Gründungsmitglied pro Tier umschalten - Empfehlungsränge des Owners: dauerhaft entziehen (bis zur Freigabe), freigeben oder manuell vergeben (besondere Leistung) - Inhalte leeren (Profilbild, Bio, Adoptionsgeschichte, Spitzname) und Tier löschen — jeweils mit Begründung, Owner per Team-DM informiert bzw. per E-Mail, wenn es sein einziges Tier war Ränge: ReferralRankAchievement bekommt revokedAt/revokeReason/ grantedManually; entzogene Ränge werden weder neu vergeben noch im Cache berücksichtigt (awardRanks, recomputeReferralRanks, referrals.me, Export). Fix: deletePetCompletely (src/lib/delete-pet.ts) für Owner-Löschen, Kontolöschung und Admin — vorher konnte das Löschen an StoryView/Report/ Repost-Fremdschlüsseln ohne Cascade scheitern, und die Zähler an fremden Beiträgen/Storys wurden nicht verringert. Co-Authored-By: Claude Opus 5.5 --- prisma/schema.prisma | 22 +- src/__tests__/delete-pet.test.ts | 74 +++++ src/__tests__/helpers/prisma-mock.ts | 4 + src/__tests__/pet-admin.test.ts | 173 ++++++++++ src/__tests__/pet-profile.test.ts | 12 +- src/app/p/[secret]/layout.tsx | 2 + src/app/p/[secret]/pets/page.tsx | 163 ++++++++++ src/components/admin/pets/PetDetailSheet.tsx | 148 +++++++++ .../admin/pets/PetModerationSection.tsx | 147 +++++++++ src/components/admin/pets/PetRankSection.tsx | 112 +++++++ src/lib/delete-account.ts | 34 +- src/lib/delete-pet.ts | 109 +++++++ src/lib/gdpr-export.ts | 3 + src/lib/referral-recompute.ts | 5 +- src/lib/referrals.ts | 19 +- src/trpc/routers/_app.ts | 2 + src/trpc/routers/pet-admin.ts | 299 ++++++++++++++++++ src/trpc/routers/pets.ts | 5 +- src/trpc/routers/referral-admin.ts | 52 ++- src/trpc/routers/referrals.ts | 7 +- 20 files changed, 1337 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/delete-pet.test.ts create mode 100644 src/__tests__/pet-admin.test.ts create mode 100644 src/app/p/[secret]/pets/page.tsx create mode 100644 src/components/admin/pets/PetDetailSheet.tsx create mode 100644 src/components/admin/pets/PetModerationSection.tsx create mode 100644 src/components/admin/pets/PetRankSection.tsx create mode 100644 src/lib/delete-pet.ts create mode 100644 src/trpc/routers/pet-admin.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1a8ccc..524c670 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,12 +106,20 @@ model ReferralRank { // Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz, // einmalige Rang-Benachrichtigungen und spätere Gutscheine. model ReferralRankAchievement { - id String @id @default(cuid()) - owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) - ownerId String - rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict) - rankId String - achievedAt DateTime @default(now()) + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String + rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict) + rankId String + achievedAt DateTime @default(now()) + // Admin-Steuerung (Tierverwaltung): ein entzogener Rang bleibt als Zeile + // stehen, damit ihn awardRanks/recomputeReferralRanks nicht neu vergeben — + // er zählt erst wieder, wenn ein Admin ihn freigibt (revokedAt = null). + revokedAt DateTime? + revokedBy String? // ownerId des Admins + revokeReason String? @db.VarChar(500) + grantedManually Boolean @default(false) // vom Admin vergeben (besondere Leistung), nicht über den Zähler + grantedBy String? // ownerId des Admins @@unique([ownerId, rankId]) @@index([rankId]) @@ -989,7 +997,7 @@ 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 | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS + 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 | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK targetId String reason String? @db.VarChar(500) diff --git a/src/__tests__/delete-pet.test.ts b/src/__tests__/delete-pet.test.ts new file mode 100644 index 0000000..c133e8c --- /dev/null +++ b/src/__tests__/delete-pet.test.ts @@ -0,0 +1,74 @@ +/** + * deletePetCompletely (src/lib/delete-pet.ts) — von pets.delete, + * Kontolöschung und Admin genutzt. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("server-only", () => ({})); +const mockDeleteFiles = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) })); +const mockMuxDelete = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/mux", () => ({ mux: { video: { assets: { delete: (...a: unknown[]) => mockMuxDelete(...a) } } } })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +import { deletePetCompletely } from "@/lib/delete-pet"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const asDb = (m: ReturnType) => m as any; + +const sqlOf = (call: unknown[]) => (call[0] as TemplateStringsArray).join("?"); + +beforeEach(() => vi.clearAllMocks()); + +describe("deletePetCompletely", () => { + function setup() { + const prisma = createMockPrisma(); + prisma.pet.findUnique.mockResolvedValue({ + avatarKey: "avatars/a.png", + posts: [ + { images: [{ storageKey: "posts/p1.jpg" }, { storageKey: "posts/p2.jpg" }], videoPost: null }, + { images: [], videoPost: { muxAssetId: "asset-post" } }, + ], + stories: [{ storageKey: "stories/s1.jpg", muxAssetId: null }, { storageKey: null, muxAssetId: "asset-story" }], + }); + prisma.pet.delete.mockResolvedValue({}); + prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma)); + return prisma; + } + + it("fixes foreign counters, removes blockers and then deletes the pet", async () => { + const prisma = setup(); + + await deletePetCompletely(asDb(prisma), "pet-1"); + + const statements = prisma.$executeRaw.mock.calls.map(sqlOf); + expect(statements.some((s) => s.includes(`"reactionCount"`) && s.includes(`"Reaction"`))).toBe(true); + expect(statements.some((s) => s.includes(`"commentCount"`) && s.includes(`"Comment"`))).toBe(true); + expect(statements.some((s) => s.includes(`"repostCount"`))).toBe(true); + expect(statements.some((s) => s.includes(`"StoryReaction"`))).toBe(true); + expect(statements.some((s) => s.includes(`"StoryComment"`))).toBe(true); + expect(statements.some((s) => s.includes(`DELETE FROM "StoryView"`))).toBe(true); + expect(statements.some((s) => s.includes(`DELETE FROM "Report"`))).toBe(true); + expect(statements.some((s) => s.includes(`DELETE FROM "Conversation"`))).toBe(true); + expect(prisma.pet.delete).toHaveBeenCalledWith({ where: { id: "pet-1" } }); + // Pet-Delete erst nach allen Vorbereitungen + const lastRaw = Math.max(...prisma.$executeRaw.mock.invocationCallOrder); + expect(prisma.pet.delete.mock.invocationCallOrder[0]).toBeGreaterThan(lastRaw); + }); + + it("removes avatar, post images, story images and Mux assets", async () => { + const prisma = setup(); + await deletePetCompletely(asDb(prisma), "pet-1"); + expect(mockDeleteFiles).toHaveBeenCalledWith(["avatars/a.png", "posts/p1.jpg", "posts/p2.jpg", "stories/s1.jpg"]); + expect(mockMuxDelete).toHaveBeenCalledWith("asset-post"); + expect(mockMuxDelete).toHaveBeenCalledWith("asset-story"); + }); + + it("does nothing for an unknown pet", async () => { + const prisma = createMockPrisma(); + prisma.pet.findUnique.mockResolvedValue(null); + await deletePetCompletely(asDb(prisma), "nope"); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 1cb62f6..dd1ae7a 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -341,7 +341,9 @@ interface MockReferralRankClient { interface MockReferralRankAchievementClient { findMany: MockFn; + findUnique: MockFn; createMany: MockFn; + upsert: MockFn; deleteMany: MockFn; groupBy: MockFn; } @@ -697,7 +699,9 @@ export function createMockPrisma(): MockPrismaClient { }, referralRankAchievement: { findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), createMany: vi.fn().mockResolvedValue({ count: 0 }), + upsert: vi.fn().mockResolvedValue({}), deleteMany: vi.fn(), groupBy: vi.fn().mockResolvedValue([]), }, diff --git a/src/__tests__/pet-admin.test.ts b/src/__tests__/pet-admin.test.ts new file mode 100644 index 0000000..2e4793e --- /dev/null +++ b/src/__tests__/pet-admin.test.ts @@ -0,0 +1,173 @@ +/** + * Admin-Tierverwaltung (pet-admin.ts) und dauerhaftes Entziehen/Vergeben von + * Empfehlungsrängen (referral-admin.ts, referrals.ts). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); +vi.mock("@/lib/redis", () => ({ redis: {} })); +vi.mock("@/lib/notify", () => ({ notify: vi.fn() })); +vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: vi.fn() })); +const mockDeletePet = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...a: unknown[]) => mockDeletePet(...a) })); +const mockDeleteFiles = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) })); +const mockSendTeamMessage = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/team-pet", () => ({ + ensureTeamPet: vi.fn().mockResolvedValue({ id: "pet-team" }), + sendTeamMessage: (...a: unknown[]) => mockSendTeamMessage(...a), +})); +const mockSendMail = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/mail", () => ({ sendMail: (...a: unknown[]) => mockSendMail(...a) })); +vi.mock("@clerk/nextjs/server", () => ({ + clerkClient: vi.fn().mockResolvedValue({ + users: { + getUser: vi.fn().mockResolvedValue({ + firstName: "Dani", + primaryEmailAddressId: "e1", + emailAddresses: [{ id: "e1", emailAddress: "owner@example.com" }], + }), + }, + }), +})); +const mockAssertAdmin = vi.fn(); +vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +import { awardRanks } from "@/lib/referrals"; + +type MockPrisma = ReturnType; + +async function createCaller(prisma: MockPrisma = createMockPrisma()) { + const { petAdminRouter } = await import("@/trpc/routers/pet-admin"); + const { referralAdminRouter } = await import("@/trpc/routers/referral-admin"); + const { initTRPC } = await import("@trpc/server"); + const t = initTRPC.context<{ userId: string; prisma: unknown; ip: string }>().create(); + const r = t.router({ petAdmin: petAdminRouter, referralAdmin: referralAdminRouter }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { c: r.createCaller({ userId: "admin-1", prisma: prisma as any, ip: "127.0.0.1" } as any), prisma }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockAssertAdmin.mockResolvedValue("SUPER_ADMIN"); +}); + +describe("petAdmin.delete", () => { + it("deletes completely and tells the owner by DM on another of their pets", async () => { + const prisma = createMockPrisma(); + prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" }); + prisma.pet.findFirst.mockResolvedValue({ id: "pet-other" }); + prisma.moderationLog.create.mockResolvedValue({}); + const { c } = await createCaller(prisma); + + const res = await c.petAdmin.delete({ petId: "pet-luna", reason: "Fake-Profil", notifyOwner: true }); + + expect(res).toEqual({ notified: "dm" }); + expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "SUPER_ADMIN"); + expect(mockDeletePet).toHaveBeenCalledWith(prisma, "pet-luna"); + expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-other", expect.stringContaining("Fake-Profil")); + expect(prisma.moderationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "DELETE_PET", targetType: "PET", targetId: "pet-luna" }), + }); + }); + + it("falls back to e-mail when it was the owner's only pet", async () => { + const prisma = createMockPrisma(); + prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" }); + prisma.pet.findFirst.mockResolvedValue(null); + prisma.moderationLog.create.mockResolvedValue({}); + const { c } = await createCaller(prisma); + + const res = await c.petAdmin.delete({ petId: "pet-luna", reason: "Spam", notifyOwner: true }); + + expect(res).toEqual({ notified: "email" }); + const mail = mockSendMail.mock.calls[0][0]; + expect(mail.to).toBe("owner@example.com"); + expect(mail.html).toContain("<b>Spam</b>"); + expect(mail.html).not.toContain("Spam"); + }); + + it("refuses to delete the curator pet", async () => { + const prisma = createMockPrisma(); + prisma.pet.findUnique.mockResolvedValue({ name: "PawFeed Team", ownerId: "admin" }); + const { c } = await createCaller(prisma); + await expect(c.petAdmin.delete({ petId: "pet-team", reason: "x", notifyOwner: false })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockDeletePet).not.toHaveBeenCalled(); + }); +}); + +describe("petAdmin.clearContent", () => { + it("clears the chosen fields, removes the avatar file and logs it", async () => { + const prisma = createMockPrisma(); + prisma.pet.findUnique.mockResolvedValue({ avatarKey: "avatars/x.png" }); + prisma.pet.update.mockResolvedValue({}); + prisma.moderationLog.create.mockResolvedValue({}); + mockAssertAdmin.mockResolvedValue("MODERATOR"); + const { c } = await createCaller(prisma); + + await c.petAdmin.clearContent({ petId: "pet-1", fields: ["avatar", "bio"], reason: "anstößig", notifyOwner: false }); + + expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { avatarKey: null, bio: null } }); + expect(mockDeleteFiles).toHaveBeenCalledWith(["avatars/x.png"]); + expect(mockSendTeamMessage).not.toHaveBeenCalled(); + }); +}); + +describe("referral ranks: revoke and grant", () => { + function txPrisma() { + const prisma = createMockPrisma(); + prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma)); + prisma.owner.update.mockResolvedValue({}); + prisma.moderationLog.create.mockResolvedValue({}); + return prisma; + } + + it("revoking keeps the row with revokedAt instead of deleting it", async () => { + const prisma = txPrisma(); + const { c } = await createCaller(prisma); + await c.referralAdmin.revokeRank({ ownerId: "owner-1", rankId: "r3", reason: "Fake-Accounts" }); + expect(prisma.referralRankAchievement.upsert).toHaveBeenCalledWith( + expect.objectContaining({ update: expect.objectContaining({ revokedAt: expect.any(Date), revokeReason: "Fake-Accounts" }) }) + ); + expect(prisma.referralRankAchievement.deleteMany).not.toHaveBeenCalled(); + }); + + it("granting restores a revoked rank, or awards one manually", async () => { + const restored = txPrisma(); + restored.referralRankAchievement.findUnique.mockResolvedValue({ revokedAt: new Date() }); + expect(await (await createCaller(restored)).c.referralAdmin.grantRank({ ownerId: "owner-1", rankId: "r3" })).toEqual({ success: true, restored: true }); + + const manual = txPrisma(); + manual.pet.findFirst.mockResolvedValue({ id: "pet-1" }); + await (await createCaller(manual)).c.referralAdmin.grantRank({ ownerId: "owner-1", rankId: "r10", note: "Tierheim-Aktion" }); + expect(manual.referralRankAchievement.upsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ grantedManually: true, grantedBy: "admin-1" }) }) + ); + expect(manual.moderationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "GRANT_REFERRAL_RANK" }) }); + }); + + it("awardRanks never re-awards a revoked rank and leaves it out of the badge cache", async () => { + const prisma = createMockPrisma(); + prisma.referralRank.findMany.mockResolvedValue([ + { id: "r1", threshold: 1, isActive: true, perks: ["PROFILE_RING"] }, + { id: "r3", threshold: 3, isActive: true, perks: ["NAME_BADGE"] }, + ]); + prisma.referralRankAchievement.findMany.mockResolvedValue([ + { rankId: "r1", revokedAt: null }, + { rankId: "r3", revokedAt: new Date() }, + ]); + prisma.owner.update.mockResolvedValue({}); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newRanks = await awardRanks(prisma as any, "owner-1", 5); + + expect(newRanks).toEqual([]); + expect(prisma.owner.update).toHaveBeenCalledWith({ + where: { id: "owner-1" }, + data: { referralRankId: "r1", referralPerks: ["PROFILE_RING"] }, + }); + }); +}); diff --git a/src/__tests__/pet-profile.test.ts b/src/__tests__/pet-profile.test.ts index 449c9ef..642a05c 100644 --- a/src/__tests__/pet-profile.test.ts +++ b/src/__tests__/pet-profile.test.ts @@ -18,6 +18,8 @@ import { petsRouter } from "@/trpc/routers/pets"; import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; vi.mock("server-only", () => ({})); +const mockDeletePetCompletely = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...args: unknown[]) => mockDeletePetCompletely(...args) })); // Mock Clerk server auth — pets router imports from @clerk/nextjs/server via trpc/init vi.mock("@clerk/nextjs/server", () => ({ @@ -188,20 +190,18 @@ describe("PET-03: Pet deletion by owner", () => { vi.clearAllMocks(); }); - it("pets.delete removes the pet row when called by the pet's owner", async () => { + it("pets.delete deletes the pet completely when called by the pet's owner", async () => { mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: TEST_OWNER_ID, }); - mockPrisma.pet.delete.mockResolvedValue(MOCK_PET); const caller = createTestCaller(TEST_OWNER_ID, mockPrisma); const result = await caller.delete({ petId: PET_ID }); expect(result).toEqual({ success: true }); - expect(mockPrisma.pet.delete).toHaveBeenCalledWith({ - where: { id: PET_ID }, - }); + // Vollständiges Löschen (Story-Aufrufe, Paw-Backs, Zähler, Dateien) — siehe delete-pet.test.ts + expect(mockDeletePetCompletely).toHaveBeenCalledWith(mockPrisma, PET_ID); }); it("pets.delete throws FORBIDDEN when called by a non-owner userId", async () => { @@ -218,7 +218,7 @@ describe("PET-03: Pet deletion by owner", () => { code: "FORBIDDEN", }); - expect(mockPrisma.pet.delete).not.toHaveBeenCalled(); + expect(mockDeletePetCompletely).not.toHaveBeenCalled(); }); }); diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index e9265f7..8fae6b4 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -23,6 +23,7 @@ import { Music, Gift, Sprout, + PawPrint, } from "lucide-react"; interface AdminLayoutProps { @@ -63,6 +64,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps { href: `${base}/posts`, label: "Posts", Icon: FileText }, { href: `${base}/reports`, label: "Reports", Icon: Flag }, { href: `${base}/users`, label: "Users", Icon: Users }, + { href: `${base}/pets`, label: "Tiere", Icon: PawPrint }, { href: `${base}/messages`, label: "Messages", Icon: MessageCircle }, { href: `${base}/ads`, label: "Ads", Icon: Megaphone }, { href: `${base}/music`, label: "Musik-Katalog", Icon: Music }, diff --git a/src/app/p/[secret]/pets/page.tsx b/src/app/p/[secret]/pets/page.tsx new file mode 100644 index 0000000..283ddb0 --- /dev/null +++ b/src/app/p/[secret]/pets/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { BadgeCheck, Loader2, Search, Sprout } from "lucide-react"; +import { useTRPC } from "@/trpc/client"; +import { useDebounce } from "@/hooks/use-debounce"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { PetAvatar } from "@/components/ui/pet-avatar"; +import { ReferralRankBadge } from "@/components/referrals/ReferralRankBadge"; +import { PetDetailSheet } from "@/components/admin/pets/PetDetailSheet"; + +type Filter = "all" | "verified" | "founding" | "ranked" | "bannedOwner"; +type Sort = "newest" | "followers" | "posts"; + +const FILTERS: { value: Filter; label: string }[] = [ + { value: "all", label: "Alle" }, + { value: "verified", label: "Verifiziert" }, + { value: "founding", label: "Gründungsmitglied" }, + { value: "ranked", label: "Mit Empfehlungsrang" }, + { value: "bannedOwner", label: "Owner gesperrt" }, +]; +const SORTS: { value: Sort; label: string }[] = [ + { value: "newest", label: "Neueste zuerst" }, + { value: "followers", label: "Meiste Follower" }, + { value: "posts", label: "Meiste Beiträge" }, +]; +const SEARCH_DEBOUNCE_MS = 300; +const selectClass = "h-9 rounded-md border bg-background px-2 text-sm"; + +/** + * Tierverwaltung — jedes Tier einzeln: Suche nach Name, Spitzname, + * Empfehlungscode oder ID; Details und Aktionen im Seitenpanel. + * Berechtigungen prüft pet-admin.ts / referral-admin.ts serverseitig. + */ +export default function AdminPetsPage() { + const trpc = useTRPC(); + const [search, setSearch] = useState(""); + const [speciesId, setSpeciesId] = useState(""); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("newest"); + const [openPetId, setOpenPetId] = useState(null); + const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS); + + const { data: species = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: "de" })); + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery( + trpc.petAdmin.list.infiniteQueryOptions( + { search: debouncedSearch || undefined, speciesId: speciesId || undefined, filter, sort }, + { getNextPageParam: (last) => last.nextCursor, initialCursor: null } + ) + ); + const pets = data?.pages.flatMap((page) => page.pets) ?? []; + + return ( +
+
+

Tiere

+

Jedes Tier einzeln verwalten: Abzeichen, Ränge des Owners, Inhalte, Löschen.

+
+ +
+
+
+ + + +
+ + {isLoading ? ( + + ) : pets.length === 0 ? ( +

Keine Tiere gefunden.

+ ) : ( +
+ + + + + + + + + + + + + {pets.map((pet) => { + const ban = pet.owner.ban; + const isOwnerBanned = !!ban && (!ban.expiresAt || new Date(ban.expiresAt) > new Date()); + return ( + setOpenPetId(pet.id)} + > + + + + + + + + ); + })} + +
TierTierartBeiträgeFollowerAngelegtStatus
+ + {pet.species.name}{pet._count.posts}{pet._count.followers}{new Date(pet.createdAt).toLocaleDateString("de-DE")}{isOwnerBanned && Owner gesperrt}
+
+ )} + + {hasNextPage && ( + + )} + + setOpenPetId(null)} /> +
+ ); +} diff --git a/src/components/admin/pets/PetDetailSheet.tsx b/src/components/admin/pets/PetDetailSheet.tsx new file mode 100644 index 0000000..3869e58 --- /dev/null +++ b/src/components/admin/pets/PetDetailSheet.tsx @@ -0,0 +1,148 @@ +"use client"; + +import Link from "next/link"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { PetAvatar } from "@/components/ui/pet-avatar"; +import { getMediaUrl } from "@/lib/media-url"; +import { PetRankSection } from "@/components/admin/pets/PetRankSection"; +import { PetModerationSection } from "@/components/admin/pets/PetModerationSection"; + +/** Detailansicht eines Tiers in der Admin-Tierverwaltung. */ +export function PetDetailSheet({ petId, onClose }: { petId: string | null; onClose: () => void }) { + const trpc = useTRPC(); + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ + ...trpc.petAdmin.detail.queryOptions({ petId: petId ?? "" }), + enabled: !!petId, + }); + + const refresh = () => { + void qc.invalidateQueries(trpc.petAdmin.pathFilter()); + }; + const setVerified = useMutation(trpc.admin.setPetVerified.mutationOptions({ onSuccess: refresh, onError: (e) => toast.error(e.message) })); + const setFounding = useMutation(trpc.petAdmin.setFoundingMember.mutationOptions({ onSuccess: refresh, onError: (e) => toast.error(e.message) })); + + const pet = data?.pet; + + return ( + !open && onClose()}> + + {isLoading || !pet ? ( + + ) : ( +
+ +
+ +
+ + {pet.name} + {pet.nickname && ({pet.nickname})} + +

+ {pet.species.name} + {pet.breed && ` · ${pet.breed.name}`} · seit {new Date(pet.createdAt).toLocaleDateString("de-DE")} +

+
+ + + +
+
+ +
+ {[ + ["Beiträge", pet._count.posts], + ["Follower", pet._count.followers], + ["Folgt", pet._count.following], + ["Storys", pet._count.stories], + ].map(([label, value]) => ( +
+
{value}
+
{label}
+
+ ))} +
+ +
+

Owner

+

{pet.owner.id}

+

+ Empfehlungscode {pet.owner.referralCode ?? "–"} · {pet.owner._count.pets} Tier(e) +

+ {pet.owner.ban && Gesperrt: {pet.owner.ban.reason ?? "ohne Grund"}} + {pet.owner.shadowban && Shadowban} +
+ + {(pet.bio || pet.adoptionStory) && ( +
+

Profiltexte

+ {pet.bio &&

{pet.bio}

} + {pet.adoptionStory &&

{pet.adoptionStory}

} +
+ )} + + {pet.posts.length > 0 && ( +
+

Letzte Beiträge

+
+ {pet.posts.map((post) => { + const key = post.images[0]?.storageKey; + return ( +
+ {key ? ( + // eslint-disable-next-line @next/next/no-img-element -- Admin-Vorschau, keine Optimierung nötig + {post.caption + ) : ( + {post.type} + )} + {post.hiddenAt && versteckt} +
+ ); + })} +
+
+ )} + +
+

Abzeichen dieses Tiers

+ + +
+ + + + { + refresh(); + onClose(); + }} + /> +
+ )} +
+
+ ); +} diff --git a/src/components/admin/pets/PetModerationSection.tsx b/src/components/admin/pets/PetModerationSection.tsx new file mode 100644 index 0000000..a3182c0 --- /dev/null +++ b/src/components/admin/pets/PetModerationSection.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +const FIELDS = [ + { key: "avatar", label: "Profilbild" }, + { key: "bio", label: "Bio" }, + { key: "adoptionStory", label: "Adoptionsgeschichte" }, + { key: "nickname", label: "Spitzname" }, +] as const; +type FieldKey = (typeof FIELDS)[number]["key"]; + +const NOTIFIED_LABEL = { dm: "Owner per Direktnachricht informiert.", email: "Owner per E-Mail informiert.", none: "Owner nicht benachrichtigt." }; + +type Props = { petId: string; petName: string; onChanged: () => void; onDeleted: () => void }; + +/** Inhalte leeren (ohne Löschen) und Tier endgültig löschen — jeweils mit Begründung. */ +export function PetModerationSection({ petId, petName, onChanged, onDeleted }: Props) { + const trpc = useTRPC(); + const [fields, setFields] = useState>(new Set()); + const [reason, setReason] = useState(""); + const [notifyOwner, setNotifyOwner] = useState(true); + const [confirmDelete, setConfirmDelete] = useState(false); + const trimmed = reason.trim(); + + const clear = useMutation( + trpc.petAdmin.clearContent.mutationOptions({ + onSuccess: () => { + toast.success("Inhalte entfernt"); + setFields(new Set()); + setReason(""); + onChanged(); + }, + onError: (e) => toast.error(e.message), + }) + ); + const remove = useMutation( + trpc.petAdmin.delete.mutationOptions({ + onSuccess: (res) => { + toast.success(`„${petName}“ gelöscht. ${NOTIFIED_LABEL[res.notified]}`); + setConfirmDelete(false); + onDeleted(); + }, + onError: (e) => { + setConfirmDelete(false); + toast.error(e.message); + }, + }) + ); + + const toggle = (key: FieldKey) => + setFields((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + return ( +
+

Moderation

+