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
31af9898c3
commit
056726a166
@ -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)) ──
|
||||
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.
|
||||
|
||||
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