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:
1 parent
6d2d2f19a5
commit
42eff68a29
7 files changed
+323
-75
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user