This commit was merged in pull request #59.
This commit is contained in:
@@ -15,10 +15,12 @@ import {
|
||||
LaufMitAdKontoVerknuepfen,
|
||||
LaufZurueckholen,
|
||||
} from "@/components/laeufe/lauf-aktionen";
|
||||
import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
|
||||
import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
||||
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
|
||||
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||
import {
|
||||
kommentareDesLaufs,
|
||||
@@ -82,12 +84,14 @@ function Schritt({
|
||||
lesend,
|
||||
ausfuehrlich,
|
||||
kommentare,
|
||||
anhaenge,
|
||||
}: {
|
||||
schritt: AbarbeitungsSchritt;
|
||||
laufId: string;
|
||||
lesend: boolean;
|
||||
ausfuehrlich: boolean;
|
||||
kommentare: readonly KommentarZeile[];
|
||||
anhaenge: readonly SchrittAnhang[];
|
||||
}) {
|
||||
const vomTisch =
|
||||
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
|
||||
@@ -197,6 +201,22 @@ function Schritt({
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
* Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem
|
||||
* archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild
|
||||
* ist Teil des Belegs.
|
||||
*/}
|
||||
{anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? (
|
||||
<div className="mt-3">
|
||||
<SchrittAnhaenge
|
||||
laufId={laufId}
|
||||
vorlagenSchrittId={schritt.schrittId}
|
||||
anhaenge={anhaenge}
|
||||
lesend={lesend || !schritt.ergebnisId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lesend || !schritt.ergebnisId ? null : (
|
||||
<details className="mt-2">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
|
||||
@@ -220,12 +240,14 @@ function Abschnitt({
|
||||
lesend,
|
||||
ausfuehrlich,
|
||||
kommentare,
|
||||
anhaenge,
|
||||
}: {
|
||||
abschnitt: AbarbeitungsAbschnitt;
|
||||
laufId: string;
|
||||
lesend: boolean;
|
||||
ausfuehrlich: boolean;
|
||||
kommentare: readonly KommentarZeile[];
|
||||
anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
|
||||
}) {
|
||||
const erledigt = abschnitt.schritte.filter(
|
||||
(schritt) =>
|
||||
@@ -263,6 +285,7 @@ function Abschnitt({
|
||||
lesend={lesend}
|
||||
ausfuehrlich={ausfuehrlich}
|
||||
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
|
||||
anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -297,6 +320,9 @@ export default async function LaufSeite({
|
||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
||||
// Einträge dieses Laufs (#023).
|
||||
const laufProtokoll = await protokollDesLaufs(id);
|
||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
||||
// Anhaenge dieses Laufs (#032).
|
||||
const anhaenge = await anhaengeDesLaufs(id);
|
||||
/*
|
||||
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
||||
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
||||
@@ -394,6 +420,7 @@ export default async function LaufSeite({
|
||||
lesend={lesend}
|
||||
ausfuehrlich={ausfuehrlich}
|
||||
kommentare={kommentare}
|
||||
anhaenge={anhaenge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AnhangFehler, anhangLesen } from "@/server/services/anhaenge";
|
||||
import { MandantFehler } from "@/server/mandant";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { istBild } from "@/lib/anhaenge";
|
||||
|
||||
/**
|
||||
* Einen Anhang ausliefern (#032).
|
||||
*
|
||||
* **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx`
|
||||
* deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist, nie was
|
||||
* drinsteht (CLAUDE.md). Hier zählt das doppelt: eine Adresse, die eine Datei
|
||||
* ohne Prüfung ausliefert, umgeht alles andere.
|
||||
*
|
||||
* `Content-Disposition: inline` nur für Bilder. Ein PDF wird **heruntergeladen**
|
||||
* statt im Browser gerendert: ein PDF kann Skripte enthalten, und ein Anhang,
|
||||
* den jemand aus einem fremden Lauf öffnet, soll nichts ausführen.
|
||||
*/
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
let benutzer: { id: string; rolle: Parameters<typeof anhangLesen>[1]["rolle"] };
|
||||
try {
|
||||
const zugang = await rechtErforderlich("lesen");
|
||||
benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) {
|
||||
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
const datei = await anhangLesen(id, benutzer);
|
||||
// Der Dateiname steckt in einer Kopfzeile. `dateinameSaeubern` hat
|
||||
// Steuerzeichen und Anführungszeichen schon beim Anlegen entfernt.
|
||||
const anzeige = istBild(datei.medientyp) ? "inline" : "attachment";
|
||||
return new Response(new Uint8Array(datei.inhalt), {
|
||||
headers: {
|
||||
"Content-Type": datei.medientyp,
|
||||
"Content-Length": String(datei.groesse),
|
||||
"Content-Disposition": `${anzeige}; filename="${datei.dateiname}"`,
|
||||
// Privat: ein Anhang gehört einem Kunden, kein Zwischenspeicher darf ihn
|
||||
// an den nächsten Betrachter weitergeben.
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof MandantFehler) {
|
||||
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
|
||||
// Unterscheidung wäre schon eine Auskunft über fremde Kunden.
|
||||
return Response.json({ fehler: "Diesen Anhang gibt es nicht." }, { status: 404 });
|
||||
}
|
||||
if (fehler instanceof AnhangFehler) {
|
||||
return Response.json({ fehler: fehler.message }, { status: 404 });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
|
||||
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { Wortmarke } from "@/components/wortmarke";
|
||||
import { groesseText, istBild } from "@/lib/anhaenge";
|
||||
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
|
||||
import type { KommentarZeile } from "@/server/queries/laeufe";
|
||||
|
||||
@@ -173,6 +174,51 @@ function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anhaenge auf dem Ausdruck (#032).
|
||||
*
|
||||
* Bilder als Miniatur, ein PDF nur als Zeile — ein eingebettetes PDF liesse sich
|
||||
* auf Papier ohnehin nicht ansehen. Die Datei kommt ueber ihre Adresse, nicht
|
||||
* eingebettet: der Ausdruck entsteht im Browser des Benutzers, und dessen
|
||||
* Sitzung laesst den Abruf ohnehin pruefen.
|
||||
*/
|
||||
function Anhaenge({ anhaenge }: { anhaenge: DruckSchritt["anhaenge"] }) {
|
||||
if (anhaenge.length === 0) return null;
|
||||
const bilder = anhaenge.filter((anhang) => istBild(anhang.medientyp));
|
||||
const dateien = anhaenge.filter((anhang) => !istBild(anhang.medientyp));
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{bilder.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{bilder.map((anhang) => (
|
||||
<li key={anhang.id} className="flex flex-col items-start gap-0.5">
|
||||
{/* Ohne next/image: der Optimierer holte die Datei erneut und ohne
|
||||
Rechteprüfung. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/anhaenge/${anhang.id}`}
|
||||
alt={anhang.dateiname}
|
||||
className="h-24 w-auto rounded border border-linie object-contain"
|
||||
/>
|
||||
<span className="max-w-32 truncate text-[10px] text-nebentext">{anhang.dateiname}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{dateien.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{dateien.map((anhang) => (
|
||||
<li key={anhang.id} className="text-xs text-nebentext">
|
||||
Anhang: {anhang.dateiname} ({groesseText(anhang.groesse)})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
|
||||
return (
|
||||
@@ -204,6 +250,7 @@ function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Anhaenge anhaenge={schritt.anhaenge} />
|
||||
<Kommentare kommentare={schritt.kommentare} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useRef, useState } from "react";
|
||||
import { Camera, FileText, Paperclip, X } from "lucide-react";
|
||||
import { ERLAUBTE_MEDIENTYPEN, groesseText, istBild, maxGroesseBytes } from "@/lib/anhaenge";
|
||||
import {
|
||||
anhangEntfernenAktion,
|
||||
anhangHochladenAktion,
|
||||
type AktionsErgebnis,
|
||||
} from "@/server/actions/anhaenge";
|
||||
|
||||
/**
|
||||
* Anhaenge eines Schritts (#032).
|
||||
*
|
||||
* Zwei Wege zum Anhaengen, weil zwei Geraete gemeint sind: **Foto** oeffnet am
|
||||
* Handy direkt die Kamera (`capture="environment"`), **Datei** den Dateiwaehler.
|
||||
* Am Rechner tun beide dasselbe — dort gibt es keine Kamera-App, und der Browser
|
||||
* zeigt in beiden Faellen den Waehler.
|
||||
*
|
||||
* Abgeschickt wird sofort nach der Wahl, ohne zweiten Knopf: ein Foto, das man
|
||||
* gerade gemacht hat, will man nicht noch bestaetigen. Ein eigener
|
||||
* „Hochladen“-Knopf waere der Schritt, den man vergisst.
|
||||
*/
|
||||
|
||||
export type SchrittAnhang = {
|
||||
readonly id: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
readonly erstelltVon: string | null;
|
||||
};
|
||||
|
||||
export function SchrittAnhaenge({
|
||||
laufId,
|
||||
vorlagenSchrittId,
|
||||
anhaenge,
|
||||
lesend,
|
||||
}: {
|
||||
laufId: string;
|
||||
vorlagenSchrittId: string;
|
||||
anhaenge: readonly SchrittAnhang[];
|
||||
lesend: boolean;
|
||||
}) {
|
||||
const [hochladen, hochladenAktion, laedt] = useActionState<AktionsErgebnis, FormData>(
|
||||
anhangHochladenAktion,
|
||||
{},
|
||||
);
|
||||
const [entfernen, entfernenAktion] = useActionState<AktionsErgebnis, FormData>(anhangEntfernenAktion, {});
|
||||
const formular = useRef<HTMLFormElement>(null);
|
||||
const [zuGross, setzeZuGross] = useState<string | null>(null);
|
||||
|
||||
function gewaehlt(ereignis: React.ChangeEvent<HTMLInputElement>): void {
|
||||
const datei = ereignis.target.files?.[0];
|
||||
if (!datei) return;
|
||||
/*
|
||||
* Die Groesse schon hier pruefen. Sonst wandern bei einem Fehlgriff 200 MB
|
||||
* durch die Leitung, nur damit der Server sie ablehnt — am Handy im
|
||||
* Mobilfunk ist das der Unterschied zwischen einer Meldung und einer
|
||||
* Minute Warten. Dienst und Action pruefen es trotzdem noch einmal.
|
||||
*/
|
||||
if (datei.size > maxGroesseBytes()) {
|
||||
setzeZuGross(
|
||||
`„${datei.name}“ ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
|
||||
`${groesseText(maxGroesseBytes())}.`,
|
||||
);
|
||||
ereignis.target.value = "";
|
||||
return;
|
||||
}
|
||||
setzeZuGross(null);
|
||||
formular.current?.requestSubmit();
|
||||
}
|
||||
|
||||
const meldung = zuGross ?? hochladen.fehler ?? entfernen.fehler;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{anhaenge.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{anhaenge.map((anhang) => (
|
||||
<li key={anhang.id} className="flex flex-col gap-1">
|
||||
<a
|
||||
href={`/api/anhaenge/${anhang.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`${anhang.dateiname} · ${groesseText(anhang.groesse)}${
|
||||
anhang.erstelltVon ? ` · ${anhang.erstelltVon}` : ""
|
||||
}`}
|
||||
className="flex items-center gap-2 rounded-karte border border-linie bg-flaeche-hoch p-1.5"
|
||||
>
|
||||
{istBild(anhang.medientyp) ? (
|
||||
// Das Bild selbst als Miniatur – ohne next/image, weil der
|
||||
// Optimierer die Datei erneut und ohne Rechteprüfung holen
|
||||
// würde.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/anhaenge/${anhang.id}`}
|
||||
alt={anhang.dateiname}
|
||||
className="size-14 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-14 items-center justify-center rounded bg-flaeche">
|
||||
<FileText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 max-w-40 flex-col text-xs">
|
||||
<span className="truncate">{anhang.dateiname}</span>
|
||||
<span className="text-nebentext">{groesseText(anhang.groesse)}</span>
|
||||
</span>
|
||||
</a>
|
||||
{lesend ? null : (
|
||||
<form action={entfernenAktion}>
|
||||
<input type="hidden" name="anhangId" value={anhang.id} />
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={`${anhang.dateiname} entfernen`}
|
||||
className="flex min-h-11 items-center gap-1 text-xs text-nebentext hover:text-fehler md:min-h-8"
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
entfernen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{lesend ? null : (
|
||||
<form ref={formular} action={hochladenAktion} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||
<Camera className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Foto
|
||||
<input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||
capture="environment"
|
||||
onChange={gewaehlt}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||
<Paperclip className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Datei
|
||||
<input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||
onChange={gewaehlt}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{laedt ? <span className="text-sm text-nebentext">wird hochgeladen …</span> : null}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{meldung ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{meldung}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Grenzen und Formen der Anhaenge (#032).
|
||||
*
|
||||
* Framework-frei, weil beide Seiten davon lesen: der Dienst prueft damit, und
|
||||
* die Oberflaeche sagt damit **vorher**, was erlaubt ist. Stuenden die Grenzen
|
||||
* nur im Dienst, waere jede Abweisung eine Ueberraschung.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Was sich anhaengen laesst: Bilder und PDF.
|
||||
*
|
||||
* Bewusst eine Liste und kein Praefix-Vergleich wie `image/*`: ein Browser
|
||||
* schickt auch `image/svg+xml`, und SVG ist ein Dokument mit Skripten, das ein
|
||||
* Betrachter ausfuehrt. Ein Anhang, den jemand oeffnet, darf kein Angriffsweg
|
||||
* sein.
|
||||
*/
|
||||
export const ERLAUBTE_MEDIENTYPEN = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"application/pdf",
|
||||
] as const;
|
||||
|
||||
export type Medientyp = (typeof ERLAUBTE_MEDIENTYPEN)[number];
|
||||
|
||||
/** Die Endung, unter der eine Datei auf der Platte liegt. */
|
||||
export const ENDUNG_JE_TYP: Readonly<Record<Medientyp, string>> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/heic": "heic",
|
||||
"image/heif": "heif",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
export function istErlaubterMedientyp(typ: string): typ is Medientyp {
|
||||
return (ERLAUBTE_MEDIENTYPEN as readonly string[]).includes(typ);
|
||||
}
|
||||
|
||||
/** Bilder werden als Miniatur gezeigt, ein PDF nur als Zeile. */
|
||||
export function istBild(medientyp: string): boolean {
|
||||
return medientyp !== "application/pdf" && istErlaubterMedientyp(medientyp);
|
||||
}
|
||||
|
||||
const STANDARD_MAX_MB = 10;
|
||||
const STANDARD_JE_LAUF = 20;
|
||||
|
||||
/**
|
||||
* Hoechstgroesse je Datei in Bytes.
|
||||
*
|
||||
* Ein Handyfoto hat heute 3–6 MB. Zehn als Vorgabe lassen Luft, ohne dass eine
|
||||
* einzelne Datei eine Sicherung sprengt. Keine serverseitige Verkleinerung:
|
||||
* sie verliert Metadaten und zieht eine Bildbibliothek ins Projekt — eine
|
||||
* ehrliche Obergrenze ist weniger Magie.
|
||||
*/
|
||||
export function maxGroesseBytes(): number {
|
||||
const roh = Number.parseInt(process.env["ANHANG_MAX_MB"]?.trim() ?? "", 10);
|
||||
const mb = Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_MB;
|
||||
return mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Hoechstzahl Anhaenge je Lauf. */
|
||||
export function maxJeLauf(): number {
|
||||
const roh = Number.parseInt(process.env["ANHAENGE_JE_LAUF"]?.trim() ?? "", 10);
|
||||
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_JE_LAUF;
|
||||
}
|
||||
|
||||
/** Wo die Dateien liegen. Ohne Angabe ein Verzeichnis im Arbeitsbaum. */
|
||||
export function anhaengePfad(): string {
|
||||
return process.env["ANHAENGE_PFAD"]?.trim() || "./daten/anhaenge";
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eine gewaehlte Datei aus mehreren Formularfeldern desselben Namens.
|
||||
*
|
||||
* Der Schritt hat **zwei** Felder `datei` in einem Formular: „Foto“ mit
|
||||
* `capture="environment"` und „Datei“ mit dem Dateiwaehler. Abgeschickt wird
|
||||
* immer das ganze Formular, also kommen beide Felder mit — das leere als
|
||||
* `File` mit `size === 0`. `FormData.get("datei")` liefert das **erste**, und
|
||||
* das war in #032 das leere: wer „Datei“ nutzte, bekam „Keine Datei gewählt“,
|
||||
* waehrend „Foto“ ging. Gefunden erst in der Klickprobe am gebauten Stand, weil
|
||||
* kein Dienst-Test zwei Felder schickt.
|
||||
*
|
||||
* Darum hier die Regel, und zwar als reine Funktion: das **erste nicht leere**
|
||||
* Feld gewinnt, unabhaengig von der Reihenfolge im Formular.
|
||||
*/
|
||||
export function gewaehlteDatei(eintraege: readonly unknown[]): File | undefined {
|
||||
return eintraege.find((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0);
|
||||
}
|
||||
|
||||
/** `4,2 MB` – fuer Meldungen und die Anzeige. */
|
||||
export function groesseText(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Dateiname, der sich anzeigen laesst.
|
||||
*
|
||||
* Der Name vom Browser wird **nie** zum Pfad — der entsteht aus der `id`.
|
||||
* Trotzdem wird er beschnitten und von Steuerzeichen befreit: er landet in einer
|
||||
* Antwortkopfzeile und in der Oberflaeche.
|
||||
*/
|
||||
export function dateinameSaeubern(roh: string): string {
|
||||
const ohnePfad = roh.split(/[\\/]/).pop() ?? "";
|
||||
const sauber = ohnePfad.replace(/[\u0000-\u001f\u007f"]/g, "").trim();
|
||||
return (sauber || "anhang").slice(0, 200);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
||||
@@ -87,6 +88,13 @@ export type DruckSchritt = {
|
||||
readonly bearbeitetVon: string | null;
|
||||
readonly bearbeitetAm: Date | null;
|
||||
readonly kommentare: readonly KommentarZeile[];
|
||||
/** Anhaenge an diesem Schritt (#032). Bilder als Miniatur, PDF als Zeile. */
|
||||
readonly anhaenge: readonly {
|
||||
readonly id: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DruckAbschnitt = {
|
||||
@@ -216,6 +224,9 @@ export async function laufZumDrucken(
|
||||
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
|
||||
// kann.
|
||||
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
||||
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
|
||||
// nicht in sich (#032).
|
||||
const alleAnhaenge = await anhaengeDesLaufs(laufId);
|
||||
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
|
||||
|
||||
let nummer = 0;
|
||||
@@ -264,6 +275,14 @@ export async function laufZumDrucken(
|
||||
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
|
||||
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
|
||||
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
|
||||
anhaenge: alleAnhaenge
|
||||
.filter((anhang) => anhang.schrittergebnisId === ergebnis?.id)
|
||||
.map((anhang) => ({
|
||||
id: anhang.id,
|
||||
dateiname: anhang.dateiname,
|
||||
medientyp: anhang.medientyp,
|
||||
groesse: anhang.groesse,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
ENDUNG_JE_TYP,
|
||||
anhaengePfad,
|
||||
dateinameSaeubern,
|
||||
groesseText,
|
||||
istErlaubterMedientyp,
|
||||
maxGroesseBytes,
|
||||
maxJeLauf,
|
||||
type Medientyp,
|
||||
} from "@/lib/anhaenge";
|
||||
import { laufZumSchreibenHolen } from "@/server/services/laeufe";
|
||||
import { kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Anhaenge und Fotos an Schritten (#032).
|
||||
*
|
||||
* Die Datei liegt im Dateisystem, die Metadaten in der Datenbank. Der Pfad
|
||||
* entsteht **aus der Kennung**, nie aus dem Namen vom Browser — ein Dateiname
|
||||
* von aussen darf keinen Pfad bestimmen.
|
||||
*
|
||||
* Der Archiv-Schreibschutz gilt auch hier: `laufZumSchreibenHolen` sperrt die
|
||||
* Laufzeile, prueft die Mandantengrenze und ruft `laufMussVeraenderbarSein` —
|
||||
* dieselbe eine Stelle wie bei Schrittergebnissen und Kommentaren.
|
||||
*/
|
||||
|
||||
export class AnhangFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "AnhangFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Pfad einer Datei. Nach Kunde und Lauf gegliedert, damit ein Verzeichnis überschaubar bleibt. */
|
||||
function dateiPfad(anhang: {
|
||||
readonly id: string;
|
||||
readonly kundeId: string;
|
||||
readonly laufId: string;
|
||||
readonly medientyp: string;
|
||||
}): string {
|
||||
const endung = istErlaubterMedientyp(anhang.medientyp) ? ENDUNG_JE_TYP[anhang.medientyp] : "bin";
|
||||
return join(anhaengePfad(), anhang.kundeId, anhang.laufId, `${anhang.id}.${endung}`);
|
||||
}
|
||||
|
||||
export type AnhangEingabe = {
|
||||
readonly laufId: string;
|
||||
readonly vorlagenSchrittId: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly inhalt: Uint8Array;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt einen Anhang an einem Schritt ab.
|
||||
*
|
||||
* Reihenfolge mit Absicht: erst alles pruefen, dann die Datei schreiben, dann
|
||||
* die Zeile anlegen. Bricht das Anlegen der Zeile ab, wird die Datei wieder
|
||||
* entfernt — eine Datei ohne Zeile waere Muell, den niemand findet. Umgekehrt
|
||||
* waere es schlimmer: eine Zeile ohne Datei ist ein Anhang, der beim Oeffnen
|
||||
* fehlschlaegt.
|
||||
*/
|
||||
export async function anhangSpeichern(
|
||||
eingabe: AnhangEingabe,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<{ readonly id: string }> {
|
||||
if (!istErlaubterMedientyp(eingabe.medientyp)) {
|
||||
throw new AnhangFehler(
|
||||
`Dateien vom Typ „${eingabe.medientyp || "unbekannt"}“ lassen sich nicht anhängen. ` +
|
||||
"Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF.",
|
||||
);
|
||||
}
|
||||
if (eingabe.inhalt.byteLength === 0) {
|
||||
throw new AnhangFehler("Die Datei ist leer.");
|
||||
}
|
||||
const grenze = maxGroesseBytes();
|
||||
if (eingabe.inhalt.byteLength > grenze) {
|
||||
throw new AnhangFehler(
|
||||
`Die Datei ist ${groesseText(eingabe.inhalt.byteLength)} groß. Erlaubt sind höchstens ` +
|
||||
`${groesseText(grenze)} je Datei.`,
|
||||
);
|
||||
}
|
||||
|
||||
const vorbereitet = await prisma.$transaction(async (tx) => {
|
||||
// Sperrt den Lauf, prueft Mandant **und** Schreibschutz – die eine Stelle.
|
||||
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
|
||||
|
||||
const ergebnis = await tx.schrittergebnis.findUnique({
|
||||
where: {
|
||||
laufId_vorlagenSchrittId: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: eingabe.vorlagenSchrittId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!ergebnis) {
|
||||
throw new AnhangFehler("Diesen Schritt gibt es in diesem Lauf nicht.");
|
||||
}
|
||||
|
||||
const vorhanden = await tx.anhang.count({ where: { laufId: lauf.id } });
|
||||
const hoechstens = maxJeLauf();
|
||||
if (vorhanden >= hoechstens) {
|
||||
throw new AnhangFehler(
|
||||
`An diesem Lauf hängen schon ${vorhanden} Dateien. Mehr als ${hoechstens} je Lauf ` +
|
||||
"nimmt Laufzettel nicht — erst eine entfernen.",
|
||||
);
|
||||
}
|
||||
|
||||
return { laufId: lauf.id, kundeId: lauf.kundeId, schrittergebnisId: ergebnis.id };
|
||||
});
|
||||
|
||||
const pruefsumme = createHash("sha256").update(eingabe.inhalt).digest("hex");
|
||||
const id = `anh_${createHash("sha256")
|
||||
.update(`${vorbereitet.schrittergebnisId}:${pruefsumme}:${Date.now()}:${Math.random()}`)
|
||||
.digest("hex")
|
||||
.slice(0, 24)}`;
|
||||
const pfad = dateiPfad({
|
||||
id,
|
||||
kundeId: vorbereitet.kundeId,
|
||||
laufId: vorbereitet.laufId,
|
||||
medientyp: eingabe.medientyp,
|
||||
});
|
||||
|
||||
await mkdir(join(pfad, ".."), { recursive: true });
|
||||
await writeFile(pfad, eingabe.inhalt, { flag: "wx" });
|
||||
|
||||
try {
|
||||
await prisma.anhang.create({
|
||||
data: {
|
||||
id,
|
||||
schrittergebnisId: vorbereitet.schrittergebnisId,
|
||||
laufId: vorbereitet.laufId,
|
||||
kundeId: vorbereitet.kundeId,
|
||||
dateiname: dateinameSaeubern(eingabe.dateiname),
|
||||
medientyp: eingabe.medientyp,
|
||||
groesse: eingabe.inhalt.byteLength,
|
||||
pruefsumme,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
// Die Datei wieder weg: eine Datei ohne Zeile findet niemand mehr.
|
||||
await rm(pfad, { force: true });
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Inhalt eines Anhangs holen – mit Recht und Mandantengrenze.
|
||||
*
|
||||
* **Jeder** Abruf prueft beides, auch der des Bildes selbst. Eine Adresse, die
|
||||
* ohne Pruefung ausliefert, waere die Luecke, die alles andere umgeht.
|
||||
*/
|
||||
export async function anhangLesen(
|
||||
anhangId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<{
|
||||
readonly inhalt: Buffer;
|
||||
readonly medientyp: string;
|
||||
readonly dateiname: string;
|
||||
readonly groesse: number;
|
||||
}> {
|
||||
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
|
||||
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
|
||||
await kundeZugriffPruefen(anhang.kundeId, benutzer);
|
||||
|
||||
const pfad = dateiPfad(anhang);
|
||||
let inhalt: Buffer;
|
||||
try {
|
||||
inhalt = await readFile(pfad);
|
||||
} catch {
|
||||
/*
|
||||
* Die Zeile steht, die Datei fehlt. Das ist der Fall nach einer Sicherung,
|
||||
* die nur die Datenbank mitgenommen hat — und deshalb sagt die Meldung das
|
||||
* auch, statt „nicht gefunden“ zu behaupten.
|
||||
*/
|
||||
throw new AnhangFehler(
|
||||
`Die Datei zu „${anhang.dateiname}“ fehlt im Dateisystem. Wurde eine Sicherung ` +
|
||||
"eingespielt, die nur die Datenbank enthielt? Siehe docs/betrieb/sicherung.md.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
inhalt,
|
||||
medientyp: anhang.medientyp,
|
||||
dateiname: anhang.dateiname,
|
||||
groesse: anhang.groesse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Anhang.
|
||||
*
|
||||
* Erst die Zeile, dann die Datei: bleibt die Datei liegen, ist sie unerreichbar
|
||||
* und beim naechsten Aufraeumen zu finden. Bliebe umgekehrt die Zeile stehen,
|
||||
* zeigte die Oberflaeche einen Anhang, der nicht mehr zu oeffnen ist.
|
||||
*/
|
||||
export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenutzer): Promise<void> {
|
||||
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
|
||||
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Sperrt, prueft Mandant und Schreibschutz – an einem archivierten Lauf
|
||||
// kommt kein Anhang weg.
|
||||
await laufZumSchreibenHolen(tx, anhang.laufId, benutzer);
|
||||
await tx.anhang.delete({ where: { id: anhangId } });
|
||||
});
|
||||
|
||||
await rm(dateiPfad(anhang), { force: true });
|
||||
}
|
||||
|
||||
/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */
|
||||
export async function anhaengeDesLaufs(laufId: string): Promise<
|
||||
readonly {
|
||||
readonly id: string;
|
||||
readonly schrittergebnisId: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
readonly erstelltAm: Date;
|
||||
readonly erstelltVon: string | null;
|
||||
}[]
|
||||
> {
|
||||
const zeilen = await prisma.anhang.findMany({
|
||||
where: { laufId },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
schrittergebnisId: true,
|
||||
dateiname: true,
|
||||
medientyp: true,
|
||||
groesse: true,
|
||||
erstelltAm: true,
|
||||
erstelltVon: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
schrittergebnisId: zeile.schrittergebnisId,
|
||||
dateiname: zeile.dateiname,
|
||||
medientyp: zeile.medientyp,
|
||||
groesse: zeile.groesse,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
|
||||
export function anhangPfad(anhang: {
|
||||
readonly id: string;
|
||||
readonly kundeId: string;
|
||||
readonly laufId: string;
|
||||
readonly medientyp: string;
|
||||
}): string {
|
||||
return dateiPfad(anhang);
|
||||
}
|
||||
|
||||
export type { Medientyp };
|
||||
Reference in New Issue
Block a user