This reverts commit ecbda3f. The deployment goes to a Linux server instead,
so the two accommodations no longer earn their place: output: "standalone"
returns to unconditional, which is what the Dockerfile wants, and
/api/import goes back to 120 seconds — the free-tier ceiling that forced 60
does not apply outside a serverless platform, and a large import benefits
from the headroom.
The Vercel section in DEPLOYMENT.md goes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
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<string, number>;
|
|
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 }
|
|
);
|
|
}
|
|
}
|