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:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user