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>
224 lines
6.5 KiB
TypeScript
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 };
|
|
}
|