Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper, Berichtsassistent und Belegerfassung — alles gegen selbst betriebene Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was Menschen getan haben. Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal. Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion. Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen. Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und faster-whisper stehen im Compose-Profil "ki". 73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|