feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen vom Browser. Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes selbst: der Route Handler ruft rechtErforderlich() selbst, weil die Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber eine Server Action statt ueber einen zweiten Endpunkt; der Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt. In der Datenbank halten zwei CHECK-Bedingungen und zwei zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht an einem Schritt eines anderen Laufs oder bei einem fremden Kunden haengen. docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach der alten Anleitung haette ab hier still Daten verloren. Sicherung und Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich. Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder "datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei" waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts laedt bewusst ueber den zweiten Knopf hoch. Closes #52 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
855164066c
commit
3cbd5026f8
@@ -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