feat: Admin-Tierverwaltung + vollständiges Löschen von Tieren
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
7a064d8a36
commit
566a2eedd0
20 files changed
+1337
-55
No files matched your search
+15
-7
@@ -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)
|
||||
|
||||
@@ -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<typeof createMockPrisma>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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([]),
|
||||
},
|
||||
|
||||
@@ -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<typeof createMockPrisma>;
|
||||
|
||||
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: "<b>Spam</b>", 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("<b>Spam</b>");
|
||||
});
|
||||
|
||||
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"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Filter>("all");
|
||||
const [sort, setSort] = useState<Sort>("newest");
|
||||
const [openPetId, setOpenPetId] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Tiere</h1>
|
||||
<p className="text-sm text-muted-foreground">Jedes Tier einzeln verwalten: Abzeichen, Ränge des Owners, Inhalte, Löschen.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Name, Spitzname, Empfehlungscode oder ID"
|
||||
aria-label="Tiere suchen"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<select aria-label="Tierart" value={speciesId} onChange={(e) => setSpeciesId(e.target.value)} className={selectClass}>
|
||||
<option value="">Alle Tierarten</option>
|
||||
{species.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select aria-label="Filter" value={filter} onChange={(e) => setFilter(e.target.value as Filter)} className={selectClass}>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select aria-label="Sortierung" value={sort} onChange={(e) => setSort(e.target.value as Sort)} className={selectClass}>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : pets.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">Keine Tiere gefunden.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Tier</th>
|
||||
<th className="px-3 py-2">Tierart</th>
|
||||
<th className="px-3 py-2 text-right">Beiträge</th>
|
||||
<th className="px-3 py-2 text-right">Follower</th>
|
||||
<th className="px-3 py-2">Angelegt</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pets.map((pet) => {
|
||||
const ban = pet.owner.ban;
|
||||
const isOwnerBanned = !!ban && (!ban.expiresAt || new Date(ban.expiresAt) > new Date());
|
||||
return (
|
||||
<tr
|
||||
key={pet.id}
|
||||
className="cursor-pointer border-t hover:bg-muted/40 focus-within:bg-muted/40"
|
||||
onClick={() => setOpenPetId(pet.id)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-left font-medium focus-visible:outline-2 focus-visible:outline-orange-500"
|
||||
onClick={() => setOpenPetId(pet.id)}
|
||||
>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
|
||||
<span>
|
||||
{pet.name}
|
||||
{pet.nickname && <span className="text-muted-foreground"> ({pet.nickname})</span>}
|
||||
</span>
|
||||
{pet.isVerified && <BadgeCheck className="h-3.5 w-3.5 fill-blue-500 text-background" aria-label="Verifiziert" />}
|
||||
{pet.isFoundingMember && <Sprout className="h-3.5 w-3.5 text-emerald-600" aria-label="Gründungsmitglied" />}
|
||||
<ReferralRankBadge owner={pet.owner} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{pet.species.name}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{pet._count.posts}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{pet._count.followers}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground tabular-nums">{new Date(pet.createdAt).toLocaleDateString("de-DE")}</td>
|
||||
<td className="px-3 py-2">{isOwnerBanned && <Badge variant="destructive">Owner gesperrt</Badge>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<Button variant="outline" onClick={() => void fetchNextPage()} disabled={isFetchingNextPage}>
|
||||
{isFetchingNextPage ? "Wird geladen…" : "Mehr laden"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<PetDetailSheet petId={openPetId} onClose={() => setOpenPetId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Sheet open={!!petId} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-xl">
|
||||
{isLoading || !pet ? (
|
||||
<Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="space-y-6 p-4">
|
||||
<SheetHeader className="p-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate">
|
||||
{pet.name}
|
||||
{pet.nickname && <span className="font-normal text-muted-foreground"> ({pet.nickname})</span>}
|
||||
</SheetTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pet.species.name}
|
||||
{pet.breed && ` · ${pet.breed.name}`} · seit {new Date(pet.createdAt).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/pets/${pet.id}`} target="_blank" className="ml-auto text-muted-foreground hover:text-foreground" aria-label="Öffentliches Profil öffnen">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<dl className="grid grid-cols-4 gap-2 text-center text-sm">
|
||||
{[
|
||||
["Beiträge", pet._count.posts],
|
||||
["Follower", pet._count.followers],
|
||||
["Folgt", pet._count.following],
|
||||
["Storys", pet._count.stories],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg border p-2">
|
||||
<dd className="font-mono text-lg font-semibold tabular-nums">{value}</dd>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<section className="space-y-1 text-sm">
|
||||
<h3 className="font-semibold">Owner</h3>
|
||||
<p className="font-mono text-xs break-all text-muted-foreground">{pet.owner.id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Empfehlungscode {pet.owner.referralCode ?? "–"} · {pet.owner._count.pets} Tier(e)
|
||||
</p>
|
||||
{pet.owner.ban && <Badge variant="destructive">Gesperrt: {pet.owner.ban.reason ?? "ohne Grund"}</Badge>}
|
||||
{pet.owner.shadowban && <Badge variant="secondary">Shadowban</Badge>}
|
||||
</section>
|
||||
|
||||
{(pet.bio || pet.adoptionStory) && (
|
||||
<section className="space-y-1 text-sm">
|
||||
<h3 className="font-semibold">Profiltexte</h3>
|
||||
{pet.bio && <p className="whitespace-pre-wrap">{pet.bio}</p>}
|
||||
{pet.adoptionStory && <p className="whitespace-pre-wrap text-muted-foreground">{pet.adoptionStory}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pet.posts.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="font-semibold">Letzte Beiträge</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{pet.posts.map((post) => {
|
||||
const key = post.images[0]?.storageKey;
|
||||
return (
|
||||
<div key={post.id} className="relative aspect-square overflow-hidden rounded-md bg-muted text-xs">
|
||||
{key ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- Admin-Vorschau, keine Optimierung nötig
|
||||
<img src={getMediaUrl(key) ?? ""} alt={post.caption ?? "Beitrag"} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center p-1 text-center text-muted-foreground">{post.type}</span>
|
||||
)}
|
||||
{post.hiddenAt && <span className="absolute left-1 top-1 rounded bg-black/70 px-1 text-white">versteckt</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="font-semibold">Abzeichen dieses Tiers</h3>
|
||||
<label className="flex items-center justify-between rounded-lg border p-3 text-sm">
|
||||
Verifiziert
|
||||
<Switch checked={pet.isVerified} disabled={setVerified.isPending} onCheckedChange={(v) => setVerified.mutate({ petId: pet.id, isVerified: v })} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-lg border p-3 text-sm">
|
||||
Gründungsmitglied
|
||||
<Switch checked={pet.isFoundingMember} disabled={setFounding.isPending} onCheckedChange={(v) => setFounding.mutate({ petId: pet.id, value: v })} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<PetRankSection
|
||||
ownerId={pet.owner.id}
|
||||
ownerPetCount={pet.owner._count.pets}
|
||||
qualifiedCount={pet.owner.qualifiedReferralCount}
|
||||
ranks={data.ranks}
|
||||
achievements={pet.owner.rankAchievements}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
|
||||
<PetModerationSection
|
||||
petId={pet.id}
|
||||
petName={pet.name}
|
||||
onChanged={refresh}
|
||||
onDeleted={() => {
|
||||
refresh();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -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<Set<FieldKey>>(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 (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-semibold">Moderation</h3>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
placeholder="Begründung (Pflicht) — geht an den Owner, wenn benachrichtigt wird"
|
||||
aria-label="Begründung für die Moderation"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={notifyOwner} onChange={(e) => setNotifyOwner(e.target.checked)} className="accent-orange-500" />
|
||||
Owner mit Begründung benachrichtigen (Direktnachricht vom „PawFeed Team“)
|
||||
</label>
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<p className="text-sm font-medium">Inhalte entfernen, Tier bleibt bestehen</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{FIELDS.map((field) => (
|
||||
<label key={field.key} className="flex items-center gap-1.5 text-sm">
|
||||
<input type="checkbox" checked={fields.has(field.key)} onChange={() => toggle(field.key)} className="accent-orange-500" />
|
||||
{field.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={fields.size === 0 || !trimmed || clear.isPending}
|
||||
onClick={() => clear.mutate({ petId, fields: [...fields], reason: trimmed, notifyOwner })}
|
||||
>
|
||||
{clear.isPending ? "Wird entfernt…" : "Ausgewählte Inhalte entfernen"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-destructive/40 p-3">
|
||||
<p className="text-sm font-medium text-destructive">Tier endgültig löschen</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Löscht Beiträge, Storys, Follows, Pfoten, Kommentare und Nachrichten des Tiers samt Bildern und Videos. Der Owner-Account bleibt.
|
||||
War es sein einziges Tier, geht die Begründung per E-Mail an ihn.
|
||||
</p>
|
||||
<Button size="sm" variant="destructive" disabled={!trimmed || remove.isPending} onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 className="h-4 w-4" /> Tier löschen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={(open) => !remove.isPending && setConfirmDelete(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>„{petName}“ endgültig löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Das lässt sich nicht rückgängig machen. Begründung: {trimmed}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={remove.isPending}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
remove.mutate({ petId, reason: trimmed, notifyOwner });
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Endgültig löschen"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
|
||||
|
||||
type Rank = { id: string; nameDe: string; threshold: number; icon: string; color: string; isActive: boolean };
|
||||
type Achievement = {
|
||||
rankId: string;
|
||||
achievedAt: Date | string;
|
||||
revokedAt: Date | string | null;
|
||||
revokeReason: string | null;
|
||||
grantedManually: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ownerId: string;
|
||||
ownerPetCount: number;
|
||||
qualifiedCount: number;
|
||||
ranks: Rank[];
|
||||
achievements: Achievement[];
|
||||
onChanged: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Empfehlungsränge des Owners — gelten für ALLE seine Tiere. Entziehen ist
|
||||
* dauerhaft (bis „Freigeben“), Vergeben geht auch ohne erreichte Schwelle.
|
||||
*/
|
||||
export function PetRankSection({ ownerId, ownerPetCount, qualifiedCount, ranks, achievements, onChanged }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const [reason, setReason] = useState("");
|
||||
const byRank = new Map(achievements.map((a) => [a.rankId, a]));
|
||||
|
||||
const done = (message: string) => {
|
||||
toast.success(message);
|
||||
setReason("");
|
||||
onChanged();
|
||||
};
|
||||
const revoke = useMutation(trpc.referralAdmin.revokeRank.mutationOptions({ onSuccess: () => done("Rang entzogen"), onError: (e) => toast.error(e.message) }));
|
||||
const grant = useMutation(
|
||||
trpc.referralAdmin.grantRank.mutationOptions({
|
||||
onSuccess: (res) => done(res.restored ? "Rang freigegeben" : "Rang vergeben"),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
);
|
||||
const isBusy = revoke.isPending || grant.isPending;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">Empfehlungsrang</h3>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Gilt für den Owner, also für {ownerPetCount === 1 ? "sein einziges Tier" : `alle ${ownerPetCount} Tiere`}. {qualifiedCount} gezählte
|
||||
Einladungen.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={500}
|
||||
placeholder="Begründung (Pflicht beim Entziehen, optional beim Vergeben)"
|
||||
aria-label="Begründung für die Rang-Änderung"
|
||||
/>
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{ranks.map((rank) => {
|
||||
const achievement = byRank.get(rank.id);
|
||||
const isRevoked = !!achievement?.revokedAt;
|
||||
const isHeld = !!achievement && !isRevoked;
|
||||
const status = isRevoked
|
||||
? `Entzogen${achievement?.revokeReason ? `: ${achievement.revokeReason}` : ""}`
|
||||
: isHeld
|
||||
? achievement?.grantedManually
|
||||
? "Manuell vergeben"
|
||||
: "Erreicht"
|
||||
: `Nicht erreicht (ab ${rank.threshold})`;
|
||||
return (
|
||||
<li key={rank.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-sm">
|
||||
<ReferralIcon icon={rank.icon} className="h-4 w-4" style={{ color: rank.color, fill: rank.color }} aria-hidden="true" />
|
||||
<span className="font-medium">{rank.nameDe}</span>
|
||||
{!rank.isActive && <span className="text-xs text-muted-foreground">(inaktiv)</span>}
|
||||
<span className={`flex-1 text-xs ${isRevoked ? "text-destructive" : "text-muted-foreground"}`}>{status}</span>
|
||||
{isHeld ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBusy || !reason.trim()}
|
||||
title={reason.trim() ? undefined : "Erst eine Begründung eingeben"}
|
||||
onClick={() => revoke.mutate({ ownerId, rankId: rank.id, reason: reason.trim() })}
|
||||
>
|
||||
Entziehen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isRevoked ? "default" : "outline"}
|
||||
disabled={isBusy}
|
||||
onClick={() => grant.mutate({ ownerId, rankId: rank.id, note: reason.trim() || undefined })}
|
||||
>
|
||||
{isRevoked ? "Freigeben" : "Vergeben"}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { deleteFiles } from "@/lib/supabase-storage";
|
||||
import { deletePetCompletely } from "@/lib/delete-pet";
|
||||
|
||||
/**
|
||||
* Permanently deletes an Owner and everything cascading from their pets
|
||||
@@ -12,37 +12,13 @@ import { deleteFiles } from "@/lib/supabase-storage";
|
||||
* 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[] = [];
|
||||
// Jedes Tier vollständig: Dateien, Mux-Assets, Story-Aufrufe, fremde Paw-Backs,
|
||||
// Zählerkorrektur an fremden Beiträgen — siehe deletePetCompletely().
|
||||
const pets = await prisma.pet.findMany({ where: { ownerId }, select: { id: true } });
|
||||
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) {
|
||||
if (story.storageKey) storageKeys.push(story.storageKey);
|
||||
}
|
||||
await deletePetCompletely(prisma, pet.id);
|
||||
}
|
||||
|
||||
// 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 } });
|
||||
|
||||
// Empfehlungsprogramm: die Clerk-ID der gelöschten Person darf nicht bei
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import "server-only";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { deleteFiles } from "@/lib/supabase-storage";
|
||||
import { mux } from "@/lib/mux";
|
||||
|
||||
/**
|
||||
* Löscht ein Tier vollständig — genutzt von pets.delete (Owner), der
|
||||
* Kontolöschung (delete-account.ts) und dem Admin (pet-admin.ts).
|
||||
*
|
||||
* Ein nacktes prisma.pet.delete reicht nicht:
|
||||
* - StoryView hat bewusst kein onDelete: Cascade (D-08), weder zur Story noch
|
||||
* zum zuschauenden Tier → ohne vorheriges Löschen scheitert der Pet-Delete
|
||||
* am Fremdschlüssel, sobald das Tier Storys mit Aufrufen hat oder selbst
|
||||
* fremde Storys angesehen hat.
|
||||
* - Repost.originalPost hat kein Cascade → Beiträge des Tiers, die andere
|
||||
* geteilt haben, blockieren; die Paw-Back-Beiträge der anderen müssen weg.
|
||||
* - Die denormalisierten Zähler an FREMDEN Inhalten (Post.reactionCount/
|
||||
* commentCount/repostCount, Story.reactionCount/commentCount) müssen um die
|
||||
* Pfoten, Kommentare und Paw-Backs dieses Tiers sinken (CLAUDE.md:
|
||||
* "Denormalized engagement counters" — keine Hintergrund-Korrektur).
|
||||
* Danach räumt der Cascade den Rest ab; Dateien in Supabase Storage und
|
||||
* Mux-Assets werden best-effort gelöscht.
|
||||
*/
|
||||
export async function deletePetCompletely(prisma: PrismaClient, petId: string): Promise<void> {
|
||||
const pet = await prisma.pet.findUnique({
|
||||
where: { id: petId },
|
||||
select: {
|
||||
avatarKey: true,
|
||||
posts: { select: { images: { select: { storageKey: true } }, videoPost: { select: { muxAssetId: true } } } },
|
||||
stories: { select: { storageKey: true, muxAssetId: true } },
|
||||
},
|
||||
});
|
||||
if (!pet) return;
|
||||
|
||||
const storageKeys = [
|
||||
pet.avatarKey,
|
||||
...pet.posts.flatMap((post) => post.images.map((image) => image.storageKey)),
|
||||
...pet.stories.map((story) => story.storageKey),
|
||||
].filter((key): key is string => !!key);
|
||||
const muxAssetIds = [
|
||||
...pet.posts.map((post) => post.videoPost?.muxAssetId),
|
||||
...pet.stories.map((story) => story.muxAssetId),
|
||||
].filter((id): id is string => !!id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Zähler an fremden Beiträgen/Storys um die Interaktionen dieses Tiers verringern.
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Post" p SET "reactionCount" = GREATEST(0, p."reactionCount" - x.n)
|
||||
FROM (SELECT r."postId", count(*)::int AS n FROM "Reaction" r
|
||||
JOIN "Post" own ON own."id" = r."postId" AND own."petId" <> ${petId}
|
||||
WHERE r."petId" = ${petId} GROUP BY r."postId") x
|
||||
WHERE p."id" = x."postId"`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Post" p SET "commentCount" = GREATEST(0, p."commentCount" - x.n)
|
||||
FROM (SELECT c."postId", count(*)::int AS n FROM "Comment" c
|
||||
JOIN "Post" own ON own."id" = c."postId" AND own."petId" <> ${petId}
|
||||
WHERE c."petId" = ${petId} GROUP BY c."postId") x
|
||||
WHERE p."id" = x."postId"`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Post" p SET "repostCount" = GREATEST(0, p."repostCount" - 1)
|
||||
FROM "Repost" rp
|
||||
WHERE rp."reposterPetId" = ${petId} AND p."id" = rp."originalPostId" AND p."petId" <> ${petId}`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Story" s SET "reactionCount" = GREATEST(0, s."reactionCount" - x.n)
|
||||
FROM (SELECT sr."storyId", count(*)::int AS n FROM "StoryReaction" sr
|
||||
JOIN "Story" own ON own."id" = sr."storyId" AND own."petId" <> ${petId}
|
||||
WHERE sr."petId" = ${petId} GROUP BY sr."storyId") x
|
||||
WHERE s."id" = x."storyId"`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Story" s SET "commentCount" = GREATEST(0, s."commentCount" - x.n)
|
||||
FROM (SELECT sc."storyId", count(*)::int AS n FROM "StoryComment" sc
|
||||
JOIN "Story" own ON own."id" = sc."storyId" AND own."petId" <> ${petId}
|
||||
WHERE sc."petId" = ${petId} GROUP BY sc."storyId") x
|
||||
WHERE s."id" = x."storyId"`;
|
||||
|
||||
// Paw-Backs anderer Tiere auf Beiträge dieses Tiers (ihr Repost-Eintrag hängt per Cascade am Post).
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "Post" WHERE "id" IN (
|
||||
SELECT rp."postId" FROM "Repost" rp
|
||||
JOIN "Post" orig ON orig."id" = rp."originalPostId"
|
||||
WHERE orig."petId" = ${petId} AND rp."reposterPetId" <> ${petId})`;
|
||||
|
||||
// Story-Aufrufe in beide Richtungen (kein Cascade, D-08).
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "StoryView"
|
||||
WHERE "viewerPetId" = ${petId} OR "storyId" IN (SELECT "id" FROM "Story" WHERE "petId" = ${petId})`;
|
||||
|
||||
// Meldungen: vom Tier abgegebene (Report.reporter ohne Cascade) und solche über das Tier
|
||||
// oder seine Beiträge (targetPetId/targetPostId sind keine Fremdschlüssel, würden ins Leere zeigen).
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "Report"
|
||||
WHERE "reporterPetId" = ${petId}
|
||||
OR "targetPetId" = ${petId}
|
||||
OR "targetPostId" IN (SELECT "id" FROM "Post" WHERE "petId" = ${petId})`;
|
||||
|
||||
// Unterhaltungen ausdrücklich vorher löschen (Nachrichten hängen per Cascade daran):
|
||||
// Message.senderPet hat kein Cascade und könnte den Pet-Delete sonst blockieren.
|
||||
await tx.$executeRaw`DELETE FROM "Conversation" WHERE "petAId" = ${petId} OR "petBId" = ${petId}`;
|
||||
|
||||
await tx.pet.delete({ where: { id: petId } });
|
||||
});
|
||||
|
||||
if (storageKeys.length > 0) {
|
||||
await deleteFiles(storageKeys).catch((err) => console.error("[delete-pet] storage cleanup failed", err));
|
||||
}
|
||||
for (const assetId of muxAssetIds) {
|
||||
await mux.video.assets.delete(assetId).catch((err) => console.error("[delete-pet] mux cleanup failed", assetId, err));
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,9 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) {
|
||||
rank: a.rank.nameDe,
|
||||
threshold: a.rank.threshold,
|
||||
reachedAt: a.achievedAt,
|
||||
grantedManually: a.grantedManually,
|
||||
revokedAt: a.revokedAt,
|
||||
revokeReason: a.revokeReason,
|
||||
})),
|
||||
currentPerks: owner.referralPerks,
|
||||
legacyInviteCodes: owner.issuedInvites,
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Prisma } from "@prisma/client";
|
||||
* Nach jeder Rang-Änderung im Admin: fehlende Achievements für alle Owner
|
||||
* anlegen (Bestandsschutz: nichts wird entfernt) und beide Caches neu setzen.
|
||||
* Mengenbasiert in SQL, damit es auch bei 100.000 Ownern eine Abfrage bleibt.
|
||||
* Vom Admin entzogene Ränge (revokedAt) bleiben als Zeile stehen — ON CONFLICT
|
||||
* DO NOTHING vergibt sie daher nicht neu, und der Cache ignoriert sie.
|
||||
* Verschickt bewusst keine Benachrichtigungen (keine Push-Welle).
|
||||
*/
|
||||
export async function recomputeReferralRanks(tx: Pick<Prisma.TransactionClient, "$executeRaw">): Promise<void> {
|
||||
@@ -28,6 +30,7 @@ export async function recomputeReferralRanks(tx: Pick<Prisma.TransactionClient,
|
||||
FROM "ReferralRankAchievement" a
|
||||
JOIN "ReferralRank" r ON r."id" = a."rankId" AND r."isActive"
|
||||
LEFT JOIN LATERAL unnest(r."perks") AS p ON true
|
||||
WHERE a."revokedAt" IS NULL
|
||||
GROUP BY a."ownerId"
|
||||
) x
|
||||
WHERE o."id" = x."ownerId"`;
|
||||
@@ -40,6 +43,6 @@ export async function recomputeReferralRanks(tx: Pick<Prisma.TransactionClient,
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ReferralRankAchievement" a
|
||||
JOIN "ReferralRank" r ON r."id" = a."rankId" AND r."isActive"
|
||||
WHERE a."ownerId" = o."id"
|
||||
WHERE a."ownerId" = o."id" AND a."revokedAt" IS NULL
|
||||
)`;
|
||||
}
|
||||
+12
-7
@@ -270,23 +270,28 @@ export async function creditInviter(tx: Prisma.TransactionClient, inviterId: str
|
||||
/**
|
||||
* Vergibt fehlende Ränge für einen Owner und aktualisiert den Cache
|
||||
* (referralRankId, referralPerks). Entzieht nie etwas (Bestandsschutz).
|
||||
* Gibt die neu vergebenen Ränge zurück.
|
||||
* Vom Admin entzogene Ränge (revokedAt gesetzt) werden weder neu vergeben
|
||||
* noch im Cache berücksichtigt. Gibt die neu vergebenen Ränge zurück.
|
||||
*/
|
||||
export async function awardRanks(tx: Prisma.TransactionClient, ownerId: string, count: number) {
|
||||
const ranks = await tx.referralRank.findMany({ orderBy: { threshold: "asc" } });
|
||||
const existing = await tx.referralRankAchievement.findMany({ where: { ownerId }, select: { rankId: true } });
|
||||
const achieved = new Set(existing.map((a) => a.rankId));
|
||||
const existing = await tx.referralRankAchievement.findMany({
|
||||
where: { ownerId },
|
||||
select: { rankId: true, revokedAt: true },
|
||||
});
|
||||
const known = new Set(existing.map((a) => a.rankId)); // inkl. entzogener — nicht neu vergeben
|
||||
const active = new Set(existing.filter((a) => !a.revokedAt).map((a) => a.rankId));
|
||||
|
||||
const newRanks = ranksToAward(ranks, count, achieved);
|
||||
const newRanks = ranksToAward(ranks, count, known);
|
||||
if (newRanks.length > 0) {
|
||||
await tx.referralRankAchievement.createMany({
|
||||
data: newRanks.map((rank) => ({ ownerId, rankId: rank.id })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
newRanks.forEach((rank) => achieved.add(rank.id));
|
||||
newRanks.forEach((rank) => active.add(rank.id));
|
||||
}
|
||||
|
||||
await refreshRankCache(tx, ownerId, ranks, achieved);
|
||||
await refreshRankCache(tx, ownerId, ranks, active);
|
||||
return newRanks;
|
||||
}
|
||||
|
||||
@@ -309,7 +314,7 @@ export async function refreshRankCache(
|
||||
export async function rebuildOwnerRankCache(tx: Prisma.TransactionClient, ownerId: string) {
|
||||
const [ranks, achievements] = await Promise.all([
|
||||
tx.referralRank.findMany({ orderBy: { threshold: "asc" } }),
|
||||
tx.referralRankAchievement.findMany({ where: { ownerId }, select: { rankId: true } }),
|
||||
tx.referralRankAchievement.findMany({ where: { ownerId, revokedAt: null }, select: { rankId: true } }),
|
||||
]);
|
||||
await refreshRankCache(tx, ownerId, ranks, new Set(achievements.map((a) => a.rankId)));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { insightsRouter } from "@/trpc/routers/insights";
|
||||
import { referralsRouter } from "@/trpc/routers/referrals";
|
||||
import { referralAdminRouter } from "@/trpc/routers/referral-admin";
|
||||
import { curatorAdminRouter } from "@/trpc/routers/curator-admin";
|
||||
import { petAdminRouter } from "@/trpc/routers/pet-admin";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -60,6 +61,7 @@ export const appRouter = router({
|
||||
referrals: referralsRouter,
|
||||
referralAdmin: referralAdminRouter,
|
||||
curatorAdmin: curatorAdminRouter,
|
||||
petAdmin: petAdminRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AdminRoleType, type Prisma } from "@prisma/client";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { assertAdmin } from "@/trpc/routers/admin";
|
||||
import { deletePetCompletely } from "@/lib/delete-pet";
|
||||
import { deleteFiles } from "@/lib/supabase-storage";
|
||||
import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { buildGreeting, renderBrandedMailHtml } from "@/lib/mail-template";
|
||||
|
||||
/**
|
||||
* Admin-Seite „Tiere“ (/p/[secret]/pets): Liste aller Tiere mit Suche/Filter,
|
||||
* Detailansicht pro Tier und Moderationsaktionen. Lesen und Inhalte leeren ab
|
||||
* MODERATOR; Gründungsmitglied ändern und Tier löschen nur SUPER_ADMIN.
|
||||
* Ränge hängen am Owner — die Rang-Aktionen liegen in referral-admin.ts.
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const RECENT_POSTS = 6;
|
||||
|
||||
const CLEARABLE_FIELDS = ["avatar", "bio", "adoptionStory", "nickname"] as const;
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch);
|
||||
}
|
||||
|
||||
async function log(
|
||||
prisma: Prisma.TransactionClient | Parameters<typeof deletePetCompletely>[0],
|
||||
data: { action: string; targetId: string; reason: string | null; moderatorId: string; moderatorRole: string; ipAddress: string | null }
|
||||
) {
|
||||
await prisma.moderationLog.create({ data: { ...data, targetType: "PET" } });
|
||||
}
|
||||
|
||||
export const petAdminRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
search: z.string().trim().max(60).optional(),
|
||||
speciesId: z.string().optional(),
|
||||
filter: z.enum(["all", "verified", "founding", "ranked", "bannedOwner"]).default("all"),
|
||||
sort: z.enum(["newest", "followers", "posts"]).default("newest"),
|
||||
cursor: z.string().nullish(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const now = new Date();
|
||||
const search = input.search;
|
||||
const where: Prisma.PetWhereInput = {
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ nickname: { contains: search, mode: "insensitive" } },
|
||||
{ owner: { referralCode: search.toUpperCase() } },
|
||||
{ id: search },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(input.speciesId ? { speciesId: input.speciesId } : {}),
|
||||
...(input.filter === "verified" ? { isVerified: true } : {}),
|
||||
...(input.filter === "founding" ? { isFoundingMember: true } : {}),
|
||||
...(input.filter === "ranked" ? { owner: { referralRankId: { not: null } } } : {}),
|
||||
...(input.filter === "bannedOwner"
|
||||
? { owner: { ban: { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] } } }
|
||||
: {}),
|
||||
};
|
||||
const orderBy: Prisma.PetOrderByWithRelationInput =
|
||||
input.sort === "followers"
|
||||
? { followers: { _count: "desc" } }
|
||||
: input.sort === "posts"
|
||||
? { posts: { _count: "desc" } }
|
||||
: { createdAt: "desc" };
|
||||
|
||||
const pets = await ctx.prisma.pet.findMany({
|
||||
where,
|
||||
orderBy: [orderBy, { id: "asc" }],
|
||||
take: PAGE_SIZE + 1,
|
||||
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nickname: true,
|
||||
avatarKey: true,
|
||||
isVerified: true,
|
||||
isFoundingMember: true,
|
||||
createdAt: true,
|
||||
ownerId: true,
|
||||
species: { select: { name: true } },
|
||||
owner: {
|
||||
select: {
|
||||
referralRankId: true,
|
||||
referralPerks: true,
|
||||
sponsorSince: true,
|
||||
sponsorPeriodEnd: true,
|
||||
ban: { select: { expiresAt: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { posts: true, followers: true } },
|
||||
},
|
||||
});
|
||||
const hasNext = pets.length > PAGE_SIZE;
|
||||
const items = hasNext ? pets.slice(0, PAGE_SIZE) : pets;
|
||||
return { pets: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
|
||||
}),
|
||||
|
||||
detail: protectedProcedure.input(z.object({ petId: z.string().min(1) })).query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const pet = await ctx.prisma.pet.findUnique({
|
||||
where: { id: input.petId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nickname: true,
|
||||
bio: true,
|
||||
adoptionStory: true,
|
||||
avatarKey: true,
|
||||
isVerified: true,
|
||||
isFoundingMember: true,
|
||||
createdAt: true,
|
||||
species: { select: { name: true } },
|
||||
breed: { select: { name: true } },
|
||||
_count: { select: { posts: true, followers: true, following: true, stories: true } },
|
||||
posts: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: RECENT_POSTS,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
caption: true,
|
||||
createdAt: true,
|
||||
hiddenAt: true,
|
||||
images: { take: 1, orderBy: { position: "asc" }, select: { storageKey: true } },
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
referralCode: true,
|
||||
qualifiedReferralCount: true,
|
||||
referralRankId: true,
|
||||
ban: { select: { reason: true, expiresAt: true } },
|
||||
shadowban: { select: { expiresAt: true } },
|
||||
_count: { select: { pets: true } },
|
||||
rankAchievements: {
|
||||
orderBy: { achievedAt: "asc" },
|
||||
select: {
|
||||
rankId: true,
|
||||
achievedAt: true,
|
||||
revokedAt: true,
|
||||
revokeReason: true,
|
||||
grantedManually: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const ranks = await ctx.prisma.referralRank.findMany({
|
||||
orderBy: { threshold: "asc" },
|
||||
select: { id: true, nameDe: true, threshold: true, icon: true, color: true, isActive: true },
|
||||
});
|
||||
return { pet, ranks };
|
||||
}),
|
||||
|
||||
setFoundingMember: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1), value: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
|
||||
await ctx.prisma.pet.update({ where: { id: input.petId }, data: { isFoundingMember: input.value } });
|
||||
await log(ctx.prisma, {
|
||||
action: "SET_FOUNDING_MEMBER",
|
||||
targetId: input.petId,
|
||||
reason: String(input.value),
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Anstößige Inhalte entfernen, ohne das Tier zu löschen. */
|
||||
clearContent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
petId: z.string().min(1),
|
||||
fields: z.array(z.enum(CLEARABLE_FIELDS)).min(1),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
notifyOwner: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const pet = await ctx.prisma.pet.findUnique({ where: { id: input.petId }, select: { avatarKey: true } });
|
||||
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
const fields = new Set(input.fields);
|
||||
await ctx.prisma.pet.update({
|
||||
where: { id: input.petId },
|
||||
data: {
|
||||
...(fields.has("avatar") ? { avatarKey: null } : {}),
|
||||
...(fields.has("bio") ? { bio: null } : {}),
|
||||
...(fields.has("adoptionStory") ? { adoptionStory: null } : {}),
|
||||
...(fields.has("nickname") ? { nickname: null } : {}),
|
||||
},
|
||||
});
|
||||
if (fields.has("avatar") && pet.avatarKey) {
|
||||
await deleteFiles([pet.avatarKey]).catch((err) => console.error("[pet-admin] avatar delete failed", err));
|
||||
}
|
||||
await log(ctx.prisma, {
|
||||
action: "CLEAR_PET_CONTENT",
|
||||
targetId: input.petId,
|
||||
reason: `${input.fields.join(", ")}: ${input.reason}`,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
if (input.notifyOwner) {
|
||||
await sendTeamMessage(
|
||||
ctx.prisma,
|
||||
input.petId,
|
||||
`Hallo, wir haben Inhalte aus deinem Tierprofil entfernt, weil sie gegen unsere Community-Richtlinien verstoßen. Begründung: ${input.reason}\n\nBei Fragen antworte einfach auf diese Nachricht. Dein PawFeed-Team`
|
||||
).catch((err) => console.error("[pet-admin] notify failed", err));
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Tier endgültig löschen. Mit notifyOwner bekommt der Owner die Begründung:
|
||||
* als Direktnachricht an ein anderes seiner Tiere — oder, wenn es sein
|
||||
* einziges war, per E-Mail an die Clerk-Adresse (DMs sind immer tierbezogen).
|
||||
*/
|
||||
delete: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
petId: z.string().min(1),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
notifyOwner: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }): Promise<{ notified: "dm" | "email" | "none" }> => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
|
||||
const pet = await ctx.prisma.pet.findUnique({ where: { id: input.petId }, select: { name: true, ownerId: true } });
|
||||
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const teamPet = await ensureTeamPet(ctx.prisma).catch(() => null);
|
||||
if (teamPet?.id === input.petId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Das Kurator-Tier „PawFeed Team“ kann nicht gelöscht werden." });
|
||||
}
|
||||
|
||||
await deletePetCompletely(ctx.prisma, input.petId);
|
||||
await log(ctx.prisma, {
|
||||
action: "DELETE_PET",
|
||||
targetId: input.petId,
|
||||
reason: `${pet.name}: ${input.reason}`,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
|
||||
if (!input.notifyOwner) return { notified: "none" };
|
||||
const text = `wir haben das Tierprofil „${pet.name}“ gelöscht. Begründung: ${input.reason}`;
|
||||
|
||||
const otherPet = await ctx.prisma.pet.findFirst({
|
||||
where: { ownerId: pet.ownerId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (otherPet) {
|
||||
await sendTeamMessage(ctx.prisma, otherPet.id, `Hallo, ${text}\n\nBei Fragen antworte einfach auf diese Nachricht. Dein PawFeed-Team`)
|
||||
.catch((err) => console.error("[pet-admin] notify dm failed", err));
|
||||
return { notified: "dm" };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await (await clerkClient()).users.getUser(pet.ownerId);
|
||||
const email = user.emailAddresses.find((e) => e.id === user.primaryEmailAddressId)?.emailAddress;
|
||||
if (!email) return { notified: "none" };
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: `Dein Tierprofil „${pet.name}“ wurde gelöscht`,
|
||||
text: `${buildGreeting(user.firstName)}\n\n${text}\n\nBei Fragen antworte einfach auf diese E-Mail.\n\nDein PawFeed-Team`,
|
||||
html: renderBrandedMailHtml({
|
||||
previewText: `Dein Tierprofil „${pet.name}“ wurde gelöscht.`,
|
||||
greetingName: user.firstName,
|
||||
bodyHtml: `<p style="margin: 0 0 16px;">${escapeHtml(text)}</p><p style="margin: 0;">Bei Fragen antworte einfach auf diese E-Mail.</p>`,
|
||||
}),
|
||||
});
|
||||
return { notified: "email" };
|
||||
} catch (err) {
|
||||
console.error("[pet-admin] notify email failed", err);
|
||||
return { notified: "none" };
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
import { petCardSelect } from "@/repositories/pet-repository";
|
||||
import { welcomeNewPet } from "@/lib/new-pet-welcome";
|
||||
import { deletePetCompletely } from "@/lib/delete-pet";
|
||||
|
||||
export const petsRouter = router({
|
||||
/**
|
||||
@@ -213,7 +214,9 @@ export const petsRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
await ctx.prisma.pet.delete({ where: { id: input.petId } });
|
||||
// Vollständig inkl. Story-Aufrufe, fremder Paw-Backs, Zählerkorrektur und Dateien —
|
||||
// ein nacktes pet.delete scheitert an den Fremdschlüsseln ohne Cascade (siehe delete-pet.ts).
|
||||
await deletePetCompletely(ctx.prisma, input.petId);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AdminRoleType, Prisma } from "@prisma/client";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { assertAdmin } from "@/trpc/routers/admin";
|
||||
import { creditInviter, rebuildOwnerRankCache, recomputeReferralRanks } from "@/lib/referrals";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { MAX_REFERRAL_RANKS, REFERRAL_COLOR_PATTERN, REFERRAL_ICON_KEYS, REFERRAL_PERKS } from "@/lib/referral-ranks";
|
||||
import { getAppRedirectStats } from "@/lib/app-redirect-stats";
|
||||
|
||||
@@ -250,12 +251,21 @@ export const referralAdminRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Rang dauerhaft entziehen: die Zeile bleibt mit revokedAt stehen, damit
|
||||
* awardRanks/recomputeReferralRanks den Rang nicht neu vergeben, bis ein
|
||||
* Admin ihn mit restoreRank freigibt. Gilt für alle Tiere des Owners.
|
||||
*/
|
||||
revokeRank: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1), rankId: z.string().min(1), reason: z.string().trim().min(1).max(500) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.referralRankAchievement.deleteMany({ where: { ownerId: input.ownerId, rankId: input.rankId } });
|
||||
await tx.referralRankAchievement.upsert({
|
||||
where: { ownerId_rankId: { ownerId: input.ownerId, rankId: input.rankId } },
|
||||
create: { ownerId: input.ownerId, rankId: input.rankId, revokedAt: new Date(), revokedBy: ctx.userId, revokeReason: input.reason },
|
||||
update: { revokedAt: new Date(), revokedBy: ctx.userId, revokeReason: input.reason },
|
||||
});
|
||||
await rebuildOwnerRankCache(tx, input.ownerId);
|
||||
await tx.moderationLog.create({
|
||||
data: { action: "REVOKE_REFERRAL_RANK", targetType: "USER", targetId: input.ownerId, reason: `${input.rankId}: ${input.reason}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
|
||||
@@ -263,4 +273,44 @@ export const referralAdminRouter = router({
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Entzogenen Rang wieder freigeben — oder einen Rang manuell vergeben
|
||||
* (besondere Leistung), auch wenn der Zähler dafür nicht reicht.
|
||||
*/
|
||||
grantRank: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1), rankId: z.string().min(1), note: z.string().trim().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
|
||||
const wasRevoked = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.referralRankAchievement.findUnique({
|
||||
where: { ownerId_rankId: { ownerId: input.ownerId, rankId: input.rankId } },
|
||||
select: { revokedAt: true },
|
||||
});
|
||||
await tx.referralRankAchievement.upsert({
|
||||
where: { ownerId_rankId: { ownerId: input.ownerId, rankId: input.rankId } },
|
||||
create: { ownerId: input.ownerId, rankId: input.rankId, grantedManually: true, grantedBy: ctx.userId },
|
||||
update: { revokedAt: null, revokedBy: null, revokeReason: null },
|
||||
});
|
||||
await rebuildOwnerRankCache(tx, input.ownerId);
|
||||
await tx.moderationLog.create({
|
||||
data: {
|
||||
action: existing?.revokedAt ? "RESTORE_REFERRAL_RANK" : "GRANT_REFERRAL_RANK",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
reason: `${input.rankId}${input.note ? `: ${input.note}` : ""}`,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
});
|
||||
return !!existing?.revokedAt;
|
||||
});
|
||||
// Neue Vergabe (nicht bloße Freigabe) wie ein normaler Aufstieg melden.
|
||||
if (!wasRevoked) {
|
||||
const pet = await ctx.prisma.pet.findFirst({ where: { ownerId: input.ownerId }, orderBy: { createdAt: "asc" }, select: { id: true } });
|
||||
if (pet) notify(ctx.prisma, { recipientPetId: pet.id, type: "REFERRAL_RANK_UP" });
|
||||
}
|
||||
return { success: true, restored: wasRevoked };
|
||||
}),
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const referralsRouter = router({
|
||||
ctx.prisma.referralRank.findMany({ orderBy: { threshold: "asc" } }),
|
||||
ctx.prisma.referralRankAchievement.findMany({
|
||||
where: { ownerId: ctx.userId },
|
||||
select: { rankId: true, achievedAt: true },
|
||||
select: { rankId: true, achievedAt: true, revokedAt: true },
|
||||
}),
|
||||
ctx.prisma.owner.findMany({
|
||||
where: { invitedById: ctx.userId },
|
||||
@@ -85,8 +85,9 @@ export const referralsRouter = router({
|
||||
}),
|
||||
]);
|
||||
|
||||
const achievedIds = new Set(achievements.map((a) => a.rankId));
|
||||
const next = pickNextRank(ranks, achievedIds);
|
||||
// Entzogene Ränge gelten nicht als erreicht, werden aber auch nicht als nächstes Ziel angezeigt.
|
||||
const achievedIds = new Set(achievements.filter((a) => !a.revokedAt).map((a) => a.rankId));
|
||||
const next = pickNextRank(ranks, new Set(achievements.map((a) => a.rankId)));
|
||||
const redeemDeadline = owner
|
||||
? new Date(owner.createdAt.getTime() + settings.redeemWindowDays * DAY_MS)
|
||||
: null;
|
||||
|
||||
Reference in new issue
Block a user