import 'server-only' import { createHash } from 'node:crypto' import type { Readable } from 'node:stream' import archiver from 'archiver' import { db, nichtGeloescht } from '@/lib/db' import { lesen } from '@/lib/speicher' import { oeffnen, VERSCHLUESSELTE_FELDER, type VerschluesseltesModell } from '@/lib/crypto/feld' import { formatDatum, formatDatumZeit } from '@/lib/utils' import { berichtDateiname, exportDokumentPfad, pfadErfordern, sichererDateiname, } from '@/lib/validierung/berichte' /** * Vollexport - alles, was ueber eine betreute Person gespeichert ist, in * einem ZIP. * * Das ist die wichtigste Zusage dieser Anwendung an ihren Nutzer: Deine * Daten gehoeren dir, und du kommst jederzeit ohne mich an sie heran. Der * Export enthaelt die Originaldateien, die Datenbankinhalte im Klartext als * JSON, die erzeugten Berichte, eine Anleitung und Pruefsummen. Wer die * Anwendung morgen abschaltet, kann mit diesem Archiv weiterarbeiten. * * Erzeugt wird als Strom. Ein Bestand aus mehreren hundert eingescannten * Bescheiden waere als Buffer im Speicher ein Problem; hier wandert jede * Datei einzeln durch: lesen, Pruefsumme bilden, ins Archiv schieben, * vergessen. Deshalb wird nach jedem Eintrag auf das Ereignis "entry" * gewartet - das ist der Gegendruck, ohne den der Archivierer schneller * annimmt, als der Browser abnimmt. * * Der Export ist eine sensible Aktion: frische Anmeldung, Rolle BETREUER * oder ADMIN, Audit-Eintrag EXPORTIERT. Das prueft die Server Action, nicht * diese Datei. */ export type VollexportOptionen = { betreuteId: string mitDokumenten: boolean mitAuditLog: boolean mitBerichten: boolean /** Name des Benutzers, der exportiert - steht in der LIESMICH.txt. */ erstelltVon: string erstelltAm?: Date } export type VollexportErgebnis = { dateiname: string /** Node-Lesestrom mit dem ZIP. Der Aufrufer reicht ihn an die Antwort weiter. */ strom: Readable } type Archiv = ReturnType // ----------------------------------------------------------------------------- // Anhaengen mit Gegendruck und Pruefsumme // ----------------------------------------------------------------------------- function sha256(inhalt: Buffer | string): string { return createHash('sha256').update(inhalt).digest('hex') } /** * Haengt einen Eintrag an und wartet, bis er geschrieben ist. Ohne dieses * Warten staut sich der gesamte Bestand im Arbeitsspeicher, sobald die * Gegenstelle langsamer ist als die Festplatte. */ function anhaengen(archiv: Archiv, pfad: string, inhalt: Buffer | string): Promise { pfadErfordern(pfad) return new Promise((erfuellen, ablehnen) => { const aufraeumen = () => { archiv.off('entry', beiEintrag) archiv.off('error', beiFehler) } const beiEintrag = (eintrag: { name?: string }) => { if (eintrag?.name !== pfad) return aufraeumen() erfuellen() } const beiFehler = (fehler: Error) => { aufraeumen() ablehnen(fehler) } archiv.on('entry', beiEintrag) archiv.on('error', beiFehler) archiv.append(inhalt, { name: pfad }) }) } /** JSON-fest machen: BigInt wird Zeichenkette, Date wird ISO-Zeitstempel. */ export function alsJson(wert: unknown): string { return JSON.stringify( wert, (_schluessel, inhalt) => (typeof inhalt === 'bigint' ? inhalt.toString() : inhalt), 2, ) } /** * Entfernt die Umschlagfelder und legt stattdessen den Klartext daneben. * Im Export soll lesbar sein, was in der Anwendung lesbar ist - ein * verschluesselter String nuetzt niemandem, der das Archiv oeffnet. */ export function entschluesseln>( modell: VerschluesseltesModell | null, datensaetze: T[], ): Record[] { if (!modell) return datensaetze as Record[] const felder = VERSCHLUESSELTE_FELDER[modell] as readonly string[] return datensaetze.map((datensatz) => { const geoeffnet = { ...oeffnen(modell, datensatz) } as Record for (const feld of felder) delete geoeffnet[feld] return geoeffnet }) } // ----------------------------------------------------------------------------- // Tabellen // ----------------------------------------------------------------------------- type Tabelle = { name: string; modell: VerschluesseltesModell | null; laden: () => Promise } /** * Alle Tabellen, die zu einer betreuten Person gehoeren. Jede Abfrage ist * auf die betreuteId eingegrenzt - direkt oder ueber die Beziehung, an der * sie haengt. Der Papierkorb wird mit ausgegeben: Was geloescht ist, bleibt * dreissig Tage lang wiederherstellbar, und der Export soll die Lage * abbilden, nicht schoenen. Das Feld "geloeschtAm" sagt, woran man ist. */ function tabellen(betreuteId: string, optionen: VollexportOptionen): Tabelle[] { const liste: Tabelle[] = [ { name: 'betreute', modell: 'Betreute', laden: () => db.betreute.findMany({ where: { id: betreuteId } }), }, { name: 'betreuungszugang', modell: null, laden: () => db.betreuungsZugang.findMany({ where: { betreuteId }, include: { user: { select: { name: true, email: true, rolle: true } } }, }), }, { name: 'vorgang', modell: 'Vorgang', laden: () => db.vorgang.findMany({ where: { betreuteId } }) }, { name: 'aufgabe', modell: null, laden: () => db.aufgabe.findMany({ where: { vorgang: { betreuteId } } }), }, { name: 'genehmigung', modell: 'Genehmigung', laden: () => db.genehmigung.findMany({ where: { betreuteId } }), }, { name: 'frist', modell: null, laden: () => db.frist.findMany({ where: { betreuteId } }) }, { // Eigene Schreibvorlagen gehoeren zur Akte: Sie tragen die im Fall // bewaehrten Formulierungen und waeren beim Umzug sonst verloren. name: 'schreibvorlage', modell: null, laden: () => db.schreibvorlage.findMany({ where: { betreuteId } }), }, { name: 'dokument', modell: 'Dokument', laden: () => db.dokument.findMany({ where: { betreuteId }, // Der Suchvektor ist ein Datenbankinterna und nicht exportierbar. select: { id: true, titel: true, art: true, richtung: true, schreibenVom: true, eingangAm: true, versandAm: true, absender: true, empfaenger: true, aktenzeichen: true, objektSchluessel: true, dateiname: true, mimeTyp: true, groesseBytes: true, pruefsumme: true, seitenzahl: true, ocrText: true, ocrStatus: true, posteingangStatus: true, ablageortPapier: true, wiedervorlageAm: true, ablaufAm: true, notizenVerschluesselt: true, erstelltAm: true, aktualisiertAm: true, geloeschtAm: true, }, }), }, { name: 'dokument_verknuepfung', modell: null, laden: () => db.dokumentVerknuepfung.findMany({ where: { betreuteId } }), }, { name: 'tag', modell: null, laden: () => db.tag.findMany({ where: { betreuteId } }) }, { name: 'konto', modell: 'Konto', laden: () => db.konto.findMany({ where: { betreuteId } }) }, { name: 'saldostand', modell: null, laden: () => db.saldostand.findMany({ where: { konto: { betreuteId } } }), }, { name: 'buchung', modell: 'Buchung', laden: () => db.buchung.findMany({ where: { betreuteId } }) }, { name: 'vermoegensposten', modell: 'Vermoegensposten', laden: () => db.vermoegensposten.findMany({ where: { betreuteId } }), }, { name: 'wertstand', modell: null, laden: () => db.wertstand.findMany({ where: { vermoegensposten: { betreuteId } } }), }, { name: 'immobilie', modell: 'Immobilie', laden: () => db.immobilie.findMany({ where: { betreuteId } }), }, { name: 'belastung', modell: null, laden: () => db.belastung.findMany({ where: { immobilie: { betreuteId } } }), }, { name: 'immobilien_checkpunkt', modell: null, laden: () => db.immobilienCheckpunkt.findMany({ where: { immobilie: { betreuteId } } }), }, { name: 'vertrag', modell: 'Vertrag', laden: () => db.vertrag.findMany({ where: { betreuteId } }) }, { name: 'kontakt', modell: 'Kontakt', laden: () => db.kontakt.findMany({ where: { betreuteId } }) }, { name: 'kontaktprotokoll', modell: 'Kontaktprotokoll', laden: () => db.kontaktprotokoll.findMany({ where: { betreuteId } }), }, { name: 'gesundheitsereignis', modell: 'Gesundheitsereignis', laden: () => db.gesundheitsereignis.findMany({ where: { betreuteId } }), }, { name: 'heimvertrag', modell: 'Heimvertrag', laden: () => db.heimvertrag.findMany({ where: { betreuteId } }), }, { name: 'heimkostenmonat', modell: null, laden: () => db.heimkostenmonat.findMany({ where: { betreuteId } }), }, { name: 'sozialhilfe_antrag', modell: 'SozialhilfeAntrag', laden: () => db.sozialhilfeAntrag.findMany({ where: { betreuteId } }), }, { name: 'nachforderung', modell: null, laden: () => db.nachforderung.findMany({ where: { antrag: { betreuteId } } }), }, { name: 'bescheid', modell: 'Bescheid', laden: () => db.bescheid.findMany({ where: { antrag: { betreuteId } } }), }, { name: 'leistung', modell: null, laden: () => db.leistung.findMany({ where: { betreuteId } }) }, { name: 'bericht', modell: 'Bericht', laden: () => db.bericht.findMany({ where: { betreuteId } }) }, ] if (optionen.mitAuditLog) { liste.push({ name: 'audit_log', modell: 'AuditLog', laden: () => db.auditLog.findMany({ where: { betreuteId }, orderBy: { zeitpunkt: 'asc' } }), }) } return liste } // ----------------------------------------------------------------------------- // LIESMICH // ----------------------------------------------------------------------------- export function liesmichText(angaben: { betreuteName: string erstelltVon: string erstelltAm: Date tabellen: string[] anzahlDokumente: number anzahlBerichte: number mitAuditLog: boolean }): string { return [ 'Vollexport aus dem Betreuungs-Cockpit', '=====================================', '', `Betreute Person: ${angaben.betreuteName}`, `Erzeugt am: ${formatDatumZeit(angaben.erstelltAm)}`, `Erzeugt von: ${angaben.erstelltVon}`, '', 'Was ist hier drin?', '------------------', '', 'dokumente//__.', ' Alle hochgeladenen Originaldateien - Bescheide, Kontoauszüge, Rechnungen,', ' Beschlüsse, Fotos. Unverändert so, wie sie eingegangen sind.', ` Enthalten: ${angaben.anzahlDokumente} Dateien.`, '', 'daten/*.json', ' Der komplette Datenbestand als JSON, eine Datei je Tabelle. Verschlüsselte', ' Felder sind hier im Klartext enthalten (z.B. "notizen" statt', ' "notizenVerschluesselt"). Geldbeträge stehen als Zeichenkette in CENT -', ' "123456" bedeutet 1.234,56 Euro. Datumsangaben sind ISO-8601 (UTC).', ` Enthalten: ${angaben.tabellen.join(', ')}.`, angaben.mitAuditLog ? ' Das Audit-Log protokolliert, wer wann was geändert hat.' : ' Das Audit-Log wurde für diesen Export abgewählt.', '', 'berichte/*.pdf', ' Die erzeugten und abgelegten Berichte - Jahresberichte, Vermögensverzeichnis,', ` Belegmappen, Unterlagenpakete. Enthalten: ${angaben.anzahlBerichte} Dateien.`, '', 'pruefsummen.txt', ' SHA-256 je Datei in diesem Archiv, im Format " ". Damit lässt', ' sich jederzeit belegen, dass eine Datei unverändert ist. Prüfen kannst du', ' das unter Linux/macOS mit "shasum -a 256 -c pruefsummen.txt", unter Windows', ' mit "certutil -hashfile SHA256".', '', 'Wie arbeite ich ohne diese Anwendung weiter?', '-------------------------------------------', '', '1. Entpacke das Archiv in einen Ordner, den du sicherst (Backup!). Die Daten', ' sind hier NICHT mehr verschlüsselt - behandle den Ordner so vertraulich wie', ' den Aktenordner selbst.', '2. Die Ordnerstruktur unter dokumente/ ist nach Jahren sortiert und selbst', ' erklärend. Für das Betreuungsgericht und den Sozialhilfeträger reicht sie', ' als Aktenführung aus.', '3. Die JSON-Dateien öffnest du mit jedem Texteditor. Wer eine Tabelle daraus', ' machen will, lädt sie in LibreOffice Calc oder Excel (Daten -> Aus Datei ->', ' JSON) oder nutzt ein Werkzeug wie "jq".', '4. Die laufenden Pflichten bleiben bestehen, auch ohne Software: Jahresbericht', ' nach § 1863 BGB, Vermögensübersicht nach § 1859 BGB, die Aufwandspauschale', ' nach § 1878 BGB innerhalb von sechs Monaten nach Ende des Betreuungsjahres', ' (Ausschlussfrist!). Übertrage die Fristen aus daten/frist.json in deinen', ' Kalender, bevor du die Anwendung abschaltest.', '', 'Dieses Archiv ist keine Rechtsberatung und ersetzt keine Rücksprache mit dem', 'Betreuungsgericht, dem Sozialhilfeträger oder einem Anwalt.', '', ].join('\n') } // ----------------------------------------------------------------------------- // Der Export selbst // ----------------------------------------------------------------------------- export async function vollexportStroemen(optionen: VollexportOptionen): Promise { const erstelltAm = optionen.erstelltAm ?? new Date() const betreute = await db.betreute.findFirstOrThrow({ where: { id: optionen.betreuteId, ...nichtGeloescht }, select: { vorname: true, nachname: true, anrede: true }, }) const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname] .filter(Boolean) .join(' ') const archiv = archiver('zip', { zlib: { level: 9 } }) // Fehlende Dateien im Objektspeicher duerfen den Lauf nicht abbrechen. archiv.on('warning', (warnung) => { console.warn('[vollexport] Warnung beim Archivieren:', warnung.message) }) const dateiname = berichtDateiname('vollexport', betreute.nachname, erstelltAm, 'zip') // Der Aufbau laeuft im Hintergrund weiter, waehrend der Aufrufer bereits // den Strom weiterreicht - genau das macht den Export streamfaehig. void befuellen(archiv, optionen, { betreuteName, erstelltAm }).catch((fehler: Error) => { console.error('[vollexport] Abbruch:', fehler) archiv.abort() }) return { dateiname, strom: archiv } } async function befuellen( archiv: Archiv, optionen: VollexportOptionen, zusatz: { betreuteName: string; erstelltAm: Date }, ): Promise { const pruefsummen: { pfad: string; hash: string }[] = [] const vergeben = new Set() /** Verhindert, dass zwei Dokumente mit gleichem Titel einander verdecken. */ const eindeutig = (pfad: string): string => { if (!vergeben.has(pfad)) { vergeben.add(pfad) return pfad } const punkt = pfad.lastIndexOf('.') const stamm = punkt < 0 ? pfad : pfad.slice(0, punkt) const endung = punkt < 0 ? '' : pfad.slice(punkt) let n = 2 while (vergeben.has(`${stamm}-${n}${endung}`)) n++ const neu = `${stamm}-${n}${endung}` vergeben.add(neu) return neu } const schreiben = async (pfad: string, inhalt: Buffer | string) => { const ziel = eindeutig(pfadErfordern(pfad)) pruefsummen.push({ pfad: ziel, hash: sha256(inhalt) }) await anhaengen(archiv, ziel, inhalt) } // --- 1. Daten ------------------------------------------------------------ const verwendeteTabellen: string[] = [] for (const tabelle of tabellen(optionen.betreuteId, optionen)) { const datensaetze = (await tabelle.laden()) as Record[] const offen = entschluesseln(tabelle.modell, datensaetze) await schreiben(`daten/${sichererDateiname(tabelle.name, 'tabelle')}.json`, alsJson(offen)) verwendeteTabellen.push(tabelle.name) } // --- 2. Dokumente -------------------------------------------------------- let anzahlDokumente = 0 if (optionen.mitDokumenten) { const dokumente = await db.dokument.findMany({ where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } }, select: { titel: true, absender: true, schreibenVom: true, eingangAm: true, erstelltAm: true, objektSchluessel: true, dateiname: true, mimeTyp: true, }, orderBy: { erstelltAm: 'asc' }, }) for (const dokument of dokumente) { if (!dokument.objektSchluessel) continue let inhalt: Buffer try { inhalt = await lesen(dokument.objektSchluessel) } catch (fehler) { console.warn( `[vollexport] Datei zu "${dokument.titel}" nicht lesbar, wird übersprungen:`, (fehler as Error).message, ) continue } const pfad = exportDokumentPfad({ datum: dokument.schreibenVom ?? dokument.eingangAm ?? dokument.erstelltAm, absender: dokument.absender, titel: dokument.titel, dateiname: dokument.dateiname, mimeTyp: dokument.mimeTyp, }) await schreiben(pfad, inhalt) anzahlDokumente++ } } // --- 3. Berichte --------------------------------------------------------- let anzahlBerichte = 0 if (optionen.mitBerichten) { const berichte = await db.bericht.findMany({ where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } }, select: { titel: true, art: true, dateiname: true, objektSchluessel: true, erstelltAm: true }, orderBy: { erstelltAm: 'asc' }, }) for (const bericht of berichte) { if (!bericht.objektSchluessel) continue let inhalt: Buffer try { inhalt = await lesen(bericht.objektSchluessel) } catch (fehler) { console.warn( `[vollexport] Bericht "${bericht.titel}" nicht lesbar, wird übersprungen:`, (fehler as Error).message, ) continue } const name = bericht.dateiname ? sichererDateiname(bericht.dateiname.replace(/\.[a-z0-9]+$/i, ''), 'bericht') : sichererDateiname(`${bericht.art}-${bericht.titel}`, 'bericht') await schreiben( `berichte/${bericht.erstelltAm.toISOString().slice(0, 10)}_${name}.pdf`, inhalt, ) anzahlBerichte++ } } // --- 4. LIESMICH und Pruefsummen ---------------------------------------- await schreiben( 'LIESMICH.txt', liesmichText({ betreuteName: zusatz.betreuteName, erstelltVon: optionen.erstelltVon, erstelltAm: zusatz.erstelltAm, tabellen: verwendeteTabellen, anzahlDokumente, anzahlBerichte, mitAuditLog: optionen.mitAuditLog, }), ) // Die Pruefsummendatei steht zuletzt und listet alles davor - sie selbst // kann sich naturgemaess nicht enthalten. const zeilen = [ `# SHA-256 aller Dateien in diesem Archiv, erzeugt am ${formatDatum(zusatz.erstelltAm)}.`, '# Prüfen: shasum -a 256 -c pruefsummen.txt', ...pruefsummen.map((eintrag) => `${eintrag.hash} ${eintrag.pfad}`), '', ].join('\n') await anhaengen(archiv, 'pruefsummen.txt', zeilen) await archiv.finalize() }