chahinebrini 056726a166 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>
2026-05-09 15:48:35 +02:00

224 lines
6.5 KiB
TypeScript

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