Files
RFP_Finanzen/src/app/api/import/csv/route.ts
jan 796e134ea2
All checks were successful
CI / Build (push) Successful in 1m59s
CI / Deploy (push) Successful in 2m2s
Rechnungsdatum und Belegupload ueberarbeiten
2026-05-01 16:51:27 +02:00

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