Two parallel agent-batches consolidated: USERS-MGMT (rebreak-backend agent): - Schema: Profile gets banned, bannedAt, bannedReason, deletedAt + indexes - Migration: 20260509_profile_admin_management (additive, idempotent) - DB-layer backend/server/db/adminUsers.ts: listAdminUsers (cursor-pagination, search, plan-filter) updateAdminUser (plan-validation, ban-stamping) softDeleteAdminUser (DSGVO PII-scrub: nickname=null, email=deleted-{shortid}@deleted.local) - 3 endpoints under /api/admin/users: GET (list with ?cursor&limit&q&plan&includeDeleted) PATCH /:id (plan/banned/bannedReason/lyraVoiceId) DELETE /:id (soft-delete idempotent) - 12 tests passing MODERATION (rebreak-backend agent): - Schema: CommunityPost+CommunityReply get isModerated, isDeleted, deletedAt, reportedAt + index (is_moderated, reported_at) - New ModerationAction model → audit-log table - Migration: 20260509_moderation_queue (additive, idempotent) - DB-layer backend/server/db/moderation.ts: listModerationQueue (merge posts+comments, sort by reportedAt, cursor) dismissModerationItem deleteModerationItem (content scrub + audit snapshot) banUserFromModerationItem (reuses banned/bannedAt/bannedReason fields) - 4 endpoints under /api/admin/moderation: GET /queue, POST /:id/dismiss, POST /:id/delete, POST /:id/ban-user - 11 tests passing Backend total: 78 tests passing | 4 skipped (pre-existing requireAdmin tests) Auth: x-admin-secret header (consistent with existing /admin/* endpoints). DSGVO: - Soft-delete scrubt PII statt hard-delete - Email NICHT in admin user-list (lebt nur in auth.users) - Audit-log für moderation-actions (90-day cleanup-cron pending hans-mueller-DSB-review) ⚠️ MIGRATIONS — auto-deploy via pipeline (commit b38bf17 detection): - 20260509_profile_admin_management - 20260509_moderation_queue Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
342 lines
9.2 KiB
TypeScript
342 lines
9.2 KiB
TypeScript
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 };
|
|
}
|