diff --git a/src/__tests__/admin-report-feedback.test.ts b/src/__tests__/admin-report-feedback.test.ts index beff45e..4f9c899 100644 --- a/src/__tests__/admin-report-feedback.test.ts +++ b/src/__tests__/admin-report-feedback.test.ts @@ -119,7 +119,7 @@ describe("admin.deleteReportedPost reporter feedback", () => { await caller.admin.deleteReportedPost({ postId: "post-1", reason: "Gewaltdarstellung" }); - expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } }); + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: { in: ["post-1"] } } }); expect(mockSendTeamMessage.mock.calls.map((c) => c[1])).toEqual(["reporter-1", "reporter-2"]); const body = mockSendTeamMessage.mock.calls[0][2] as string; expect(body).toContain("Meldung zum Beitrag von Bubi"); diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 9df0071..dda85ef 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { TRPCError } from "@trpc/server"; +import { Prisma } from "@prisma/client"; vi.mock("server-only", () => ({})); const mockGetUserList = vi.fn(); @@ -221,6 +222,160 @@ describe("admin transactional moderation actions", () => { }); }); +// Admin-Review 2026-10-06, Paket 1. +describe("admin: Beitrag mit Paw-Backs löschen", () => { + it("deletePost löscht zuerst die Paw-Backs anderer Tiere und deren Meldungen", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); + mockPrisma.repost.findMany.mockResolvedValue([{ postId: "pawback-1" }, { postId: "pawback-2" }]); + + await caller.admin.deletePost({ postId: "post-1" }); + + expect(mockPrisma.repost.findMany).toHaveBeenCalledWith({ + where: { originalPostId: "post-1" }, + select: { postId: true }, + }); + expect(mockPrisma.post.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ["pawback-1", "pawback-2"] } }, + }); + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ + where: { targetPostId: { in: ["post-1", "pawback-1", "pawback-2"] } }, + }); + expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" } }); + }); + + it("deleteReportedPost löscht die Paw-Backs ebenfalls mit", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bello" } }); + mockPrisma.report.findMany.mockResolvedValue([]); + mockPrisma.repost.findMany.mockResolvedValue([{ postId: "pawback-1" }]); + + await caller.admin.deleteReportedPost({ postId: "post-1", notifyReporters: false }); + + expect(mockPrisma.post.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ["pawback-1"] } } }); + expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" } }); + }); + + it("ohne Paw-Backs wird kein deleteMany auf Beiträge ausgeführt", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); + + await caller.admin.deletePost({ postId: "post-1" }); + + expect(mockPrisma.post.deleteMany).not.toHaveBeenCalled(); + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ + where: { targetPostId: { in: ["post-1"] } }, + }); + }); +}); + +describe("admin: keine Aktionen gegen gleich- oder höherrangige Konten", () => { + it("ein MODERATOR kann keinen SUPER_ADMIN sperren", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "bootstrap"); + const { caller, mockPrisma } = await createAdminCaller({ userId: "mod-1" }); + mockPrisma.adminRole.findUnique + .mockResolvedValueOnce({ ownerId: "mod-1", role: "MODERATOR" }) + .mockResolvedValueOnce({ ownerId: "super-1", role: "SUPER_ADMIN" }); + + await expect(caller.admin.banUser({ ownerId: "super-1" })).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockPrisma.userBan.upsert).not.toHaveBeenCalled(); + }); + + it("ein MODERATOR kann keinen anderen MODERATOR shadowbannen oder verwarnen", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "bootstrap"); + const { caller, mockPrisma } = await createAdminCaller({ userId: "mod-1" }); + mockPrisma.adminRole.findUnique.mockImplementation(({ where }: { where: { ownerId: string } }) => + Promise.resolve({ ownerId: where.ownerId, role: "MODERATOR" }) + ); + + await expect(caller.admin.shadowbanOwner({ ownerId: "mod-2", reason: "x" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + await expect(caller.admin.issueWarning({ ownerId: "mod-2", reason: "x" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + expect(mockPrisma.shadowban.upsert).not.toHaveBeenCalled(); + expect(mockPrisma.warning.create).not.toHaveBeenCalled(); + }); + + it("das Bootstrap-Konto (ADMIN_OWNER_ID) kann niemand sperren oder löschen", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "bootstrap"); + const { caller, mockPrisma } = await createAdminCaller({ userId: "super-1" }); + mockPrisma.adminRole.findUnique.mockResolvedValue({ ownerId: "super-1", role: "SUPER_ADMIN" }); + + await expect(caller.admin.banUser({ ownerId: "bootstrap" })).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + caller.admin.deleteUserAccount({ ownerId: "bootstrap", confirmation: "bootstrap" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockPrisma.userBan.upsert).not.toHaveBeenCalled(); + }); + + it("ein MODERATOR kann ein normales Konto sperren", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "bootstrap"); + const { caller, mockPrisma } = await createAdminCaller({ userId: "mod-1" }); + mockPrisma.adminRole.findUnique + .mockResolvedValueOnce({ ownerId: "mod-1", role: "MODERATOR" }) + .mockResolvedValueOnce(null); + mockPrisma.userBan.upsert.mockResolvedValue({ ownerId: "owner-2" }); + + await expect(caller.admin.banUser({ ownerId: "owner-2" })).resolves.toEqual({ success: true }); + }); + + it("das Bootstrap-Konto kann einen SUPER_ADMIN sperren", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.adminRole.findUnique.mockResolvedValue({ ownerId: "super-1", role: "SUPER_ADMIN" }); + mockPrisma.userBan.upsert.mockResolvedValue({ ownerId: "super-1" }); + + await expect(caller.admin.banUser({ ownerId: "super-1" })).resolves.toEqual({ success: true }); + }); +}); + +describe("admin: Aufheben ohne passenden Eintrag", () => { + const missing = () => + new Prisma.PrismaClientKnownRequestError("Record to delete does not exist.", { + code: "P2025", + clientVersion: "test", + }); + + it("unbanUser, removeShadowban und revokeRole liefern NOT_FOUND statt eines Serverfehlers", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.userBan.delete.mockRejectedValue(missing()); + mockPrisma.shadowban.delete.mockRejectedValue(missing()); + mockPrisma.adminRole.delete.mockRejectedValue(missing()); + + await expect(caller.admin.unbanUser({ ownerId: "owner-2" })).rejects.toMatchObject({ code: "NOT_FOUND" }); + await expect(caller.admin.removeShadowban({ ownerId: "owner-2" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect(caller.admin.revokeRole({ ownerId: "owner-2" })).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); + +describe("admin.deleteBlacklistEntry", () => { + it("schreibt einen Audit-Log-Eintrag in derselben Transaktion", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.blacklistEntry.delete.mockResolvedValue({ id: "bl-1", type: "WORD", value: "spam" }); + + await caller.admin.deleteBlacklistEntry({ id: "bl-1" }); + + expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Function)); + expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "DELETE_BLACKLIST_ENTRY", + targetType: "SYSTEM", + targetId: "bl-1", + reason: "word: spam", + }), + }); + }); +}); + describe("admin dashboard time-series", () => { it("getPostsOverTime returns a zero-filled 30-bucket series with today's posts counted correctly", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 2e0469a..7741cea 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -72,6 +72,7 @@ interface MockBreedClient { interface MockPostClient { create: MockFn; + deleteMany: MockFn; findMany: MockFn; findUnique: MockFn; findFirst: MockFn; @@ -185,6 +186,7 @@ interface MockCommentClient { interface MockRepostClient { findUnique: MockFn; + findMany: MockFn; create: MockFn; delete: MockFn; } @@ -388,6 +390,7 @@ interface MockPrismaClient { report: MockReportClient; userBan: MockUserBanClient; shadowban: MockShadowbanClient; + warning: { create: MockFn; findMany: MockFn }; blacklistEntry: MockBlacklistEntryClient; // Phase 3 reaction: MockReactionClient; @@ -485,6 +488,7 @@ export function createMockPrisma(): MockPrismaClient { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn(), + deleteMany: vi.fn(), count: vi.fn(), aggregate: vi.fn(), }, @@ -599,6 +603,10 @@ export function createMockPrisma(): MockPrismaClient { delete: vi.fn(), findUnique: vi.fn(), }, + warning: { + create: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + }, blacklistEntry: { findMany: vi.fn().mockResolvedValue([]), upsert: vi.fn(), @@ -620,6 +628,8 @@ export function createMockPrisma(): MockPrismaClient { }, repost: { findUnique: vi.fn(), + // Paw-Backs auf einen Beitrag (delete-post.ts) — Standard: keine. + findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn(), }, diff --git a/src/__tests__/posts.test.ts b/src/__tests__/posts.test.ts index 9cd436b..79624cd 100644 --- a/src/__tests__/posts.test.ts +++ b/src/__tests__/posts.test.ts @@ -224,9 +224,27 @@ describe("posts.delete", () => { where: { id: "post-1" }, }); // Reports have no FK to Post — they are removed in the same transaction instead of staying orphaned. - expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } }); + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: { in: ["post-1"] } } }); expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); }); + + it("löscht Paw-Backs anderer Tiere mit — sonst blockiert Repost.originalPost das Löschen", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ + id: "post-1", + petId: "pet-1", + pet: { id: "pet-1", ownerId: "owner-1" }, + }); + mockPrisma.repost.findMany.mockResolvedValue([{ postId: "pawback-1" }]); + + await caller.delete({ postId: "post-1" }); + + expect(mockPrisma.post.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ["pawback-1"] } } }); + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ + where: { targetPostId: { in: ["post-1", "pawback-1"] } }, + }); + expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" } }); + }); }); describe("posts.getById", () => { diff --git a/src/lib/delete-post.ts b/src/lib/delete-post.ts new file mode 100644 index 0000000..14f5790 --- /dev/null +++ b/src/lib/delete-post.ts @@ -0,0 +1,34 @@ +import "server-only"; +import type { Prisma, PrismaClient } from "@prisma/client"; + +/** + * Datenbank-Schritte zum Löschen eines Beitrags, für ein `$transaction([...])`. + * + * - Repost.originalPost hat kein Cascade: Paw-Backs anderer Tiere auf diesen + * Beitrag würden das Löschen blockieren, sie fallen deshalb mit weg (ihr + * Repost-Eintrag hängt per Cascade an ihrem eigenen Post). + * - Meldungen auf den Beitrag und seine Paw-Backs: targetPostId ist kein + * Fremdschlüssel, sie stünden sonst verwaist in der Report-Liste. + * - Ist der Beitrag selbst ein Paw-Back (`originalPostId`), sinkt der + * denormalisierte repostCount des Originals. + */ +export async function postDeleteOps( + prisma: PrismaClient, + postId: string, + originalPostId?: string | null +): Promise[]> { + const pawBacks = await prisma.repost.findMany({ + where: { originalPostId: postId }, + select: { postId: true }, + }); + const pawBackIds = pawBacks.map((r) => r.postId); + + return [ + prisma.report.deleteMany({ where: { targetPostId: { in: [postId, ...pawBackIds] } } }), + ...(pawBackIds.length > 0 ? [prisma.post.deleteMany({ where: { id: { in: pawBackIds } } })] : []), + prisma.post.delete({ where: { id: postId } }), + ...(originalPostId + ? [prisma.post.update({ where: { id: originalPostId }, data: { repostCount: { decrement: 1 } } })] + : []), + ]; +} diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 1bb6e79..f862847 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -2,7 +2,7 @@ import "server-only"; import { z } from "zod"; import { randomUUID } from "crypto"; import { TRPCError } from "@trpc/server"; -import { AdminRoleType, BlacklistEntryType, type PrismaClient, type ReportReason } from "@prisma/client"; +import { AdminRoleType, BlacklistEntryType, Prisma, type PrismaClient, type ReportReason } from "@prisma/client"; import { clerkClient } from "@clerk/nextjs/server"; import { router, protectedProcedure } from "@/trpc/init"; import { petIdentitySelect } from "@/repositories/pet-repository"; @@ -19,6 +19,7 @@ import { uploadExport } from "@/lib/data-export-storage"; import { sendMail } from "@/lib/mail"; import { buildGreeting, renderBrandedMailHtml } from "@/lib/mail-template"; import { deleteOwnerAccount } from "@/lib/delete-account"; +import { postDeleteOps } from "@/lib/delete-post"; import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet"; import { describeReportTarget, reportDismissedMessage, reportUpheldMessage } from "@/lib/report-feedback"; import { matchesWordBlacklist, matchesLinkBlacklist } from "@/lib/blacklist"; @@ -65,6 +66,32 @@ export async function assertAdmin( return role.role; } +/** + * Moderationsaktionen gegen ein Konto (sperren, verwarnen, löschen …) nur + * gegen niedrigere Ränge. Das Bootstrap-Konto (ADMIN_OWNER_ID, besitzt das + * Team-Tier) ist für alle tabu, es selbst darf gegen jede Rolle vorgehen. + */ +async function assertCanModerate( + prisma: PrismaClient, + callerRole: ResolvedAdminRole, + targetOwnerId: string +): Promise { + if (process.env.ADMIN_OWNER_ID === targetOwnerId) throw new TRPCError({ code: "FORBIDDEN" }); + if (callerRole === "BOOTSTRAP") return; + const target = await prisma.adminRole.findUnique({ where: { ownerId: targetOwnerId } }); + if (target && ROLE_RANK[target.role] >= ROLE_RANK[callerRole]) { + throw new TRPCError({ code: "FORBIDDEN", message: "Cannot act on an admin with an equal or higher role." }); + } +} + +/** Prisma P2025 (zu löschender Eintrag fehlt) → NOT_FOUND statt Serverfehler. */ +function rethrowNotFound(err: unknown): never { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2025") { + throw new TRPCError({ code: "NOT_FOUND" }); + } + throw err; +} + type Granularity = "day" | "week" | "month"; const BUCKET_COUNT: Record = { day: 30, week: 12, month: 12 }; @@ -358,19 +385,9 @@ export const adminRouter = router({ }); if (!post) throw new TRPCError({ code: "NOT_FOUND" }); - // Meldungen zum Beitrag mitlöschen — targetPostId ist kein Fremdschlüssel, - // sie stünden sonst mit leerem Beitrag in der Report-Liste. + // Meldungen und Paw-Backs anderer Tiere fallen mit weg (delete-post.ts). await ctx.prisma.$transaction([ - ctx.prisma.report.deleteMany({ where: { targetPostId: input.postId } }), - ctx.prisma.post.delete({ where: { id: input.postId } }), - ...(post.repost - ? [ - ctx.prisma.post.update({ - where: { id: post.repost.originalPostId }, - data: { repostCount: { decrement: 1 } }, - }), - ] - : []), + ...(await postDeleteOps(ctx.prisma, input.postId, post.repost?.originalPostId)), ctx.prisma.moderationLog.create({ data: { action: "DELETE_POST", @@ -556,8 +573,23 @@ export const adminRouter = router({ deleteBlacklistEntry: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); - await ctx.prisma.blacklistEntry.delete({ where: { id: input.id } }); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); + await ctx.prisma + .$transaction(async (tx) => { + const entry = await tx.blacklistEntry.delete({ where: { id: input.id } }); + await tx.moderationLog.create({ + data: { + action: "DELETE_BLACKLIST_ENTRY", + targetType: "SYSTEM", + targetId: input.id, + reason: `${entry.type.toLowerCase()}: ${entry.value}`, + moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, + }, + }); + }) + .catch(rethrowNotFound); return { success: true }; }), @@ -694,6 +726,7 @@ export const adminRouter = router({ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); if (input.ownerId === ctx.userId) throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot ban yourself." }); + await assertCanModerate(ctx.prisma, callerRole, input.ownerId); await ctx.prisma.$transaction([ ctx.prisma.userBan.upsert({ where: { ownerId: input.ownerId }, @@ -729,20 +762,22 @@ export const adminRouter = router({ .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); - await ctx.prisma.$transaction([ - ctx.prisma.userBan.delete({ where: { ownerId: input.ownerId } }), - ctx.prisma.moderationLog.create({ - data: { - action: "UNBAN_USER", - targetType: "USER", - targetId: input.ownerId, - reason: null, - moderatorId: ctx.userId, - moderatorRole: callerRole, - ipAddress: ctx.ip, - }, - }), - ]); + await ctx.prisma + .$transaction([ + ctx.prisma.userBan.delete({ where: { ownerId: input.ownerId } }), + ctx.prisma.moderationLog.create({ + data: { + action: "UNBAN_USER", + targetType: "USER", + targetId: input.ownerId, + reason: null, + moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, + }, + }), + ]) + .catch(rethrowNotFound); return { success: true }; }), @@ -752,6 +787,7 @@ export const adminRouter = router({ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); if (input.ownerId === ctx.userId) throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot shadowban yourself." }); + await assertCanModerate(ctx.prisma, callerRole, input.ownerId); await ctx.prisma.$transaction([ ctx.prisma.shadowban.upsert({ where: { ownerId: input.ownerId }, @@ -777,20 +813,22 @@ export const adminRouter = router({ .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); - await ctx.prisma.$transaction([ - ctx.prisma.shadowban.delete({ where: { ownerId: input.ownerId } }), - ctx.prisma.moderationLog.create({ - data: { - action: "UNSHADOWBAN_USER", - targetType: "USER", - targetId: input.ownerId, - reason: null, - moderatorId: ctx.userId, - moderatorRole: callerRole, - ipAddress: ctx.ip, - }, - }), - ]); + await ctx.prisma + .$transaction([ + ctx.prisma.shadowban.delete({ where: { ownerId: input.ownerId } }), + ctx.prisma.moderationLog.create({ + data: { + action: "UNSHADOWBAN_USER", + targetType: "USER", + targetId: input.ownerId, + reason: null, + moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, + }, + }), + ]) + .catch(rethrowNotFound); return { success: true }; }), @@ -809,6 +847,7 @@ export const adminRouter = router({ .input(z.object({ ownerId: z.string().min(1), reason: z.string().min(1).max(500) })) .mutation(async ({ ctx, input }) => { const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); + await assertCanModerate(ctx.prisma, callerRole, input.ownerId); const [warning] = await ctx.prisma.$transaction([ ctx.prisma.warning.create({ data: { ownerId: input.ownerId, reason: input.reason, issuedBy: ctx.userId }, @@ -1008,6 +1047,7 @@ export const adminRouter = router({ if (input.confirmation !== input.ownerId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Confirmation does not match." }); } + await assertCanModerate(ctx.prisma, callerRole, input.ownerId); await deleteOwnerAccount(ctx.prisma, input.ownerId); @@ -1385,18 +1425,9 @@ export const adminRouter = router({ select: { reporterPetId: true }, }); - // Comments and reactions cascade via the Prisma schema; reports don't (see above). + // Comments and reactions cascade via the Prisma schema; reports and paw-backs don't (delete-post.ts). await ctx.prisma.$transaction([ - ctx.prisma.report.deleteMany({ where: { targetPostId: input.postId } }), - ctx.prisma.post.delete({ where: { id: input.postId } }), - ...(post.repost - ? [ - ctx.prisma.post.update({ - where: { id: post.repost.originalPostId }, - data: { repostCount: { decrement: 1 } }, - }), - ] - : []), + ...(await postDeleteOps(ctx.prisma, input.postId, post.repost?.originalPostId)), ctx.prisma.moderationLog.create({ data: { action: "DELETE_POST", @@ -1766,19 +1797,21 @@ export const adminRouter = router({ .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); - await ctx.prisma.$transaction([ - ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }), - ctx.prisma.moderationLog.create({ - data: { - action: "REVOKE_ROLE", - targetType: "USER", - targetId: input.ownerId, - moderatorId: ctx.userId, - moderatorRole: callerRole, - ipAddress: ctx.ip, - }, - }), - ]); + await ctx.prisma + .$transaction([ + ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }), + ctx.prisma.moderationLog.create({ + data: { + action: "REVOKE_ROLE", + targetType: "USER", + targetId: input.ownerId, + moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, + }, + }), + ]) + .catch(rethrowNotFound); return { success: true }; }), diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 19b604f..cb6e4ba 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -17,6 +17,7 @@ import { createMentionNotifications } from "@/lib/mention-helpers"; import { assertPetOwnership, petRoleOf } from "@/lib/assert-pet-ownership"; import { petIdentitySelect } from "@/repositories/pet-repository"; import { maybeQualifyReferral } from "@/lib/referrals"; +import { postDeleteOps } from "@/lib/delete-post"; /** * Posts tRPC router — photo post creation, caption edit, deletion, feed hydration. @@ -225,12 +226,9 @@ export const postsRouter = router({ // Ownership assertion (T-02-34) await assertPostOwnership(ctx.prisma, input.postId, ctx.userId); - // PostImages cascade via schema onDelete: Cascade. Meldungen nicht (targetPostId - // ist kein Fremdschlüssel) — mitlöschen, sonst bleiben sie verwaist in der Report-Liste. - await ctx.prisma.$transaction([ - ctx.prisma.report.deleteMany({ where: { targetPostId: input.postId } }), - ctx.prisma.post.delete({ where: { id: input.postId } }), - ]); + // PostImages cascade via schema onDelete: Cascade. Meldungen und Paw-Backs + // anderer Tiere nicht — die räumt postDeleteOps mit ab (delete-post.ts). + await ctx.prisma.$transaction(await postDeleteOps(ctx.prisma, input.postId)); return { deleted: true }; }),