Initial commit
This commit is contained in:
239
src/app/api/import/csv/route.ts
Normal file
239
src/app/api/import/csv/route.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createAuditLog } from "@/lib/audit-log";
|
||||
import { parseCsv } from "@/lib/backup-csv";
|
||||
import { canManageUsers } 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;
|
||||
}
|
||||
|
||||
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 oder Finanz-AG 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 auswaehlen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const content = await uploadedFile.text();
|
||||
const rows = parseCsv(content);
|
||||
|
||||
if (rows.length < 2) {
|
||||
return NextResponse.json({ error: "Die CSV-Datei enthaelt 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 vollstaendig eingespielt werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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} enthaelt kein gueltiges 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) {
|
||||
await tx.user.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
name: row.userName,
|
||||
username: row.username,
|
||||
email: toNullable(row.email),
|
||||
passwordHash: row.passwordHash,
|
||||
role: row.role as "ADMIN" | "FINANCE" | "MEMBER",
|
||||
approvalPreference: toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null,
|
||||
workingGroupId: toNullable(row.workingGroupId),
|
||||
createdAt: toDate(row.createdAt) ?? new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of budgetRows) {
|
||||
const totalBudget = toNumber(row.totalBudget);
|
||||
|
||||
if (totalBudget === null) {
|
||||
throw new Error(`Budget ${row.budgetName || row.id} enthaelt keinen gueltigen Betrag.`);
|
||||
}
|
||||
|
||||
await tx.budget.create({
|
||||
data: {
|
||||
id: row.id,
|
||||
name: row.budgetName,
|
||||
totalBudget,
|
||||
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} enthaelt keinen gueltigen 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",
|
||||
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} enthaelt keinen gueltigen 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user