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>
This commit is contained in:
chahinebrini 2026-05-09 15:46:44 +02:00
parent 31af9898c3
commit 056726a166
14 changed files with 1534 additions and 0 deletions

View File

@ -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");

View File

@ -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");

View File

@ -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.

View File

@ -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,
);
});

View File

@ -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,
);
});

View File

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

View File

@ -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,
});
});

View File

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

View File

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

View File

@ -0,0 +1,43 @@
import { listAdminUsers } from "../../../db/adminUsers";
/**
* GET /api/admin/users Admin-User-Liste (cursor-paginated, search, plan-filter)
*
* Query-Params:
* ?cursor=<id> pagination cursor (id from previous nextCursor)
* ?limit=50 max 100
* ?q=<search> 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",
});
});

View File

@ -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<string, unknown> = {};
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<AdminUserRow> {
const db = usePrisma();
const data: Record<string, unknown> = {};
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 };
}

View File

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

View File

@ -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();
});
});

View File

@ -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<string, unknown>;
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<string, unknown>).getHeader = vi.fn(() => undefined);
(globalThis as Record<string, unknown>).getQuery = vi.fn(() => ({}));
const mod = await import("../../server/api/admin/users/index.get");
const handler = mod.default as (e: unknown) => Promise<unknown>;
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<string, unknown>).getHeader = vi.fn(
() => "wrong-secret",
);
(globalThis as Record<string, unknown>).getRouterParam = vi.fn(
() => "user-id",
);
(globalThis as Record<string, unknown>).readBody = vi.fn(async () => ({
banned: true,
}));
const mod = await import("../../server/api/admin/users/[id].patch");
const handler = mod.default as (e: unknown) => Promise<unknown>;
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<string, unknown>).getHeader = vi.fn(() => undefined);
(globalThis as Record<string, unknown>).getRouterParam = vi.fn(
() => "user-id",
);
const mod = await import("../../server/api/admin/users/[id].delete");
const handler = mod.default as (e: unknown) => Promise<unknown>;
await expect(handler({})).rejects.toMatchObject({ statusCode: 401 });
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeRow(id: string, overrides: Partial<Record<string, unknown>> = {}) {
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,
};
}