It was never used. The repository lives on a self-hosted Gitea, which Vercel's git integration cannot connect to at all — so the documented route amounted to "mirror to GitHub first", and nobody did. vercel.json is gone, and with it the branch in next.config.ts that switched off `output: "standalone"` when the VERCEL variable was present. That branch was the only functional trace; everything else was documentation and comments describing a second deployment path that did not exist. DEPLOYMENT.md loses its "two supported ways" framing and the whole Vercel section — about fifty lines. Several statements next to it were stale for a different reason and are corrected in the same pass: the outbound-firewall table still listed Supabase's pooler (the database is a container now, nothing leaves the server), the prerequisites still demanded an existing Supabase project, and the .env table still asked for a pooler connection string instead of the two new passwords. The nightly job is described as what it is — a container in docker-compose.yml — rather than as a replacement for Vercel Cron. Migrations keep their references: two comments from July mention Vercel Cron, and they describe what was true when they were written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
4.8 KiB
TypeScript
136 lines
4.8 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");
|
|
}
|
|
}
|
|
|
|
// Im eigenen Container wirkt diese Angabe nicht — sie richtet sich an
|
|
// serverlose Plattformen, die einen Aufruf nach Ablauf abschneiden. Sie bleibt
|
|
// als Absichtserklärung stehen: ein Import, der länger als eine Minute
|
|
// braucht, ist einer, der in Teilen laufen sollte.
|
|
export const maxDuration = 60;
|
|
|
|
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 }
|
|
);
|
|
}
|
|
}
|