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:
adminandClaude Opus 5.5 committed 2026-10-02 21:03:05 +02:00
1 parent 7a064d8a36
commit 566a2eedd0
20 files changed
+1337 -55

No files matched your search

+15 -7
View File
@@ -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)
+74
View File
@@ -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();
});
});
+4
View File
@@ -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([]),
},
+173
View File
@@ -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("&lt;b&gt;Spam&lt;/b&gt;");
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"] },
});
});
});
+6 -6
View File
@@ -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();
});
});
+2
View File
@@ -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 },
+163
View File
@@ -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>
);
}
+5 -29
View File
@@ -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
+109
View File
@@ -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));
}
}
+3
View File
@@ -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,
+4 -1
View File
@@ -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
View File
@@ -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)));
}
+2
View File
@@ -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
+299
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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" };
}
}),
});
+4 -1
View File
@@ -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 };
}),
+51 -1
View File
@@ -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 };
}),
});
+4 -3
View File
@@ -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;