Files
Laufzettel/src/server/services/anhaenge.ts
T
florian.wilde 3ccf3aa2d9
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(anhaenge): Fotos und Dateien an einem Schritt (#52) (#59)
2026-10-05 20:49:26 +00:00

263 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };