import { usePrisma } from "../utils/prisma"; // ───────────────────────────────────────────────────────────────────────────── // Moderation-Queue — DB-Layer // // Wird ausschließlich vom Admin-Backend (/api/admin/moderation/*) verwendet. // Reported-Marker ist `isModerated=true` auf CommunityPost / CommunityReply // (siehe Migration 20260509_moderation_queue). // // Audit-Trail: jede Aktion (dismiss / delete / ban_user) schreibt einen // `moderation_actions`-Eintrag inkl. content-snapshot. Original-Reporter wird // nicht persistiert (anonymous-report-Convention, DSGVO-Datenminimierung). // ───────────────────────────────────────────────────────────────────────────── const MAX_LIMIT = 100; const DEFAULT_LIMIT = 50; export type ModerationItemType = "post" | "comment"; export type ModerationQueueItem = { id: string; type: ModerationItemType; content: string; postId: string | null; userId: string; reportedAt: Date | null; createdAt: Date; isDeleted: boolean; author: { id: string; nickname: string | null; avatar: string | null; plan: string; } | null; }; export type ListQueueOpts = { cursor?: string; limit?: number; }; /** * Liste der gemeldeten (isModerated=true) Posts + Comments. * * Items mit reportedAt=null (legacy-flagged ohne Timestamp) werden * trotzdem ausgeliefert und ans Ende der Sortierung gestellt. * * Cursor: opaker String "{type}:{id}"; nextCursor=null → Ende. */ export async function listModerationQueue(opts: ListQueueOpts = {}): Promise<{ items: ModerationQueueItem[]; nextCursor: string | null; }> { const db = usePrisma(); const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); // Cursor-Parsing — Format: "{type}:{uuid}". Robuster Fallback auf null // bei korruptem Cursor (treat as no-cursor). let cursorPostId: string | null = null; let cursorReplyId: string | null = null; if (opts.cursor) { const [t, id] = opts.cursor.split(":"); if (t === "post" && id) cursorPostId = id; else if (t === "comment" && id) cursorReplyId = id; } // Wir holen pro Typ limit+1 — danach in-memory mergen + sortieren. // Klein genug für admin-queue (typisch <100 reports gleichzeitig). const [posts, replies] = await Promise.all([ db.communityPost.findMany({ where: { isModerated: true }, orderBy: [{ reportedAt: "desc" }, { id: "desc" }], take: limit + 1, ...(cursorPostId ? { cursor: { id: cursorPostId }, skip: 1 } : {}), select: { id: true, userId: true, content: true, reportedAt: true, createdAt: true, isDeleted: true, author: { select: { id: true, nickname: true, avatar: true, plan: true, }, }, }, }), db.communityReply.findMany({ where: { isModerated: true }, orderBy: [{ reportedAt: "desc" }, { id: "desc" }], take: limit + 1, ...(cursorReplyId ? { cursor: { id: cursorReplyId }, skip: 1 } : {}), select: { id: true, postId: true, userId: true, content: true, reportedAt: true, createdAt: true, isDeleted: true, author: { select: { id: true, nickname: true, avatar: true, plan: true, }, }, }, }), ]); const merged: ModerationQueueItem[] = [ ...posts.map((p) => ({ id: p.id, type: "post" as const, content: p.content, postId: null, userId: p.userId, reportedAt: p.reportedAt, createdAt: p.createdAt, isDeleted: p.isDeleted, author: p.author, })), ...replies.map((r) => ({ id: r.id, type: "comment" as const, content: r.content, postId: r.postId, userId: r.userId, reportedAt: r.reportedAt, createdAt: r.createdAt, isDeleted: r.isDeleted, author: r.author, })), ]; // reportedAt desc, NULL ans Ende; Tie-Break: createdAt desc. merged.sort((a, b) => { const aTs = a.reportedAt?.getTime() ?? 0; const bTs = b.reportedAt?.getTime() ?? 0; if (aTs !== bTs) return bTs - aTs; return b.createdAt.getTime() - a.createdAt.getTime(); }); const items = merged.slice(0, limit); // nextCursor — nimm letztes Item, encode "{type}:{id}". Heuristik: wenn // wir genau `limit` Items haben UND mind. einer der DB-Calls limit+1 // geliefert hat, dann gibt's mehr. const hasMore = posts.length > limit || replies.length > limit || merged.length > items.length; const last = items[items.length - 1]; const nextCursor = hasMore && last ? `${last.type}:${last.id}` : null; return { items, nextCursor }; } /** * Dismiss → Flag clear ohne Aktion. isModerated=false, reportedAt=null. * Schreibt audit-log "dismiss". */ export async function dismissModerationItem( type: ModerationItemType, id: string, adminUserId: string | null, ): Promise<{ ok: true }> { const db = usePrisma(); const target = type === "post" ? await db.communityPost.findUnique({ where: { id }, select: { id: true, content: true }, }) : await db.communityReply.findUnique({ where: { id }, select: { id: true, content: true }, }); if (!target) { throw createError({ statusCode: 404, message: `${type} nicht gefunden`, }); } if (type === "post") { await db.communityPost.update({ where: { id }, data: { isModerated: false, reportedAt: null }, }); } else { await db.communityReply.update({ where: { id }, data: { isModerated: false, reportedAt: null }, }); } await db.moderationAction.create({ data: { targetType: type, targetId: id, action: "dismiss", adminUserId, contentSnapshot: target.content, }, }); return { ok: true }; } /** * Soft-Delete: content="", isDeleted=true. isModerated bleibt true (audit-trail * — Admin sieht in queue-Filter „deleted" was schon weg ist). Original-content * wird in moderation_actions.contentSnapshot persistiert. */ export async function deleteModerationItem( type: ModerationItemType, id: string, adminUserId: string | null, reason?: string | null, ): Promise<{ ok: true }> { const db = usePrisma(); const target = type === "post" ? await db.communityPost.findUnique({ where: { id }, select: { id: true, content: true }, }) : await db.communityReply.findUnique({ where: { id }, select: { id: true, content: true }, }); if (!target) { throw createError({ statusCode: 404, message: `${type} nicht gefunden`, }); } const now = new Date(); if (type === "post") { await db.communityPost.update({ where: { id }, data: { content: "", isDeleted: true, deletedAt: now, }, }); } else { await db.communityReply.update({ where: { id }, data: { content: "", isDeleted: true, deletedAt: now, }, }); } await db.moderationAction.create({ data: { targetType: type, targetId: id, action: "delete", adminUserId, contentSnapshot: target.content, reason: reason ?? null, }, }); return { ok: true }; } /** * Ban-User wegen content. Reuses Profile.banned + bannedAt + bannedReason * (siehe db/adminUsers.ts updateAdminUser-Pattern). Schreibt audit-log "ban_user". * * NOTE: Hier wird zwingend nur der Profile-Patch ausgeführt. Der Caller (Endpoint) * sollte zusätzlich `updateAdminUser({ banned: true })` aus adminUsers.ts nutzen, * falls beide Endpoints denselben Pfad teilen sollen — aktuell duplizieren wir * den minimalen Patch hier um die DB-Layer-Trennung sauber zu halten. */ export async function banUserFromModerationItem( type: ModerationItemType, id: string, adminUserId: string | null, reason?: string | null, ): Promise<{ ok: true; bannedUserId: string }> { const db = usePrisma(); const target = type === "post" ? await db.communityPost.findUnique({ where: { id }, select: { id: true, content: true, userId: true }, }) : await db.communityReply.findUnique({ where: { id }, select: { id: true, content: true, userId: true }, }); if (!target) { throw createError({ statusCode: 404, message: `${type} nicht gefunden`, }); } await db.profile.update({ where: { id: target.userId }, data: { banned: true, bannedAt: new Date(), bannedReason: reason ?? `Moderation: ${type} ${id}`, }, }); await db.moderationAction.create({ data: { targetType: type, targetId: id, action: "ban_user", adminUserId, contentSnapshot: target.content, reason: reason ?? null, }, }); return { ok: true, bannedUserId: target.userId }; }