fix(admin): Beiträge mit Paw-Backs löschbar, Rangschutz, saubere Fehler

- postDeleteOps (src/lib/delete-post.ts): Paw-Backs anderer Tiere und deren
  Meldungen fallen beim Löschen mit weg. Repost.originalPost hat kein Cascade
  und blockierte bisher das Löschen solcher Beiträge, im Admin-Panel
  (deletePost, deleteReportedPost) wie in posts.delete.
- assertCanModerate: sperren, shadowbannen, verwarnen und Konto löschen nur
  gegen niedrigere Ränge; das Bootstrap-Konto (ADMIN_OWNER_ID) ist tabu.
- unbanUser, removeShadowban, revokeRole: fehlender Eintrag gibt NOT_FOUND
  statt eines 500-Fehlers.
- deleteBlacklistEntry schreibt einen Audit-Log-Eintrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 20:30:11 +02:00
1 parent 6d2d2f19a5
commit 42eff68a29
7 files changed
+323 -75

No files matched your search

+1 -1
View File
@@ -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");
+155
View File
@@ -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");
+10
View File
@@ -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(),
},
+19 -1
View File
@@ -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", () => {
+34
View File
@@ -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<Prisma.PrismaPromise<unknown>[]> {
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 } } })]
: []),
];
}
+100 -67
View File
@@ -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<void> {
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<Granularity, number> = { 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 };
}),
+4 -6
View File
@@ -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 };
}),