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:
parent
c9029b8fb5
commit
29c5d9c8e5
@ -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");
|
||||||
@ -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");
|
||||||
@ -55,9 +55,19 @@ model Profile {
|
|||||||
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
||||||
lastInstallAt DateTime? @map("last_install_at")
|
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[]
|
communityPosts CommunityPost[]
|
||||||
communityReplies CommunityReply[]
|
communityReplies CommunityReply[]
|
||||||
|
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@index([plan])
|
||||||
@@map("profiles")
|
@@map("profiles")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
@ -133,7 +143,16 @@ model CommunityPost {
|
|||||||
commentsCount Int @default(0) @map("comments_count")
|
commentsCount Int @default(0) @map("comments_count")
|
||||||
repostsCount Int @default(0) @map("reposts_count")
|
repostsCount Int @default(0) @map("reposts_count")
|
||||||
isAnonymous Boolean @default(false) @map("is_anonymous")
|
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")
|
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
|
repostOfId String? @map("repost_of_id") @db.Uuid
|
||||||
challengeId String? @map("challenge_id") @db.Uuid
|
challengeId String? @map("challenge_id") @db.Uuid
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@ -144,6 +163,7 @@ model CommunityPost {
|
|||||||
PostLike PostLike[]
|
PostLike PostLike[]
|
||||||
CommunityReply CommunityReply[]
|
CommunityReply CommunityReply[]
|
||||||
|
|
||||||
|
@@index([isModerated, reportedAt])
|
||||||
@@map("community_posts")
|
@@map("community_posts")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
@ -168,12 +188,18 @@ model CommunityReply {
|
|||||||
parentReplyId String? @map("parent_reply_id") @db.Uuid
|
parentReplyId String? @map("parent_reply_id") @db.Uuid
|
||||||
isAnonymous Boolean @default(false) @map("is_anonymous")
|
isAnonymous Boolean @default(false) @map("is_anonymous")
|
||||||
likesCount Int @default(0) @map("likes_count")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade)
|
post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
author Profile? @relation(fields: [userId], references: [id])
|
author Profile? @relation(fields: [userId], references: [id])
|
||||||
CommentLike CommentLike[]
|
CommentLike CommentLike[]
|
||||||
|
|
||||||
|
@@index([isModerated, reportedAt])
|
||||||
@@map("community_replies")
|
@@map("community_replies")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
@ -627,6 +653,31 @@ model AdminUser {
|
|||||||
@@schema("rebreak")
|
@@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).
|
// 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
|
// Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird
|
||||||
// bei jedem authentifizierten Request via x-device-id Header geprüft.
|
// bei jedem authentifizierten Request via x-device-id Header geprüft.
|
||||||
|
|||||||
35
backend/server/api/admin/moderation/[id]/ban-user.post.ts
Normal file
35
backend/server/api/admin/moderation/[id]/ban-user.post.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
34
backend/server/api/admin/moderation/[id]/delete.post.ts
Normal file
34
backend/server/api/admin/moderation/[id]/delete.post.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
27
backend/server/api/admin/moderation/[id]/dismiss.post.ts
Normal file
27
backend/server/api/admin/moderation/[id]/dismiss.post.ts
Normal 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);
|
||||||
|
});
|
||||||
36
backend/server/api/admin/moderation/queue.get.ts
Normal file
36
backend/server/api/admin/moderation/queue.get.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
37
backend/server/api/admin/users/[id].delete.ts
Normal file
37
backend/server/api/admin/users/[id].delete.ts
Normal 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;
|
||||||
|
});
|
||||||
62
backend/server/api/admin/users/[id].patch.ts
Normal file
62
backend/server/api/admin/users/[id].patch.ts
Normal 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;
|
||||||
|
});
|
||||||
43
backend/server/api/admin/users/index.get.ts
Normal file
43
backend/server/api/admin/users/index.get.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
223
backend/server/db/adminUsers.ts
Normal file
223
backend/server/db/adminUsers.ts
Normal 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 };
|
||||||
|
}
|
||||||
341
backend/server/db/moderation.ts
Normal file
341
backend/server/db/moderation.ts
Normal 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 };
|
||||||
|
}
|
||||||
314
backend/tests/admin/moderation.test.ts
Normal file
314
backend/tests/admin/moderation.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
250
backend/tests/admin/users.test.ts
Normal file
250
backend/tests/admin/users.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user