chahinebrini 056726a166 feat(admin): Phase 2 Backend — Users + Moderation endpoints + 2 schema migrations
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>
2026-05-09 15:48:35 +02:00

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 };
}