import { NextResponse, type NextRequest } from "next/server"; import { requireHrUser } from "@/lib/auth/require-hr"; import { withUser } from "@/lib/db"; import { bestandLaden, laden, type Ladebericht } from "@/lib/import/load"; import { dateiLesen, type ImportSheet } from "@/lib/import/parse"; import { pruefe, type Befund } from "@/lib/import/validate"; // Massenimport — Prüflauf und Übernahme über denselben Weg. // // Es gibt bewusst **keinen** Zwischenspeicher zwischen beiden Schritten. Die // Oberfläche schickt die Datei zweimal: einmal mit `pruefen=1`, um den // Bericht zu zeigen, und nach der Bestätigung noch einmal zum Übernehmen. // Das kostet eine Übertragung und erspart serverseitigen Zustand, der // ablaufen, vollaufen oder zwischen zwei Personen verwechselt werden kann. // // Beide Läufe sehen denselben Bestand, weil Prüfung und Schreiben in // derselben Transaktion stattfinden. Zwischen „geprüft" und „geschrieben" // passt sonst eine fremde Änderung — etwa jemand, der dieselbe Planstelle // besetzt. /** Bricht die Transaktion ab, ohne einen Fehler zu sein. */ class Rueckabwicklung extends Error { constructor(readonly nutzlast: unknown) { super("Prüflauf"); } } export const maxDuration = 120; type Antwort = { ok: boolean; geprueft: boolean; blaetter: string[]; fehler: Befund[]; hinweise: Befund[]; anzahl: Record; bericht?: Ladebericht; meldung?: string; }; export async function POST(request: NextRequest) { const gate = await requireHrUser(); if ("denied" in gate) return gate.denied; const form = await request.formData(); const nurPruefen = form.get("pruefen") === "1"; const dateien = form.getAll("datei").filter((f): f is File => f instanceof File); if (dateien.length === 0) { return NextResponse.json({ ok: false, meldung: "Keine Datei erhalten." }, { status: 400 }); } // Mehrere Dateien werden zusammengesetzt: eine Mappe mit allen Blättern // oder eine CSV je Blatt sind derselbe Vorgang. const blaetter: ImportSheet[] = []; const lesefehler: string[] = []; for (const datei of dateien) { const ergebnis = await dateiLesen(datei.name, await datei.arrayBuffer()); blaetter.push(...ergebnis.blaetter); lesefehler.push(...ergebnis.fehler); } if (lesefehler.length > 0) { return NextResponse.json( { ok: false, geprueft: true, blaetter: blaetter.map((b) => b.name), fehler: lesefehler.map((m) => ({ blatt: "Datei", zeile: null, spalte: null, meldung: m })), hinweise: [], anzahl: {}, } satisfies Antwort, { status: 422 } ); } const profil = await withUser(gate.userId, (tx) => tx.selectFrom("profiles").select(["full_name", "email"]).where("id", "=", gate.userId).executeTakeFirst() ); try { const antwort = await withUser(gate.userId, async (tx) => { const bestand = await bestandLaden(tx); const geprueft = pruefe(blaetter, bestand); const basis: Antwort = { ok: geprueft.fehler.length === 0, geprueft: true, blaetter: blaetter.map((b) => b.name), fehler: geprueft.fehler, hinweise: geprueft.hinweise, anzahl: geprueft.anzahl, }; // Fehler oder Prüflauf: die Transaktion wird zurückgerollt. Beim // Prüflauf hat sie trotzdem echte Abfragen gemacht — der Bericht // beruht also auf dem tatsächlichen Bestand, nicht auf einer Kopie. if (!basis.ok || nurPruefen) throw new Rueckabwicklung({ ...basis, geprueft: nurPruefen || !basis.ok }); const bericht = await laden(tx, geprueft.datensatz, bestand, { userId: gate.userId, name: profil?.full_name || profil?.email || "Unbekannt", }); return { ...basis, geprueft: false, bericht }; }); return NextResponse.json(antwort); } catch (err) { if (err instanceof Rueckabwicklung) { const nutzlast = err.nutzlast as Antwort; return NextResponse.json(nutzlast, { status: nutzlast.ok ? 200 : 422 }); } // Ein echter Fehler beim Schreiben. Die Transaktion ist zurückgerollt, // es steht also nichts Halbes in der Datenbank. return NextResponse.json( { ok: false, geprueft: false, blaetter: blaetter.map((b) => b.name), fehler: [], hinweise: [], anzahl: {}, meldung: err instanceof Error ? `Der Import wurde vollständig zurückgenommen. Grund: ${err.message}` : "Der Import wurde vollständig zurückgenommen.", } satisfies Antwort, { status: 500 } ); } }