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", // Seit Phase 8: Diktataufnahmen. Welches Format der Browser liefert, hängt // von ihm ab — Chrome nimmt webm/opus auf, Safari mp4/aac. Beide Wege // müssen offen sein, sonst kann die Hälfte der Wehr nicht diktieren. "audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg", "audio/wav", "audio/x-m4a", ]); /** 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, PDF und Tonaufnahmen.`, }, { 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 }, ); } }