import { NextResponse } from "next/server"; import { createAuditLog } from "@/lib/audit-log"; import { parseCsv } from "@/lib/backup-csv"; import { canManageUsers, DEFAULT_APPROVAL_THRESHOLD, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; function toNullable(value: string | undefined) { return value && value.length > 0 ? value : null; } function toDate(value: string | undefined) { if (!value) { return null; } const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function toNumber(value: string | undefined) { if (!value || value.length === 0) { return null; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function toRole(value: string | undefined): "BOARD" | "ORGA" | "FINANCE" | "MEMBER" { switch (value) { case "ADMIN": case "BOARD": return "BOARD"; case "ORGA": return "ORGA"; case "FINANCE": return "FINANCE"; case "MEMBER": default: return "MEMBER"; } } function toApprovalPermissions( value: string | undefined, role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER", approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null ) { const explicitPermissions = value ? value .split("|") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) as ("CHAIR_A" | "CHAIR_B" | "FINANCE")[] : []; return normalizeApprovalPermissions(role, explicitPermissions, approvalPreference); } export async function POST(request: Request) { const viewer = await getCurrentViewer(); if (!viewer) { return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); } if (!canManageUsers(viewer.role)) { return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Backups einspielen." }, { status: 403 }); } const formData = await request.formData().catch(() => null); const uploadedFile = formData?.get("file"); if (!(uploadedFile instanceof File)) { return NextResponse.json({ error: "Bitte eine CSV-Datei auswählen." }, { status: 400 }); } const content = await uploadedFile.text(); const rows = parseCsv(content); if (rows.length < 2) { return NextResponse.json({ error: "Die CSV-Datei enthält keine Daten." }, { status: 400 }); } const headers = rows[0]; const rawEntries = rows .slice(1) .filter((row) => row.some((cell) => cell.trim().length > 0)) .map((row) => { const entry = Object.fromEntries(headers.map((header, index) => [header, row[index] ?? ""])); return entry as Record; }); const userRows = rawEntries.filter((entry) => entry.recordType === "user"); if (userRows.some((entry) => !entry.passwordHash)) { return NextResponse.json( { error: "Dieses Backup stammt aus einem alten Format ohne Passwort-Hashes und kann nicht vollständig eingespielt werden." }, { status: 400 } ); } const settingsRows = rawEntries.filter((entry) => entry.recordType === "settings"); const periodRows = rawEntries.filter((entry) => entry.recordType === "period"); const groupRows = rawEntries.filter((entry) => entry.recordType === "workingGroup"); const budgetRows = rawEntries.filter((entry) => entry.recordType === "budget"); const expenseRows = rawEntries.filter((entry) => entry.recordType === "expense"); const approvalRows = rawEntries.filter((entry) => entry.recordType === "approval"); const auditRows = rawEntries.filter((entry) => entry.recordType === "auditLog"); try { await prisma.$transaction(async (tx) => { await tx.approval.deleteMany(); await tx.expense.deleteMany(); await tx.budget.deleteMany(); await tx.auditLog.deleteMany(); await tx.user.deleteMany(); await tx.workingGroup.deleteMany(); await tx.accountingPeriod.deleteMany(); await tx.appSettings.deleteMany(); const settingsRow = settingsRows[0]; await tx.appSettings.create({ data: { id: settingsRow?.id || "global", approvalThreshold: toNumber(settingsRow?.approvalThreshold) ?? DEFAULT_APPROVAL_THRESHOLD, createdAt: toDate(settingsRow?.createdAt) ?? new Date() } }); for (const row of periodRows) { const startsAt = toDate(row.periodStartsAt); const endsAt = toDate(row.periodEndsAt); if (!startsAt || !endsAt) { throw new Error(`Zeitraum ${row.periodName || row.id} enthält kein gültiges Datum.`); } await tx.accountingPeriod.create({ data: { id: row.id, name: row.periodName, startsAt, endsAt, isCurrent: row.periodIsCurrent === "true", createdAt: toDate(row.createdAt) ?? new Date() } }); } for (const row of groupRows) { await tx.workingGroup.create({ data: { id: row.id, name: row.workingGroupName, createdAt: toDate(row.createdAt) ?? new Date() } }); } for (const row of userRows) { const role = toRole(row.role); const approvalPreference = toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null; const approvalPermissions = toApprovalPermissions(row.approvalPermissions, role, approvalPreference); await tx.user.create({ data: { id: row.id, name: row.userName, username: row.username, email: toNullable(row.email), passwordHash: row.passwordHash, role, approvalPreference: getLegacyApprovalPreference(approvalPermissions), approvalPermissions, workingGroupId: toNullable(row.workingGroupId), createdAt: toDate(row.createdAt) ?? new Date() } }); } for (const row of budgetRows) { const totalBudget = toNumber(row.totalBudget); const releasedAmount = toNumber(row.releasedAmount) ?? 0; if (totalBudget === null) { throw new Error(`Budget ${row.budgetName || row.id} enth\u00e4lt keinen g\u00fcltigen Betrag.`); } if (releasedAmount > totalBudget) { throw new Error(`Budget ${row.budgetName || row.id} enth\u00e4lt eine zu hohe zus\u00e4tzliche Mittel\u00fcbergabe.`); } await tx.budget.create({ data: { id: row.id, name: row.budgetName, totalBudget, releasedAmount, colorCode: row.colorCode, workingGroupId: row.workingGroupId, periodId: row.periodId, createdAt: toDate(row.createdAt) ?? new Date() } }); } for (const row of expenseRows) { const amount = toNumber(row.amount); if (amount === null) { throw new Error(`Ausgabe ${row.title || row.id} enthält keinen gültigen Betrag.`); } await tx.expense.create({ data: { id: row.id, title: row.title, description: toNullable(row.description), amount, creatorId: row.userId, agId: row.workingGroupId, budgetId: row.budgetId, periodId: row.periodId, approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING", recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE", recurrenceStartAt: toDate(row.recurrenceStartAt), invoiceDate: toDate(row.invoiceDate), proofUrl: toNullable(row.proofUrl), createdAt: toDate(row.createdAt) ?? new Date(), paidAt: toDate(row.paidAt), documentedAt: toDate(row.documentedAt) } }); } for (const row of approvalRows) { const timestamp = toDate(row.createdAt); if (!timestamp) { throw new Error(`Freigabe ${row.id} enthält keinen gültigen Zeitstempel.`); } await tx.approval.create({ data: { id: row.id, expenseId: row.parentId, userId: row.userId, approvalType: row.approvalType as "CHAIR_A" | "CHAIR_B" | "FINANCE", timestamp } }); } for (const row of auditRows) { await tx.auditLog.create({ data: { id: row.id, actorId: toNullable(row.auditActorId), action: row.auditAction, entityType: row.auditEntityType, entityId: toNullable(row.auditEntityId), entityLabel: toNullable(row.auditEntityLabel), summary: row.auditSummary, metadata: row.auditMetadata ? JSON.parse(row.auditMetadata) : null, createdAt: toDate(row.createdAt) ?? new Date() } }); } }); await createAuditLog(prisma, { actorId: userRows.some((row) => row.id === viewer.id) ? viewer.id : null, action: "backup.import", entityType: "system", entityLabel: uploadedFile.name, summary: `CSV-Backup ${uploadedFile.name} wurde eingespielt.`, metadata: { fileName: uploadedFile.name, rowCount: rawEntries.length } }); return NextResponse.json({ ok: true, importedRows: rawEntries.length }); } catch (error) { const message = error instanceof Error ? error.message : "Das Backup konnte nicht eingespielt werden."; return NextResponse.json({ error: message }, { status: 400 }); } }