docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
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 } }) },
|
||||
{
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user