feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+227
View File
@@ -0,0 +1,227 @@
import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
/**
* Notnagel: den bereits im PDF eingebetteten Text lesen.
*
* Der Regelweg ist Tesseract. Fehlt auf dem System auch `pdftoppm`, um das
* PDF in Bilder zu wandeln, bleibt nur dieser Weg - und er reicht
* ueberraschend oft: Bescheide von Behoerden und Rechnungen kommen heute
* meist als "digitales" PDF mit echtem Text, nicht als Scan.
*
* Es ist bewusst ein einfacher Extraktor, kein PDF-Renderer:
* unkomprimierte und Flate-komprimierte Inhaltsstroeme werden ausgepackt
* und die Text-Operatoren (Tj, TJ, ', ") daraus gelesen. Was er nicht kann,
* meldet er als "kein Text" - dann bleibt der ocrStatus eben
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
*
* Rein, ohne Seiteneffekte, ohne Netz.
*/
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
function literalEntpacken(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const zeichen = roh[i]!
if (zeichen !== '\\') {
ergebnis += zeichen
continue
}
const naechstes = roh[++i]
if (naechstes === undefined) break
switch (naechstes) {
case 'n':
ergebnis += '\n'
break
case 'r':
ergebnis += '\n'
break
case 't':
ergebnis += '\t'
break
case 'b':
case 'f':
ergebnis += ' '
break
case '\n':
break // Zeilenumbruch im Quelltext, kein Zeichen
default:
if (naechstes >= '0' && naechstes <= '7') {
let oktal = naechstes
while (oktal.length < 3 && roh[i + 1] !== undefined && roh[i + 1]! >= '0' && roh[i + 1]! <= '7') {
oktal += roh[++i]!
}
ergebnis += String.fromCharCode(Number.parseInt(oktal, 8))
} else {
ergebnis += naechstes
}
}
}
return ergebnis
}
/**
* PDF-Bytes in Zeichen wandeln. Ohne die Font-Tabelle des Dokuments ist
* das eine Naeherung; fuer WinAnsi (der Normalfall bei deutschen Texten)
* stimmt sie, inklusive Umlauten.
*/
const WINANSI_SONDER: Record<number, string> = {
0x80: '€',
0x82: '‚',
0x84: '„',
0x85: '…',
0x91: '‘',
0x92: '’',
0x93: '“',
0x94: '”',
0x95: '•',
0x96: '–',
0x97: '—',
0x9c: 'œ',
0x9f: 'Ÿ',
}
function bytesZuText(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const code = roh.charCodeAt(i)
ergebnis += WINANSI_SONDER[code] ?? String.fromCharCode(code)
}
return ergebnis
}
/** Hex-String <48616C6C6F> */
function hexEntpacken(roh: string): string {
const sauber = roh.replace(/[^0-9a-fA-F]/g, '')
let ergebnis = ''
for (let i = 0; i + 1 < sauber.length; i += 2) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(i, i + 2), 16))
}
// Ungerade Laenge: das letzte Nibble wird laut Spezifikation mit 0 aufgefuellt.
if (sauber.length % 2 === 1) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(-1) + '0', 16))
}
return ergebnis
}
/** Holt alle Streams aus dem PDF und packt sie aus, soweit moeglich. */
function stroemeAuspacken(pdf: Buffer): string[] {
const roh = pdf.toString('latin1')
const stroeme: string[] = []
let suchAb = 0
while (stroeme.length < 2000) {
const beginn = roh.indexOf('stream', suchAb)
if (beginn < 0) break
let inhaltVon = beginn + 'stream'.length
if (roh[inhaltVon] === '\r') inhaltVon++
if (roh[inhaltVon] === '\n') inhaltVon++
const ende = roh.indexOf('endstream', inhaltVon)
if (ende < 0) break
suchAb = ende + 'endstream'.length
const daten = Buffer.from(roh.slice(inhaltVon, ende), 'latin1')
if (daten.length === 0) continue
// Nur der Kopf des Objekts direkt davor sagt, wie komprimiert wurde.
const kopf = roh.slice(Math.max(0, beginn - 500), beginn)
if (/\/Filter\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode|RunLengthDecode)/.test(kopf)) {
continue // Bilddaten - hier ist kein Text zu holen
}
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
continue
}
if (!/\/Filter/.test(kopf)) {
stroeme.push(daten.toString('latin1'))
continue
}
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
}
return stroeme
}
function flateVersuchen(daten: Buffer): Buffer | null {
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
try {
return fn(daten)
} catch {
// naechster Versuch
}
}
return null
}
/** Liest die Text-Operatoren aus einem ausgepackten Inhaltsstrom. */
function textAusStrom(strom: string): string {
if (!/\bTj\b|\bTJ\b|\bTd\b|\bTf\b/.test(strom)) return ''
let ergebnis = ''
const muster = /\((?:\\.|[^\\()])*\)|<[0-9a-fA-F\s]*>|\bTJ\b|\bTj\b|\bT\*\b|\bTd\b|\bTD\b|\bET\b|'|"/g
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(strom)) !== null) {
const wert = treffer[0]!
if (wert.startsWith('(')) {
ergebnis += bytesZuText(literalEntpacken(wert.slice(1, -1)))
} else if (wert.startsWith('<')) {
ergebnis += bytesZuText(hexEntpacken(wert.slice(1, -1)))
} else if (wert === 'T*' || wert === 'TD' || wert === 'Td' || wert === 'ET' || wert === "'" || wert === '"') {
ergebnis += '\n'
} else if (wert === 'TJ' || wert === 'Tj') {
ergebnis += ' '
}
}
return ergebnis
}
/**
* Zieht den eingebetteten Text aus einem PDF. Gibt eine leere Zeichenkette
* zurueck, wenn nichts zu holen ist (reiner Scan, verschluesseltes PDF,
* exotische Kodierung) - nie einen Fehler.
*/
export function pdfTextLesen(pdf: Buffer): string {
try {
const teile = stroemeAuspacken(pdf).map(textAusStrom).filter((t) => t.trim().length > 0)
return textAufraeumen(teile.join('\n'))
} catch {
return ''
}
}
/** Vereinheitlicht Leerraum, ohne Zeilenstruktur zu zerstoeren. */
export function textAufraeumen(roh: string): string {
return roh
.replace(/\r\n?/g, '\n')
// Nullbytes, weiche Trennstriche und Zero-Width-Zeichen entfernen.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u00ad\u200b-\u200d\ufeff]/g, '')
.split('\n')
.map((zeile) => zeile.replace(/[ \t\u00a0]+/g, ' ').trim())
.filter((zeile, i, alle) => zeile !== '' || (alle[i - 1] ?? '') !== '')
.join('\n')
.trim()
}
/**
* Grobe Seitenzahl aus der PDF-Struktur. Nutzt zuerst /Count aus dem
* Seitenbaum, sonst die Zahl der /Type /Page-Objekte.
*/
export function pdfSeitenzahlSchaetzen(pdf: Buffer): number | null {
const roh = pdf.toString('latin1')
const zaehler = [...roh.matchAll(/\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/g)].map((t) =>
Number.parseInt(t[1]!, 10),
)
if (zaehler.length > 0) return Math.max(...zaehler)
const seiten = roh.match(/\/Type\s*\/Page[^s]/g)
return seiten ? seiten.length : null
}