294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
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<string, string>;
|
|
});
|
|
|
|
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 });
|
|
}
|
|
}
|