feat(anhaenge): Fotos und Dateien an einem Schritt (#52) (#59)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #59.
This commit is contained in:
2026-10-05 20:49:26 +00:00
parent 855164066c
commit 3ccf3aa2d9
25 changed files with 1989 additions and 47 deletions
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { gewaehlteDatei, groesseText, maxGroesseBytes } from "@/lib/anhaenge";
import { MandantFehler } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { LaufFehler } from "@/server/services/laeufe";
import { AnhangFehler, anhangEntfernen, anhangSpeichern } from "@/server/services/anhaenge";
/**
* Server Actions der Anhaenge (#032).
*
* Recht ist `laeufeBearbeiten` — wer einen Schritt abhaken darf, darf auch ein
* Foto dazu legen. Der Archiv-Schreibschutz sitzt im Dienst, an derselben
* Stelle wie fuer alles andere am Lauf.
*
* Die Datei kommt als `File` im Formular. Server Actions nehmen das von sich
* aus; ein eigener Upload-Endpunkt waere eine zweite Tuer mit eigener
* Rechtepruefung.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG = "Dafür fehlt dir die Berechtigung.";
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof AnhangFehler || fehler instanceof LaufFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof MandantFehler) return { fehler: fehler.message };
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
console.error(
`${neutral}:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
);
return { fehler: neutral };
}
export async function anhangHochladenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("laeufeBearbeiten");
const eingabe = z.object({ laufId: z.string().min(1), vorlagenSchrittId: z.string().min(1) }).safeParse({
laufId: formular.get("laufId"),
vorlagenSchrittId: formular.get("vorlagenSchrittId"),
});
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
// `getAll`, nicht `get`: das Formular hat zwei Felder `datei` – siehe
// `gewaehlteDatei`.
const datei = gewaehlteDatei(formular.getAll("datei"));
if (!datei) {
return { fehler: "Keine Datei gewählt." };
}
/*
* Die Groesse **vor** dem Lesen pruefen. `arrayBuffer()` zieht die ganze
* Datei in den Speicher; bei einer 500-MB-Datei waere die Pruefung danach zu
* spaet. Der Dienst prueft es noch einmal — er ist auch ohne diese Action
* erreichbar.
*/
const grenze = maxGroesseBytes();
if (datei.size > grenze) {
return {
fehler:
`Die Datei ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
`${groesseText(grenze)} je Datei.`,
};
}
await anhangSpeichern(
{
laufId: eingabe.data.laufId,
vorlagenSchrittId: eingabe.data.vorlagenSchrittId,
dateiname: datei.name,
medientyp: datei.type,
inhalt: new Uint8Array(await datei.arrayBuffer()),
},
{ id: benutzer.id, rolle: benutzer.rolle },
);
revalidatePath(`/laeufe/${eingabe.data.laufId}`);
return { erfolg: "Angehängt." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Anhang konnte nicht gespeichert werden.");
}
}
export async function anhangEntfernenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("laeufeBearbeiten");
const eingabe = z
.object({ anhangId: z.string().min(1), laufId: z.string().min(1) })
.safeParse({ anhangId: formular.get("anhangId"), laufId: formular.get("laufId") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
await anhangEntfernen(eingabe.data.anhangId, { id: benutzer.id, rolle: benutzer.rolle });
revalidatePath(`/laeufe/${eingabe.data.laufId}`);
return { erfolg: "Entfernt." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Anhang konnte nicht entfernt werden.");
}
}