feat: Dokumentenablage, OCR, Volltextsuche und Belegmappe
Etappe 3. Dazu die geteilte Infrastruktur, auf der die Berichte der
folgenden Etappen aufsetzen: Speichertreiber und PDF-Grundgeruest.
Dateiablage hinter einer Schnittstelle, zwei Treiber - lokales
Verzeichnis fuer die Entwicklung, S3-kompatibel fuer den Betrieb. Der
Schluessel wird erzeugt, nie aus dem Dateinamen abgeleitet; ein
Pfadausbruch ueber den Namen ist damit ausgeschlossen.
Der Dateityp wird ausschliesslich an den Magic Bytes geprueft, und ein
PDF muss bei Offset 0 stehen - damit fallen auch Polyglot-Dateien durch,
die gleichzeitig gueltiges PDF und gueltiges HTML sind. Ausgeliefert
wird nur ueber kurzlebige signierte URLs, immer als Anhang und mit
nosniff. Die Route prueft Signatur, Anmeldung UND Zugriff auf die
betreuteId aus dem Schluessel; jeder Abruf steht im Audit-Log.
Beim Dateitreiber liegt die Datei zusaetzlich AES-256-GCM-verschluesselt.
Beim S3-Treiber geht das nicht, weil signierte URLs am Server vorbei
ausgeliefert werden - das ist in der Datei dokumentiert, nicht
stillschweigend anders gemacht.
@react-pdf/renderer konnte in diesem Abhaengigkeitsbaum keinen einzigen
Buchstaben setzen: die Unterabhaengigkeiten sind weitergewandert und
liefern fuer Standardschriften kein Schriftobjekt mehr.
src/lib/pdf/standardschrift.ts baut es aus den Metriken, die pdfkit
ohnehin mitbringt - kein neues Paket, kein Netz - und haengt sich nur
ein, wenn die Aufloesung tatsaechlich kaputt ist.
Fristerkennung im OCR-Text ("innerhalb von vier Wochen", "binnen eines
Monats") wird immer nur VORGESCHLAGEN, nie automatisch angelegt. Die
Textstelle steht daneben, damit man nachprueft, bevor man uebernimmt.
Gerechnet wird auf lokaler Mittagszeit: in UTC verschoebe die Sommerzeit
"vier Wochen ab 12.03." um einen Tag.
57 Tests.
Etappe: 3
Phase: Papier
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# Schema-Wünsche aus Etappe 3 „Papier“
|
||||
|
||||
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
|
||||
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
|
||||
funktioniert, wäre aber sauberer, wenn es im Modell stünde.
|
||||
|
||||
Reihenfolge: was am meisten weh tut, steht oben.
|
||||
|
||||
---
|
||||
|
||||
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
|
||||
|
||||
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
|
||||
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
|
||||
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
|
||||
Prüfung in `dokumentVerknuepfen`).
|
||||
|
||||
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
|
||||
Seed oder ein späterer Import das nicht verletzen kann:
|
||||
|
||||
```sql
|
||||
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
|
||||
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
|
||||
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
|
||||
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
|
||||
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int
|
||||
+ ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int
|
||||
+ ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1
|
||||
);
|
||||
```
|
||||
|
||||
Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das
|
||||
Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung
|
||||
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
|
||||
erklärbar.
|
||||
|
||||
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
|
||||
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
|
||||
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
|
||||
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
|
||||
WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
|
||||
|
||||
---
|
||||
|
||||
## 2. `Dokument.ocrStatus` ist ein freier String
|
||||
|
||||
**Stand:** `ocrStatus String?` mit dem Kommentar
|
||||
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
|
||||
|
||||
**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in
|
||||
`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche
|
||||
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
|
||||
heute erst auf, wenn das Abzeichen leer bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kein Index auf `Dokument.objektSchluessel`
|
||||
|
||||
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
|
||||
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
|
||||
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
|
||||
|
||||
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
|
||||
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Keine Dublettenerkennung über die Prüfsumme
|
||||
|
||||
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
|
||||
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
|
||||
|
||||
**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload
|
||||
sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den
|
||||
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
|
||||
|
||||
---
|
||||
|
||||
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
|
||||
|
||||
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
|
||||
Verknüpfen muss deshalb über die Beziehung gehen
|
||||
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
|
||||
|
||||
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
|
||||
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
|
||||
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
|
||||
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
|
||||
passieren.
|
||||
|
||||
---
|
||||
|
||||
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
|
||||
|
||||
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
|
||||
OCR-Lauf zuletzt versucht wurde, steht nirgends.
|
||||
|
||||
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
|
||||
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
|
||||
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
|
||||
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
|
||||
bei jedem Nachziehen erneut durch Tesseract zu schicken.
|
||||
|
||||
---
|
||||
|
||||
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
|
||||
|
||||
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
|
||||
festgehalten gehört. Bei `Dokument.ocrText` steht:
|
||||
|
||||
> „die Datei selbst liegt verschlüsselt im Objektspeicher“
|
||||
|
||||
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
|
||||
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
|
||||
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
|
||||
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
|
||||
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
|
||||
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
|
||||
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
|
||||
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
|
||||
|
||||
Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet —
|
||||
sonst wäre sie als Nachweis gegenüber dem Gericht wertlos.
|
||||
|
||||
Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()`
|
||||
würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt,
|
||||
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
|
||||
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
|
||||
berührt.
|
||||
|
||||
---
|
||||
|
||||
# Anhang: kein Schema-Wunsch, aber dringend
|
||||
|
||||
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
|
||||
|
||||
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
|
||||
also auch die Berichte aus Etappe 5.
|
||||
|
||||
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
|
||||
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
|
||||
at @react-pdf/textkit/lib/textkit.js:3090
|
||||
```
|
||||
|
||||
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
|
||||
schuld: in reinem Node reproduziert es genauso.
|
||||
|
||||
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
|
||||
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
|
||||
inzwischen weitergewandert:
|
||||
|
||||
| Paket | installiert | von renderer 4.1.6 erwartet |
|
||||
|---|---|---|
|
||||
| `@react-pdf/font` | 3.1.0 | `^3.0.1` |
|
||||
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
|
||||
| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) |
|
||||
|
||||
`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher
|
||||
löste die Schicht darunter den bloßen Namen selbst auf.
|
||||
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
|
||||
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
|
||||
beiden klafft eine Lücke.
|
||||
|
||||
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
|
||||
Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und
|
||||
hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues
|
||||
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
|
||||
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
|
||||
„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`).
|
||||
|
||||
**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier):
|
||||
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
|
||||
|
||||
```jsonc
|
||||
"overrides": {
|
||||
"@react-pdf/layout": "4.2.x",
|
||||
"@react-pdf/textkit": "5.x"
|
||||
}
|
||||
```
|
||||
|
||||
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
|
||||
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
|
||||
Unterabhängigkeiten wieder zusammenpassen. Danach kann
|
||||
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
|
||||
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
|
||||
Reference in New Issue
Block a user