diff --git a/backend/prisma/migrations/20260509_moderation_queue/migration.sql b/backend/prisma/migrations/20260509_moderation_queue/migration.sql new file mode 100644 index 0000000..0959bd4 --- /dev/null +++ b/backend/prisma/migrations/20260509_moderation_queue/migration.sql @@ -0,0 +1,56 @@ +-- Admin Moderation Queue — Phase E +-- Erweitert CommunityPost / CommunityReply um Moderation-Felder und legt +-- moderation_actions-Audit-Tabelle an. +-- +-- Reported-Marker: +-- isModerated=true → in /api/admin/moderation/queue gelistet +-- Dismiss → isModerated=false (Flag clear) +-- Delete → content="", isDeleted=true, isModerated bleibt true +-- (für audit / spätere Re-Review) +-- +-- Audit-Trail (DSGVO): +-- Jede Aktion (dismiss / delete / ban_user) schreibt einen ModerationAction- +-- Eintrag inkl. content-snapshot. Reporter-Info wird nicht persistiert +-- (anonymous report-flow per Convention). +-- +-- Drift-Hinweis: Diese Migration wird via `pnpm prisma migrate deploy` auf +-- staging-/prod-DB gefahren. Lokal NICHT ausführen. Falls Drift erkannt wird: +-- pnpm prisma migrate resolve --applied 20260509_moderation_queue + +-- ─── community_posts: Moderation-Felder ────────────────────────────────────── +ALTER TABLE "rebreak"."community_posts" + ADD COLUMN IF NOT EXISTS "is_deleted" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "reported_at" TIMESTAMP(3); + +CREATE INDEX IF NOT EXISTS "community_posts_is_moderated_reported_at_idx" + ON "rebreak"."community_posts" ("is_moderated", "reported_at"); + +-- ─── community_replies: Moderation-Felder ──────────────────────────────────── +ALTER TABLE "rebreak"."community_replies" + ADD COLUMN IF NOT EXISTS "is_moderated" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "is_deleted" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "reported_at" TIMESTAMP(3); + +CREATE INDEX IF NOT EXISTS "community_replies_is_moderated_reported_at_idx" + ON "rebreak"."community_replies" ("is_moderated", "reported_at"); + +-- ─── moderation_actions: Audit-Log ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "rebreak"."moderation_actions" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "target_type" TEXT NOT NULL, + "target_id" UUID NOT NULL, + "action" TEXT NOT NULL, + "admin_user_id" UUID, + "content_snapshot" TEXT, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "moderation_actions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "moderation_actions_target_idx" + ON "rebreak"."moderation_actions" ("target_type", "target_id"); +CREATE INDEX IF NOT EXISTS "moderation_actions_created_at_idx" + ON "rebreak"."moderation_actions" ("created_at"); diff --git a/backend/prisma/migrations/20260509_profile_admin_management/migration.sql b/backend/prisma/migrations/20260509_profile_admin_management/migration.sql new file mode 100644 index 0000000..06b620a --- /dev/null +++ b/backend/prisma/migrations/20260509_profile_admin_management/migration.sql @@ -0,0 +1,25 @@ +-- Profile Admin Management — Phase E +-- Felder für Admin-User-Management: +-- * banned → BOOLEAN, default false +-- * banned_at → TIMESTAMP, gesetzt wenn banned=true +-- * banned_reason → TEXT, optional Note vom Admin +-- * deleted_at → TIMESTAMP, soft-delete (DSGVO Art. 17, scrubbed PII) +-- +-- Soft-Delete-Strategie: +-- nickname → NULL, username → 'deleted-{shortid}', avatar → NULL, +-- demographics → cleared. auth.users-Eintrag bleibt zunächst (manueller +-- Hard-Delete via Supabase-Admin-API in separater Operation). +-- +-- Drift-Hinweis: Diese Migration wird via `pnpm prisma migrate deploy` auf +-- staging-/prod-DB gefahren. Lokal NICHT ausführen. Falls Drift erkannt wird: +-- pnpm prisma migrate resolve --applied 20260509_profile_admin_management + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "banned" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "banned_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "banned_reason" TEXT, + ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3); + +-- Performance: Admin-User-Liste filtert oft auf nicht-gelöschte / nicht-gebannte +CREATE INDEX IF NOT EXISTS "profiles_deleted_at_idx" ON "rebreak"."profiles" ("deleted_at"); +CREATE INDEX IF NOT EXISTS "profiles_plan_idx" ON "rebreak"."profiles" ("plan"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 79ada6a..d175430 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -55,9 +55,19 @@ model Profile { // ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ── lastInstallAt DateTime? @map("last_install_at") + // ─── Admin-Management (Phase E, Migration 20260509) ───────────────────── + // banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase + // bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO). + banned Boolean @default(false) + bannedAt DateTime? @map("banned_at") + bannedReason String? @map("banned_reason") + deletedAt DateTime? @map("deleted_at") + communityPosts CommunityPost[] communityReplies CommunityReply[] + @@index([deletedAt]) + @@index([plan]) @@map("profiles") @@schema("rebreak") } @@ -133,7 +143,16 @@ model CommunityPost { commentsCount Int @default(0) @map("comments_count") repostsCount Int @default(0) @map("reposts_count") isAnonymous Boolean @default(false) @map("is_anonymous") + /// Reported-Marker: true wenn ein User den Post gemeldet hat. Admin-Queue + /// listet alle Posts mit isModerated=true. Dismiss → false, Delete → bleibt + /// true zusammen mit isDeleted=true (für audit/spätere Re-Review). isModerated Boolean @default(false) @map("is_moderated") + /// Soft-Delete durch Moderation. content → "" damit Public-API nichts mehr + /// rendert; Audit-Log behält Original (siehe ModerationAction.contentSnapshot). + isDeleted Boolean @default(false) @map("is_deleted") + deletedAt DateTime? @map("deleted_at") + /// Wann der Post zum ersten Mal gemeldet wurde (queue-Sortierung). + reportedAt DateTime? @map("reported_at") repostOfId String? @map("repost_of_id") @db.Uuid challengeId String? @map("challenge_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") @@ -144,6 +163,7 @@ model CommunityPost { PostLike PostLike[] CommunityReply CommunityReply[] + @@index([isModerated, reportedAt]) @@map("community_posts") @@schema("rebreak") } @@ -168,12 +188,18 @@ model CommunityReply { parentReplyId String? @map("parent_reply_id") @db.Uuid isAnonymous Boolean @default(false) @map("is_anonymous") likesCount Int @default(0) @map("likes_count") + /// Reported-Marker analog CommunityPost.isModerated. + isModerated Boolean @default(false) @map("is_moderated") + isDeleted Boolean @default(false) @map("is_deleted") + deletedAt DateTime? @map("deleted_at") + reportedAt DateTime? @map("reported_at") createdAt DateTime @default(now()) @map("created_at") post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade) author Profile? @relation(fields: [userId], references: [id]) CommentLike CommentLike[] + @@index([isModerated, reportedAt]) @@map("community_replies") @@schema("rebreak") } @@ -627,6 +653,31 @@ model AdminUser { @@schema("rebreak") } +/// Audit-Log für Moderation-Aktionen (DSGVO-konform: Original-Inhalt bleibt für +/// 90 Tage erhalten, danach Cron-Cleanup). Wird von /api/admin/moderation/[id]/* +/// nach jeder Aktion (dismiss / delete / ban-user) geschrieben. +model ModerationAction { + id String @id @default(uuid()) @db.Uuid + /// "post" | "comment" + targetType String @map("target_type") + /// CommunityPost.id oder CommunityReply.id + targetId String @map("target_id") @db.Uuid + /// "dismiss" | "delete" | "ban_user" + action String + /// Profile.id des Admins (aus admin_users-Allowlist). + adminUserId String? @map("admin_user_id") @db.Uuid + /// Snapshot des Original-Contents zum Zeitpunkt der Aktion (Audit-Trail). + contentSnapshot String? @map("content_snapshot") + /// Optionale Begründung vom Admin. + reason String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([targetType, targetId]) + @@index([createdAt]) + @@map("moderation_actions") + @@schema("rebreak") +} + // Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices). // Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird // bei jedem authentifizierten Request via x-device-id Header geprüft. diff --git a/backend/server/api/admin/moderation/[id]/ban-user.post.ts b/backend/server/api/admin/moderation/[id]/ban-user.post.ts new file mode 100644 index 0000000..e9631de --- /dev/null +++ b/backend/server/api/admin/moderation/[id]/ban-user.post.ts @@ -0,0 +1,35 @@ +// backend/server/api/admin/moderation/[id]/ban-user.post.ts +// +// POST /api/admin/moderation/[id]/ban-user +// Body: { type: "post" | "comment", reason?: string } +// +// Bant den Author des gemeldeten Items (Profile.banned=true). Reuses denselben +// Patch-Pattern wie /api/admin/users/[id] (siehe db/adminUsers.ts updateAdminUser). +// Schreibt audit-log "ban_user" inkl. content-snapshot. + +import { banUserFromModerationItem } from "../../../../db/moderation"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + const body = (await readBody(event).catch(() => ({}))) as { + type?: string; + reason?: string; + adminUserId?: string; + }; + + const type = body?.type === "comment" ? "comment" : "post"; + return banUserFromModerationItem( + type, + id, + body?.adminUserId ?? null, + body?.reason ?? null, + ); +}); diff --git a/backend/server/api/admin/moderation/[id]/delete.post.ts b/backend/server/api/admin/moderation/[id]/delete.post.ts new file mode 100644 index 0000000..1f1d78f --- /dev/null +++ b/backend/server/api/admin/moderation/[id]/delete.post.ts @@ -0,0 +1,34 @@ +// backend/server/api/admin/moderation/[id]/delete.post.ts +// +// POST /api/admin/moderation/[id]/delete +// Body: { type: "post" | "comment", reason?: string } +// +// Soft-Delete: content="", isDeleted=true. Original-Content + reporter-info +// bleiben in moderation_actions (audit-log, DSGVO Art. 17 erlaubt audit-trail). + +import { deleteModerationItem } from "../../../../db/moderation"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + const body = (await readBody(event).catch(() => ({}))) as { + type?: string; + reason?: string; + adminUserId?: string; + }; + + const type = body?.type === "comment" ? "comment" : "post"; + return deleteModerationItem( + type, + id, + body?.adminUserId ?? null, + body?.reason ?? null, + ); +}); diff --git a/backend/server/api/admin/moderation/[id]/dismiss.post.ts b/backend/server/api/admin/moderation/[id]/dismiss.post.ts new file mode 100644 index 0000000..36924a6 --- /dev/null +++ b/backend/server/api/admin/moderation/[id]/dismiss.post.ts @@ -0,0 +1,27 @@ +// backend/server/api/admin/moderation/[id]/dismiss.post.ts +// +// POST /api/admin/moderation/[id]/dismiss +// Body: { type: "post" | "comment" } +// +// Flag-clear ohne Aktion: isModerated → false. Audit-Log "dismiss". + +import { dismissModerationItem } from "../../../../db/moderation"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + const body = (await readBody(event).catch(() => ({}))) as { + type?: string; + adminUserId?: string; + }; + + const type = body?.type === "comment" ? "comment" : "post"; + return dismissModerationItem(type, id, body?.adminUserId ?? null); +}); diff --git a/backend/server/api/admin/moderation/queue.get.ts b/backend/server/api/admin/moderation/queue.get.ts new file mode 100644 index 0000000..a6edb98 --- /dev/null +++ b/backend/server/api/admin/moderation/queue.get.ts @@ -0,0 +1,36 @@ +// backend/server/api/admin/moderation/queue.get.ts +// +// GET /api/admin/moderation/queue +// Liste aller gemeldeten Posts + Comments (isModerated=true). +// +// Query-Params: +// - cursor (optional): "{type}:{id}" opaker String, vom letzten Response übernehmen +// - limit (optional): default 50, max 100 +// +// Auth: x-admin-secret-Header (analog stats.get.ts / domain-submissions). + +import { listModerationQueue } from "../../../db/moderation"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const query = getQuery(event); + const cursor = + typeof query.cursor === "string" && query.cursor.length > 0 + ? query.cursor + : undefined; + const limitRaw = query.limit; + const limit = + typeof limitRaw === "string" || typeof limitRaw === "number" + ? Number.parseInt(String(limitRaw), 10) + : undefined; + + return listModerationQueue({ + cursor, + limit: Number.isFinite(limit) ? limit : undefined, + }); +}); diff --git a/backend/server/api/admin/users/[id].delete.ts b/backend/server/api/admin/users/[id].delete.ts new file mode 100644 index 0000000..0958d8c --- /dev/null +++ b/backend/server/api/admin/users/[id].delete.ts @@ -0,0 +1,37 @@ +import { softDeleteAdminUser } from "../../../db/adminUsers"; + +/** + * DELETE /api/admin/users/[id] — Soft-Delete (DSGVO Art. 17, scrubbed PII) + * + * Was passiert (Details siehe db/adminUsers.ts → softDeleteAdminUser): + * - PII-Felder werden auf null gesetzt (nickname, avatar, demographics) + * - username → "deleted-{shortid}" + * - deletedAt-Marker → User taucht in normalen Listen nicht mehr auf + * + * Was NICHT passiert: + * - Hard-Delete (FK-Cascade auf Posts/Likes wäre destruktiv für Community-State) + * - Supabase auth.users Eintrag (separater Schritt — in dieser Operation + * bewusst getrennt damit kein Login-Lock vor User-Bestätigung) + * + * Auth: x-admin-secret. + * Idempotent — wiederholtes DELETE → { ok: true, alreadyDeleted: true }. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "User-ID fehlt" }); + + const result = await softDeleteAdminUser(id); + + // Console-audit-trail bis dedicated audit_log table verfügbar ist + console.log( + `[admin/users] DELETE (soft) user=${id} alreadyDeleted=${result.alreadyDeleted}`, + ); + + return result; +}); diff --git a/backend/server/api/admin/users/[id].patch.ts b/backend/server/api/admin/users/[id].patch.ts new file mode 100644 index 0000000..42fcf3c --- /dev/null +++ b/backend/server/api/admin/users/[id].patch.ts @@ -0,0 +1,62 @@ +import { updateAdminUser } from "../../../db/adminUsers"; + +/** + * PATCH /api/admin/users/[id] — Admin-Update für plan / banned / lyraVoiceId + * + * Body: + * { + * plan?: "free" | "pro" | "legend", + * banned?: boolean, + * bannedReason?: string | null, + * lyraVoiceId?: string | null + * } + * + * Returns: updated user-row (admin-projection). + * + * Auth: x-admin-secret. + * + * Audit: TODO(hans-mueller) — sobald audit_log table existiert, hier write + * { actor, target_user_id, action: "admin_user_update", diff, ts }. + * Aktuell wird die Änderung nur per console.log gespiegelt. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "User-ID fehlt" }); + + const body = (await readBody(event).catch(() => ({}))) as Record< + string, + unknown + >; + + const patch = { + plan: typeof body.plan === "string" ? body.plan : undefined, + banned: typeof body.banned === "boolean" ? body.banned : undefined, + bannedReason: + body.bannedReason === null + ? null + : typeof body.bannedReason === "string" + ? body.bannedReason + : undefined, + lyraVoiceId: + body.lyraVoiceId === null + ? null + : typeof body.lyraVoiceId === "string" + ? body.lyraVoiceId + : undefined, + }; + + const updated = await updateAdminUser(id, patch); + + // Console-audit-trail bis dedicated audit_log table verfügbar ist + console.log( + `[admin/users] PATCH user=${id} patch=${JSON.stringify(patch)} → plan=${updated.plan} banned=${updated.banned}`, + ); + + return updated; +}); diff --git a/backend/server/api/admin/users/index.get.ts b/backend/server/api/admin/users/index.get.ts new file mode 100644 index 0000000..2781902 --- /dev/null +++ b/backend/server/api/admin/users/index.get.ts @@ -0,0 +1,43 @@ +import { listAdminUsers } from "../../../db/adminUsers"; + +/** + * GET /api/admin/users — Admin-User-Liste (cursor-paginated, search, plan-filter) + * + * Query-Params: + * ?cursor= — pagination cursor (id from previous nextCursor) + * ?limit=50 — max 100 + * ?q= — fuzzy-match auf nickname + username (case-insensitive) + * ?plan=free|pro|legend — filter + * ?includeDeleted=1 — auch soft-deleted users zurückgeben + * + * Auth: x-admin-secret Header (gleicher Pattern wie /api/admin/stats). + * + * DSGVO-Note: Email lebt in supabase.auth.users — bewusst NICHT joinen, + * Admin-UI zeigt nur Nickname/Username (siehe memory/feedback_anonymity_nickname). + * Wenn Email für DSGVO-Auskunft nötig ist → separater Endpoint mit + * zusätzlicher Bestätigung (Phase F, hans-mueller). + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const query = getQuery(event); + const limitNum = query.limit ? Number(query.limit) : undefined; + const planRaw = typeof query.plan === "string" ? query.plan : undefined; + const plan = + planRaw === "free" || planRaw === "pro" || planRaw === "legend" + ? planRaw + : undefined; + + return listAdminUsers({ + cursor: typeof query.cursor === "string" ? query.cursor : undefined, + limit: Number.isFinite(limitNum) ? limitNum : undefined, + q: typeof query.q === "string" ? query.q : undefined, + plan, + includeDeleted: + query.includeDeleted === "1" || query.includeDeleted === "true", + }); +}); diff --git a/backend/server/db/adminUsers.ts b/backend/server/db/adminUsers.ts new file mode 100644 index 0000000..5c4624b --- /dev/null +++ b/backend/server/db/adminUsers.ts @@ -0,0 +1,223 @@ +import { usePrisma } from "../utils/prisma"; + +// ───────────────────────────────────────────────────────────────────────────── +// Admin-User-Management — DB-layer +// +// Wird ausschließlich vom Admin-Backend (/api/admin/users/*) verwendet. +// Alle Schreib-Operationen sind audit-relevant — TODO(hans-mueller): sobald +// `audit_log` table existiert, in jeder Update-/Delete-Funktion einen +// Eintrag schreiben (admin-id + target-user + diff + timestamp). +// ───────────────────────────────────────────────────────────────────────────── + +export type ListUsersOpts = { + cursor?: string; + limit?: number; + q?: string; + plan?: "free" | "pro" | "legend"; + /** Default: false → soft-deleted users werden ausgeblendet. */ + includeDeleted?: boolean; +}; + +export type AdminUserRow = { + id: string; + nickname: string | null; + username: string | null; + avatar: string | null; + plan: string; + streak: number; + banned: boolean; + bannedAt: Date | null; + deletedAt: Date | null; + createdAt: Date; + lyraVoiceId: string | null; + premiumUntil: Date | null; + proTrialExpiresAt: Date | null; +}; + +const MAX_LIMIT = 100; +const DEFAULT_LIMIT = 50; + +/** + * List users with cursor-pagination, optional fuzzy-search on nickname/username. + * Returns one extra row to determine `nextCursor` without a separate count. + */ +export async function listAdminUsers(opts: ListUsersOpts = {}): Promise<{ + items: AdminUserRow[]; + nextCursor: string | null; +}> { + const db = usePrisma(); + const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + + const where: Record = {}; + if (!opts.includeDeleted) where.deletedAt = null; + if (opts.plan) where.plan = opts.plan; + if (opts.q && opts.q.trim().length > 0) { + const term = opts.q.trim(); + where.OR = [ + { nickname: { contains: term, mode: "insensitive" } }, + { username: { contains: term, mode: "insensitive" } }, + ]; + } + + // Cursor-pagination by `id` desc — stable, no skipped rows on inserts. + // We over-fetch by one to detect "has more". + const rows = await db.profile.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + ...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}), + select: { + id: true, + nickname: true, + username: true, + avatar: true, + plan: true, + streak: true, + banned: true, + bannedAt: true, + deletedAt: true, + createdAt: true, + lyraVoiceId: true, + premiumUntil: true, + proTrialExpiresAt: true, + }, + }); + + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? items[items.length - 1]!.id : null; + + return { items, nextCursor }; +} + +const VALID_PLANS = new Set(["free", "pro", "legend"]); + +export type UpdateAdminUserPatch = { + plan?: string; + banned?: boolean; + bannedReason?: string | null; + lyraVoiceId?: string | null; +}; + +/** + * Update plan / ban-status / lyra-voice for a user. Validates plan-enum. + * Returns the updated row (admin-projection — no PII beyond what admin already + * sees in the list). + */ +export async function updateAdminUser( + userId: string, + patch: UpdateAdminUserPatch, +): Promise { + const db = usePrisma(); + const data: Record = {}; + + if (patch.plan !== undefined) { + const planLc = patch.plan.toLowerCase(); + if (!VALID_PLANS.has(planLc)) { + throw createError({ + statusCode: 400, + message: `Ungültiger plan-Wert: ${patch.plan} (erwartet: free|pro|legend)`, + }); + } + data.plan = planLc; + } + + if (patch.banned !== undefined) { + data.banned = patch.banned; + data.bannedAt = patch.banned ? new Date() : null; + if (!patch.banned) data.bannedReason = null; + } + if (patch.bannedReason !== undefined) { + data.bannedReason = patch.bannedReason; + } + if (patch.lyraVoiceId !== undefined) { + data.lyraVoiceId = patch.lyraVoiceId; + } + + if (Object.keys(data).length === 0) { + throw createError({ + statusCode: 400, + message: "Keine änderbaren Felder im Body", + }); + } + + return db.profile.update({ + where: { id: userId }, + data, + select: { + id: true, + nickname: true, + username: true, + avatar: true, + plan: true, + streak: true, + banned: true, + bannedAt: true, + deletedAt: true, + createdAt: true, + lyraVoiceId: true, + premiumUntil: true, + proTrialExpiresAt: true, + }, + }); +} + +/** + * DSGVO-konformer Soft-Delete (Art. 17 — Recht auf Vergessenwerden). + * + * Was passiert: + * 1. PII wird scrubbed (nickname → null, username → "deleted-{shortid}", + * avatar → null, alle demographics → null) + * 2. `deletedAt` wird gesetzt → User taucht in normalen Listen nicht mehr auf + * 3. auth.users-Eintrag bleibt unangetastet (Hard-Delete via Supabase-Admin + * erfolgt in separater Operation, sonst FK-Cascade-Risiko bei Posts/Likes) + * + * Idempotent — wenn `deletedAt` schon gesetzt ist, wird die Operation übersprungen. + */ +export async function softDeleteAdminUser(userId: string): Promise<{ + ok: boolean; + alreadyDeleted: boolean; +}> { + const db = usePrisma(); + const existing = await db.profile.findUnique({ + where: { id: userId }, + select: { deletedAt: true }, + }); + if (!existing) { + throw createError({ statusCode: 404, message: "User nicht gefunden" }); + } + if (existing.deletedAt) { + return { ok: true, alreadyDeleted: true }; + } + + // Kurzer eindeutiger Suffix für scrubbed-username damit unique-constraints + // (falls künftig vorhanden) nicht kollidieren. + const shortId = userId.replace(/-/g, "").slice(0, 8); + + await db.profile.update({ + where: { id: userId }, + data: { + nickname: null, + username: `deleted-${shortId}`, + avatar: null, + // Demographics komplett entfernen + birthYear: null, + gender: null, + maritalStatus: null, + profession: null, + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, + bundesland: null, + city: null, + // Stripe-Refs entfernen — Stripe-Customer wird separat gekündigt + stripeCustomerId: null, + stripeSubId: null, + // Soft-Delete-Marker + deletedAt: new Date(), + }, + }); + + return { ok: true, alreadyDeleted: false }; +} diff --git a/backend/server/db/moderation.ts b/backend/server/db/moderation.ts new file mode 100644 index 0000000..d44ebc8 --- /dev/null +++ b/backend/server/db/moderation.ts @@ -0,0 +1,341 @@ +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 }; +} diff --git a/backend/tests/admin/moderation.test.ts b/backend/tests/admin/moderation.test.ts new file mode 100644 index 0000000..1a98a0e --- /dev/null +++ b/backend/tests/admin/moderation.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for admin-moderation DB-layer (server/db/moderation.ts). + * + * Covers: + * - listModerationQueue: merges posts + comments, sorts by reportedAt desc + * - dismissModerationItem: clears flag, writes audit-log + * - deleteModerationItem: soft-deletes content, persists snapshot + * - banUserFromModerationItem: sets Profile.banned, writes audit-log + * + * Strategy: prisma-mock via vi.hoisted (analog demographics.patch.test.ts). + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const prismaMock = vi.hoisted(() => ({ + communityPost: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + communityReply: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + profile: { + update: vi.fn(), + }, + moderationAction: { + create: vi.fn(), + }, +})); + +vi.mock("../../server/utils/prisma", () => ({ + usePrisma: () => prismaMock, +})); + +import { + listModerationQueue, + dismissModerationItem, + deleteModerationItem, + banUserFromModerationItem, +} from "../../server/db/moderation"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── listModerationQueue ───────────────────────────────────────────────────── + +describe("listModerationQueue — merges posts + comments by reportedAt desc", () => { + it("returns merged sorted list with newest report first", async () => { + prismaMock.communityPost.findMany.mockResolvedValueOnce([ + { + id: "post-1", + userId: "user-1", + content: "bad post 1", + reportedAt: new Date("2026-05-01T10:00:00Z"), + createdAt: new Date("2026-04-30T10:00:00Z"), + isDeleted: false, + author: { + id: "user-1", + nickname: "alice", + avatar: null, + plan: "free", + }, + }, + ]); + prismaMock.communityReply.findMany.mockResolvedValueOnce([ + { + id: "reply-1", + postId: "post-99", + userId: "user-2", + content: "bad comment 1", + reportedAt: new Date("2026-05-02T10:00:00Z"), + createdAt: new Date("2026-05-01T10:00:00Z"), + isDeleted: false, + author: { + id: "user-2", + nickname: "bob", + avatar: null, + plan: "pro", + }, + }, + ]); + + const result = await listModerationQueue({ limit: 10 }); + + expect(result.items).toHaveLength(2); + // reply has newer reportedAt → first + expect(result.items[0]!.type).toBe("comment"); + expect(result.items[0]!.id).toBe("reply-1"); + expect(result.items[0]!.postId).toBe("post-99"); + expect(result.items[1]!.type).toBe("post"); + expect(result.items[1]!.id).toBe("post-1"); + expect(result.nextCursor).toBeNull(); + }); + + it("emits nextCursor when posts overflow limit", async () => { + // limit=1, posts.length=2 → nextCursor non-null + prismaMock.communityPost.findMany.mockResolvedValueOnce([ + { + id: "post-1", + userId: "user-1", + content: "p1", + reportedAt: new Date("2026-05-01T10:00:00Z"), + createdAt: new Date("2026-04-30T10:00:00Z"), + isDeleted: false, + author: null, + }, + { + id: "post-2", + userId: "user-1", + content: "p2", + reportedAt: new Date("2026-04-29T10:00:00Z"), + createdAt: new Date("2026-04-29T10:00:00Z"), + isDeleted: false, + author: null, + }, + ]); + prismaMock.communityReply.findMany.mockResolvedValueOnce([]); + + const result = await listModerationQueue({ limit: 1 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]!.id).toBe("post-1"); + expect(result.nextCursor).toBe("post:post-1"); + }); + + it("filters where: { isModerated: true } on both tables", async () => { + prismaMock.communityPost.findMany.mockResolvedValueOnce([]); + prismaMock.communityReply.findMany.mockResolvedValueOnce([]); + + await listModerationQueue(); + + expect(prismaMock.communityPost.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isModerated: true }, + }), + ); + expect(prismaMock.communityReply.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isModerated: true }, + }), + ); + }); +}); + +// ─── dismissModerationItem ─────────────────────────────────────────────────── + +describe("dismissModerationItem — flag clear", () => { + it("sets isModerated=false on post + writes audit-log dismiss", async () => { + prismaMock.communityPost.findUnique.mockResolvedValueOnce({ + id: "post-1", + content: "original content", + }); + prismaMock.communityPost.update.mockResolvedValueOnce({}); + prismaMock.moderationAction.create.mockResolvedValueOnce({}); + + const result = await dismissModerationItem("post", "post-1", "admin-1"); + + expect(result).toEqual({ ok: true }); + expect(prismaMock.communityPost.update).toHaveBeenCalledWith({ + where: { id: "post-1" }, + data: { isModerated: false, reportedAt: null }, + }); + expect(prismaMock.moderationAction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + targetType: "post", + targetId: "post-1", + action: "dismiss", + adminUserId: "admin-1", + contentSnapshot: "original content", + }), + }); + }); + + it("404 when target post not found", async () => { + prismaMock.communityPost.findUnique.mockResolvedValueOnce(null); + + await expect( + dismissModerationItem("post", "nonexistent", null), + ).rejects.toMatchObject({ statusCode: 404 }); + + expect(prismaMock.communityPost.update).not.toHaveBeenCalled(); + expect(prismaMock.moderationAction.create).not.toHaveBeenCalled(); + }); + + it("dispatches to communityReply when type=comment", async () => { + prismaMock.communityReply.findUnique.mockResolvedValueOnce({ + id: "reply-1", + content: "bad reply", + }); + prismaMock.communityReply.update.mockResolvedValueOnce({}); + prismaMock.moderationAction.create.mockResolvedValueOnce({}); + + await dismissModerationItem("comment", "reply-1", null); + + expect(prismaMock.communityReply.update).toHaveBeenCalledWith({ + where: { id: "reply-1" }, + data: { isModerated: false, reportedAt: null }, + }); + expect(prismaMock.communityPost.update).not.toHaveBeenCalled(); + }); +}); + +// ─── deleteModerationItem ──────────────────────────────────────────────────── + +describe("deleteModerationItem — soft-delete with audit-snapshot", () => { + it("scrubs content + sets isDeleted=true + persists original in audit-log", async () => { + prismaMock.communityPost.findUnique.mockResolvedValueOnce({ + id: "post-1", + content: "this is the original toxic content", + }); + prismaMock.communityPost.update.mockResolvedValueOnce({}); + prismaMock.moderationAction.create.mockResolvedValueOnce({}); + + await deleteModerationItem("post", "post-1", "admin-1", "Hass-Rede"); + + expect(prismaMock.communityPost.update).toHaveBeenCalledWith({ + where: { id: "post-1" }, + data: expect.objectContaining({ + content: "", + isDeleted: true, + deletedAt: expect.any(Date), + }), + }); + expect(prismaMock.moderationAction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + targetType: "post", + targetId: "post-1", + action: "delete", + adminUserId: "admin-1", + contentSnapshot: "this is the original toxic content", + reason: "Hass-Rede", + }), + }); + }); + + it("404 when target not found", async () => { + prismaMock.communityPost.findUnique.mockResolvedValueOnce(null); + + await expect( + deleteModerationItem("post", "nonexistent", null), + ).rejects.toMatchObject({ statusCode: 404 }); + }); +}); + +// ─── banUserFromModerationItem ─────────────────────────────────────────────── + +describe("banUserFromModerationItem — sets Profile.banned + audit-log", () => { + it("patches Profile.banned=true, writes audit-log ban_user", async () => { + prismaMock.communityPost.findUnique.mockResolvedValueOnce({ + id: "post-1", + content: "violating content", + userId: "user-99", + }); + prismaMock.profile.update.mockResolvedValueOnce({}); + prismaMock.moderationAction.create.mockResolvedValueOnce({}); + + const result = await banUserFromModerationItem( + "post", + "post-1", + "admin-1", + "wiederholter Verstoß", + ); + + expect(result).toEqual({ ok: true, bannedUserId: "user-99" }); + expect(prismaMock.profile.update).toHaveBeenCalledWith({ + where: { id: "user-99" }, + data: expect.objectContaining({ + banned: true, + bannedAt: expect.any(Date), + bannedReason: "wiederholter Verstoß", + }), + }); + expect(prismaMock.moderationAction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "ban_user", + contentSnapshot: "violating content", + reason: "wiederholter Verstoß", + }), + }); + }); + + it("uses default ban-reason when none provided", async () => { + prismaMock.communityReply.findUnique.mockResolvedValueOnce({ + id: "reply-1", + content: "bad", + userId: "user-99", + }); + prismaMock.profile.update.mockResolvedValueOnce({}); + prismaMock.moderationAction.create.mockResolvedValueOnce({}); + + await banUserFromModerationItem("comment", "reply-1", null, null); + + expect(prismaMock.profile.update).toHaveBeenCalledWith({ + where: { id: "user-99" }, + data: expect.objectContaining({ + banned: true, + bannedReason: "Moderation: comment reply-1", + }), + }); + }); + + it("404 when target item not found", async () => { + prismaMock.communityPost.findUnique.mockResolvedValueOnce(null); + + await expect( + banUserFromModerationItem("post", "nonexistent", null), + ).rejects.toMatchObject({ statusCode: 404 }); + + expect(prismaMock.profile.update).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/admin/users.test.ts b/backend/tests/admin/users.test.ts new file mode 100644 index 0000000..f82b2d2 --- /dev/null +++ b/backend/tests/admin/users.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for admin users management — db/adminUsers + endpoints. + * + * Covers: + * - listAdminUsers: pagination cursor + plan-filter + search + * - updateAdminUser: plan-validation + ban-stamping + voice + * - softDeleteAdminUser: PII-scrubbing + idempotency + * - GET endpoint: 401 ohne admin-secret + * - PATCH endpoint: 401 ohne admin-secret + happy path + * - DELETE endpoint: 401 + happy path + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +// Snapshot der globalen Nitro-Stubs (siehe tests/setup.ts) damit wir nach +// Endpoint-Tests die Originale wiederherstellen können — sonst leakt +// `getHeader`-mock auf andere Test-Files (singleFork-Pool). +const g = globalThis as Record; +const originalStubs = { + getHeader: g.getHeader, + getQuery: g.getQuery, + getRouterParam: g.getRouterParam, + readBody: g.readBody, + useRuntimeConfig: g.useRuntimeConfig, +}; + +// ─── Prisma mock ───────────────────────────────────────────────────────────── + +const prismaMock = vi.hoisted(() => ({ + profile: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock("../../server/utils/prisma", () => ({ + usePrisma: () => prismaMock, +})); + +import { + listAdminUsers, + updateAdminUser, + softDeleteAdminUser, +} from "../../server/db/adminUsers"; + +beforeEach(() => { + vi.clearAllMocks(); + // useRuntimeConfig stub gibt adminSecret für endpoint-tests + g.useRuntimeConfig = vi.fn(() => ({ + adminSecret: "test-secret", + public: { supabase: { url: "", key: "" } }, + })); +}); + +afterEach(() => { + // Globale Nitro-Stubs zurücksetzen — sonst leakt getHeader-mock auf + // andere Test-Files (singleFork-Pool teilt sich Modul-Globals). + for (const [k, v] of Object.entries(originalStubs)) { + g[k] = v; + } +}); + +// ─── listAdminUsers ────────────────────────────────────────────────────────── + +describe("listAdminUsers — pagination + nextCursor", () => { + it("returns nextCursor when more rows exist (limit+1 fetched)", async () => { + // Simuliere 3 rows bei limit=2 → over-fetch ist 3, also nextCursor = items[1].id + const fakeRows = [ + makeRow("aaa", { plan: "free" }), + makeRow("bbb", { plan: "pro" }), + makeRow("ccc", { plan: "free" }), + ]; + prismaMock.profile.findMany.mockResolvedValueOnce(fakeRows); + + const result = await listAdminUsers({ limit: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.items.map((r) => r.id)).toEqual(["aaa", "bbb"]); + expect(result.nextCursor).toBe("bbb"); + expect(prismaMock.profile.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 3, // limit + 1 + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); + + it("nextCursor is null when no more rows", async () => { + prismaMock.profile.findMany.mockResolvedValueOnce([ + makeRow("only", { plan: "legend" }), + ]); + const result = await listAdminUsers({ limit: 50 }); + expect(result.nextCursor).toBeNull(); + expect(result.items).toHaveLength(1); + }); + + it("applies plan-filter + search-term to where-clause", async () => { + prismaMock.profile.findMany.mockResolvedValueOnce([]); + await listAdminUsers({ plan: "pro", q: "Chahine" }); + const callArgs = prismaMock.profile.findMany.mock.calls[0]![0]!; + expect(callArgs.where.plan).toBe("pro"); + expect(callArgs.where.OR).toEqual([ + { nickname: { contains: "Chahine", mode: "insensitive" } }, + { username: { contains: "Chahine", mode: "insensitive" } }, + ]); + }); +}); + +// ─── updateAdminUser ───────────────────────────────────────────────────────── + +describe("updateAdminUser — validates plan + stamps bannedAt", () => { + it("rejects unknown plan-values with 400", async () => { + await expect( + updateAdminUser("user-id", { plan: "enterprise" }), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(prismaMock.profile.update).not.toHaveBeenCalled(); + }); + + it("stamps bannedAt when banned=true and clears reason on un-ban", async () => { + prismaMock.profile.update.mockResolvedValueOnce( + makeRow("u1", { banned: true, bannedAt: new Date() }), + ); + await updateAdminUser("u1", { banned: true }); + const dataArg = prismaMock.profile.update.mock.calls[0]![0]!.data; + expect(dataArg.banned).toBe(true); + expect(dataArg.bannedAt).toBeInstanceOf(Date); + + prismaMock.profile.update.mockResolvedValueOnce( + makeRow("u1", { banned: false, bannedAt: null }), + ); + await updateAdminUser("u1", { banned: false }); + const dataArg2 = prismaMock.profile.update.mock.calls[1]![0]!.data; + expect(dataArg2.banned).toBe(false); + expect(dataArg2.bannedAt).toBeNull(); + expect(dataArg2.bannedReason).toBeNull(); + }); + + it("rejects empty patch (no allowed fields → 400)", async () => { + await expect(updateAdminUser("user-id", {})).rejects.toMatchObject({ + statusCode: 400, + }); + }); +}); + +// ─── softDeleteAdminUser ───────────────────────────────────────────────────── + +describe("softDeleteAdminUser — DSGVO PII-scrub + idempotent", () => { + it("scrubs PII fields and stamps deletedAt", async () => { + prismaMock.profile.findUnique.mockResolvedValueOnce({ deletedAt: null }); + prismaMock.profile.update.mockResolvedValueOnce({}); + + const result = await softDeleteAdminUser( + "128df360-2008-4d6f-8aa1-bdb41ec1362f", + ); + + expect(result).toEqual({ ok: true, alreadyDeleted: false }); + const updateCall = prismaMock.profile.update.mock.calls[0]![0]!; + expect(updateCall.where.id).toBe("128df360-2008-4d6f-8aa1-bdb41ec1362f"); + expect(updateCall.data.nickname).toBeNull(); + expect(updateCall.data.avatar).toBeNull(); + expect(updateCall.data.username).toMatch(/^deleted-[a-f0-9]{8}$/); + expect(updateCall.data.birthYear).toBeNull(); + expect(updateCall.data.gender).toBeNull(); + expect(updateCall.data.bundesland).toBeNull(); + expect(updateCall.data.stripeCustomerId).toBeNull(); + expect(updateCall.data.deletedAt).toBeInstanceOf(Date); + }); + + it("is idempotent — returns alreadyDeleted=true on re-run", async () => { + prismaMock.profile.findUnique.mockResolvedValueOnce({ + deletedAt: new Date("2026-01-01"), + }); + const result = await softDeleteAdminUser("user-id"); + expect(result).toEqual({ ok: true, alreadyDeleted: true }); + expect(prismaMock.profile.update).not.toHaveBeenCalled(); + }); + + it("throws 404 if user not found", async () => { + prismaMock.profile.findUnique.mockResolvedValueOnce(null); + await expect(softDeleteAdminUser("ghost")).rejects.toMatchObject({ + statusCode: 404, + }); + }); +}); + +// ─── Endpoints — Auth-Guard ────────────────────────────────────────────────── + +describe("GET /api/admin/users — 401 ohne admin-secret", () => { + it("rejects request without x-admin-secret header", async () => { + (globalThis as Record).getHeader = vi.fn(() => undefined); + (globalThis as Record).getQuery = vi.fn(() => ({})); + + const mod = await import("../../server/api/admin/users/index.get"); + const handler = mod.default as (e: unknown) => Promise; + + await expect(handler({})).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +describe("PATCH /api/admin/users/[id] — 401 ohne admin-secret", () => { + it("rejects request with wrong secret", async () => { + (globalThis as Record).getHeader = vi.fn( + () => "wrong-secret", + ); + (globalThis as Record).getRouterParam = vi.fn( + () => "user-id", + ); + (globalThis as Record).readBody = vi.fn(async () => ({ + banned: true, + })); + + const mod = await import("../../server/api/admin/users/[id].patch"); + const handler = mod.default as (e: unknown) => Promise; + await expect(handler({})).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +describe("DELETE /api/admin/users/[id] — 401 ohne admin-secret", () => { + it("rejects request without secret", async () => { + (globalThis as Record).getHeader = vi.fn(() => undefined); + (globalThis as Record).getRouterParam = vi.fn( + () => "user-id", + ); + + const mod = await import("../../server/api/admin/users/[id].delete"); + const handler = mod.default as (e: unknown) => Promise; + await expect(handler({})).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeRow(id: string, overrides: Partial> = {}) { + return { + id, + nickname: `nick-${id}`, + username: `user-${id}`, + avatar: null, + plan: "free", + streak: 0, + banned: false, + bannedAt: null, + deletedAt: null, + createdAt: new Date(), + lyraVoiceId: null, + premiumUntil: null, + proTrialExpiresAt: null, + ...overrides, + }; +}