import { usePrisma } from "../utils/prisma"; // ───────────────────────────────────────────────────────────────────────────── // Admin-User-Management — DB-layer // // Wird ausschließlich vom Admin-Backend (/api/admin/users/*) verwendet. // Alle Schreib-Operationen sind audit-relevant — TODO(hans-mueller): sobald // `audit_log` table existiert, in jeder Update-/Delete-Funktion einen // Eintrag schreiben (admin-id + target-user + diff + timestamp). // ───────────────────────────────────────────────────────────────────────────── export type ListUsersOpts = { cursor?: string; limit?: number; q?: string; plan?: "free" | "pro" | "legend"; /** Default: false → soft-deleted users werden ausgeblendet. */ includeDeleted?: boolean; }; export type AdminUserRow = { id: string; nickname: string | null; username: string | null; avatar: string | null; plan: string; streak: number; banned: boolean; bannedAt: Date | null; deletedAt: Date | null; createdAt: Date; lyraVoiceId: string | null; premiumUntil: Date | null; proTrialExpiresAt: Date | null; }; const MAX_LIMIT = 100; const DEFAULT_LIMIT = 50; /** * List users with cursor-pagination, optional fuzzy-search on nickname/username. * Returns one extra row to determine `nextCursor` without a separate count. */ export async function listAdminUsers(opts: ListUsersOpts = {}): Promise<{ items: AdminUserRow[]; nextCursor: string | null; }> { const db = usePrisma(); const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); const where: Record = {}; if (!opts.includeDeleted) where.deletedAt = null; if (opts.plan) where.plan = opts.plan; if (opts.q && opts.q.trim().length > 0) { const term = opts.q.trim(); where.OR = [ { nickname: { contains: term, mode: "insensitive" } }, { username: { contains: term, mode: "insensitive" } }, ]; } // Cursor-pagination by `id` desc — stable, no skipped rows on inserts. // We over-fetch by one to detect "has more". const rows = await db.profile.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limit + 1, ...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}), select: { id: true, nickname: true, username: true, avatar: true, plan: true, streak: true, banned: true, bannedAt: true, deletedAt: true, createdAt: true, lyraVoiceId: true, premiumUntil: true, proTrialExpiresAt: true, }, }); const hasMore = rows.length > limit; const items = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? items[items.length - 1]!.id : null; return { items, nextCursor }; } const VALID_PLANS = new Set(["free", "pro", "legend"]); export type UpdateAdminUserPatch = { plan?: string; banned?: boolean; bannedReason?: string | null; lyraVoiceId?: string | null; }; /** * Update plan / ban-status / lyra-voice for a user. Validates plan-enum. * Returns the updated row (admin-projection — no PII beyond what admin already * sees in the list). */ export async function updateAdminUser( userId: string, patch: UpdateAdminUserPatch, ): Promise { const db = usePrisma(); const data: Record = {}; if (patch.plan !== undefined) { const planLc = patch.plan.toLowerCase(); if (!VALID_PLANS.has(planLc)) { throw createError({ statusCode: 400, message: `Ungültiger plan-Wert: ${patch.plan} (erwartet: free|pro|legend)`, }); } data.plan = planLc; } if (patch.banned !== undefined) { data.banned = patch.banned; data.bannedAt = patch.banned ? new Date() : null; if (!patch.banned) data.bannedReason = null; } if (patch.bannedReason !== undefined) { data.bannedReason = patch.bannedReason; } if (patch.lyraVoiceId !== undefined) { data.lyraVoiceId = patch.lyraVoiceId; } if (Object.keys(data).length === 0) { throw createError({ statusCode: 400, message: "Keine änderbaren Felder im Body", }); } return db.profile.update({ where: { id: userId }, data, select: { id: true, nickname: true, username: true, avatar: true, plan: true, streak: true, banned: true, bannedAt: true, deletedAt: true, createdAt: true, lyraVoiceId: true, premiumUntil: true, proTrialExpiresAt: true, }, }); } /** * DSGVO-konformer Soft-Delete (Art. 17 — Recht auf Vergessenwerden). * * Was passiert: * 1. PII wird scrubbed (nickname → null, username → "deleted-{shortid}", * avatar → null, alle demographics → null) * 2. `deletedAt` wird gesetzt → User taucht in normalen Listen nicht mehr auf * 3. auth.users-Eintrag bleibt unangetastet (Hard-Delete via Supabase-Admin * erfolgt in separater Operation, sonst FK-Cascade-Risiko bei Posts/Likes) * * Idempotent — wenn `deletedAt` schon gesetzt ist, wird die Operation übersprungen. */ export async function softDeleteAdminUser(userId: string): Promise<{ ok: boolean; alreadyDeleted: boolean; }> { const db = usePrisma(); const existing = await db.profile.findUnique({ where: { id: userId }, select: { deletedAt: true }, }); if (!existing) { throw createError({ statusCode: 404, message: "User nicht gefunden" }); } if (existing.deletedAt) { return { ok: true, alreadyDeleted: true }; } // Kurzer eindeutiger Suffix für scrubbed-username damit unique-constraints // (falls künftig vorhanden) nicht kollidieren. const shortId = userId.replace(/-/g, "").slice(0, 8); await db.profile.update({ where: { id: userId }, data: { nickname: null, username: `deleted-${shortId}`, avatar: null, // Demographics komplett entfernen birthYear: null, gender: null, maritalStatus: null, profession: null, employmentStatus: null, shiftWork: null, industry: null, jobTenure: null, bundesland: null, city: null, // Stripe-Refs entfernen — Stripe-Customer wird separat gekündigt stripeCustomerId: null, stripeSubId: null, // Soft-Delete-Marker deletedAt: new Date(), }, }); return { ok: true, alreadyDeleted: false }; }