Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
+168
View File
@@ -0,0 +1,168 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { hatRecht } from "@/lib/authz";
import { hochladen, objektSchluessel } from "@/lib/storage";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
/**
* Datei-Upload.
*
* Eigener Endpunkt statt einer tRPC-Prozedur, aus demselben Grund wie beim
* Etikettenbogen: Dateien sind keine JSON-Daten. Über tRPC müssten sie
* Base64-kodiert durch die Leitung — ein Drittel mehr Bytes, alles im
* Arbeitsspeicher, und die Fortschrittsanzeige des Browsers wäre weg.
*
* Verwendet für die Fotos im Prüfwizard, die Unterschrift aus dem Zeichenfeld
* und das Fremdprotokoll einer Fachfirma.
*
* Die Datei geht ausschließlich über lib/storage.ts nach MinIO — Projektregel.
* Was hier entsteht, ist der Datei-Datensatz mit der Kennung, die der Aufrufer
* anschließend an Prüfergebnis, Signatur oder Mangel hängt.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Was hochgeladen werden darf. Alles andere hat in einem Protokoll nichts verloren. */
const ERLAUBTE_TYPEN = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"application/pdf",
]);
/** 20 MB. Ein Handyfoto liegt darunter, ein Fremdprotokoll erst recht. */
const MAX_BYTES = 20 * 1024 * 1024;
const felder = z.object({
/** Woran die Datei hängt: "Pruefung", "Mangel", "Signatur". */
entitaet: z.string().trim().min(1).max(80),
entitaetId: z.string().uuid().optional(),
/** Einordnung: "foto", "signatur", "fremdprotokoll". */
kategorie: z.string().trim().max(60).default("foto"),
});
export async function POST(anfrage: Request) {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "datei.hochladen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung zum Hochladen." }, { status: 403 });
}
const formular = await anfrage.formData().catch(() => null);
if (!formular) {
return NextResponse.json(
{ fehler: "Die Anfrage enthält kein Formular mit einer Datei." },
{ status: 400 },
);
}
const datei = formular.get("datei");
if (!(datei instanceof File)) {
return NextResponse.json({ fehler: "Es wurde keine Datei übergeben." }, { status: 400 });
}
const geprueft = felder.safeParse({
entitaet: formular.get("entitaet") ?? "",
entitaetId: formular.get("entitaetId") || undefined,
kategorie: formular.get("kategorie") || undefined,
});
if (!geprueft.success) {
return NextResponse.json(
{ fehler: "Es fehlt die Angabe, wozu die Datei gehört." },
{ status: 400 },
);
}
const mimeTyp = datei.type || "application/octet-stream";
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
return NextResponse.json(
{
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder und PDF.`,
},
{ status: 415 },
);
}
if (datei.size > MAX_BYTES) {
return NextResponse.json(
{ fehler: `Die Datei ist zu groß (${Math.round(datei.size / 1024 / 1024)} MB, erlaubt sind 20 MB).` },
{ status: 413 },
);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
try {
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { slug: true },
}),
);
const schluessel = objektSchluessel({
organisationSlug: organisation?.slug ?? sitzung.organisationId,
entitaet: geprueft.data.entitaet,
entitaetId: geprueft.data.entitaetId,
dateiname: datei.name || "datei",
});
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
kategorie: geprueft.data.kategorie,
hochgeladenvon: sitzung.personId,
});
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
const angelegt = await db.datei.create({
data: {
organisationId: sitzung.organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname: datei.name || "datei",
mimeTyp,
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: geprueft.data.entitaet,
entitaetId: geprueft.data.entitaetId ?? null,
kategorie: geprueft.data.kategorie,
hochgeladenVonId: sitzung.personId,
},
});
await protokolliereIn(db, {
organisationId: sitzung.organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: geprueft.data.entitaet,
entitaetId: geprueft.data.entitaetId ?? null,
beschreibung: `Datei "${angelegt.dateiname}" hochgeladen (${geprueft.data.kategorie})`,
sitzung,
nachher: { dateiId: angelegt.id, mimeTyp, groesseBytes: abgelegt.groesseBytes },
});
return angelegt;
});
return NextResponse.json({
id: datensatz.id,
dateiname: datensatz.dateiname,
mimeTyp: datensatz.mimeTyp,
groesseBytes: abgelegt.groesseBytes,
});
} catch (fehler) {
console.error("[dateien] Upload fehlgeschlagen:", fehler);
return NextResponse.json(
{
fehler:
"Die Datei ließ sich nicht ablegen. Läuft der MinIO-Container? (docker compose ps)",
},
{ status: 502 },
);
}
}