Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.
Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
[Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
"Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
sind im Vollexport ihrer Akte enthalten.
Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.
Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.
Refs #7
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
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<typeof archiver>
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 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<void> {
|
|
pfadErfordern(pfad)
|
|
return new Promise<void>((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<T extends Record<string, unknown>>(
|
|
modell: VerschluesseltesModell | null,
|
|
datensaetze: T[],
|
|
): Record<string, unknown>[] {
|
|
if (!modell) return datensaetze as Record<string, unknown>[]
|
|
const felder = VERSCHLUESSELTE_FELDER[modell] as readonly string[]
|
|
|
|
return datensaetze.map((datensatz) => {
|
|
const geoeffnet = { ...oeffnen(modell, datensatz) } as Record<string, unknown>
|
|
for (const feld of felder) delete geoeffnet[feld]
|
|
return geoeffnet
|
|
})
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Tabellen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
type Tabelle = { name: string; modell: VerschluesseltesModell | null; laden: () => Promise<unknown[]> }
|
|
|
|
/**
|
|
* 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/<jahr>/<datum>_<absender>_<titel>.<endung>',
|
|
' 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 "<hash> <pfad>". 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 <datei> 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<VollexportErgebnis> {
|
|
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<void> {
|
|
const pruefsummen: { pfad: string; hash: string }[] = []
|
|
const vergeben = new Set<string>()
|
|
|
|
/** 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<string, unknown>[]
|
|
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()
|
|
}
|