feat: Jahresbericht, Vollexport, Wissensbasis und Mitleser-Freigaben

Etappe 7. Die Ausgabeschicht - alles, was diese Anwendung nach aussen
abgibt.

Der Jahresbericht nach § 1863 BGB ist ein ENTWURF zum Ueberarbeiten,
kein fertiger Text. Er zieht sich die fuenf Gliederungspunkte aus
Kontaktprotokollen, Gesundheitsereignissen und Vorgangsstatus zusammen;
was davor abgegeben wird, entscheidet der Betreuer. Das sagt auch die
Oberflaeche. Ausgabe als PDF und als DOCX, damit sich weiterschreiben
laesst.

Vollexport als ZIP-Strom mit Gegendruck, damit auch grosse Bestaende
ohne Speicherausbruch durchgehen: Originaldateien nach Jahren sortiert,
alle Tabellen als entschluesseltes JSON, die erzeugten Berichte, ein
LIESMICH und SHA-256 je Datei. Damit laesst sich die Anwendung
verlassen - kein Datensilo.

Der Export ist eine sensible Aktion: nur BETREUER und ADMIN, nur nach
frischer Anmeldung, immer mit Audit-Eintrag. Ein Mitleser kommt nicht
heran.

37 Hinweiskarten in daten/hinweise.json, Zod-geprueft und ohne
Neustart pflegbar. Jede mit Rechtsgrundlage, Link und dem stehenden
Vermerk, dass sie keine Rechtsberatung ersetzt.

Modulweise Freigaben fuer Mitleser, mit ausdruecklicher Warnung bei
Gesundheit, Finanzen und Sozialhilfe. Rollen- und Freigabeaenderungen
erfordern eine frische Anmeldung und stehen im Audit-Log.

Nebenbei geloest: Webpack loest .tsx vor .ts auf, TypeScript und Vitest
umgekehrt. Ein Modul, das beide Endungen traegt, importiert sich unter
einem der beiden Werkzeuge selbst. Deshalb heissen die Bausteine jetzt
eindeutig (jahresbericht.entwurf.ts, .inhalt.tsx, .ausgabe.ts) und
werden ueber eine gemeinsame Fassade weitergereicht.

41 Tests.

Etappe: 7
Phase: Ausgabe
This commit is contained in:
Florian Wilde
2026-08-22 06:22:44 +02:00
parent b69a12da8f
commit 5cc7f5c140
29 changed files with 5573 additions and 3076 deletions
@@ -0,0 +1,128 @@
import { Readable } from 'node:stream'
import type { NextRequest } from 'next/server'
import { aktiveBetreute, erfordereBenutzer, reauthGueltig } from '@/lib/auth/sitzung'
import { darf } from '@/lib/auth/rollen'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { vollexportStroemen } from '@/lib/berichte/vollexport'
/**
* Auslieferung des Vollexports.
*
* Warum eine eigene Route und keine Server Action? Weil das Archiv als
* Strom entsteht: Waehrend der Browser die ersten Bytes empfaengt, wird
* die naechste Datei noch gelesen. Eine Server Action kann keinen Strom
* zurueckgeben - sie muesste das ganze ZIP erst im Arbeitsspeicher
* aufbauen, und bei ein paar hundert eingescannten Bescheiden ist das der
* Punkt, an dem der Server umfaellt.
*
* Die Pruefung ist hier vollstaendig, nicht abgekuerzt: angemeldet,
* Zugriff auf genau diese betreute Person, Rolle BETREUER oder ADMIN,
* frische Anmeldung. Ein Mitleser kommt hier nie durch - auch nicht mit
* allen Modulfreigaben.
*/
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function abgelehnt(nachricht: string, status: number): Response {
return new Response(nachricht, {
status,
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff' },
})
}
export async function GET(anfrage: NextRequest): Promise<Response> {
const benutzer = await erfordereBenutzer('/berichte/export')
const gewuenscht = anfrage.nextUrl.searchParams.get('betreute')
const aktive = await aktiveBetreute(benutzer)
const betreuteId = gewuenscht ?? aktive?.id
if (!betreuteId) return abgelehnt('Es ist keine betreute Person ausgewählt.', 400)
const betreute = await db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { id: true, vorname: true, nachname: true },
})
if (!betreute) return abgelehnt('Diese betreute Person gibt es nicht.', 404)
const eintrag = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId: benutzer.id, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
const zugang = eintrag
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
: null
if (!darf(zugang, 'BERICHTE', 'schreiben')) {
return abgelehnt('Für diesen Bereich fehlt dir die Berechtigung.', 403)
}
if (zugang!.rolle !== 'BETREUER' && zugang!.rolle !== 'ADMIN') {
return abgelehnt('Einen Vollexport darf nur der Betreuer oder ein Administrator erzeugen.', 403)
}
if (!(await reauthGueltig(benutzer.id))) {
return abgelehnt(
'Der Export muss zuerst freigeschaltet werden. Geh zurück auf die Exportseite und ' +
'bestätige deine Anmeldung erneut.',
403,
)
}
const ja = (name: string) => anfrage.nextUrl.searchParams.get(name) !== 'nein'
const { dateiname, strom } = await vollexportStroemen({
betreuteId,
mitDokumenten: ja('dokumente'),
mitAuditLog: ja('audit'),
mitBerichten: ja('berichte'),
erstelltVon: benutzer.name,
})
// Der Eintrag entsteht, bevor das erste Byte fliesst: Ein abgebrochener
// Download aendert nichts daran, dass der Bestand das Haus verlassen hat.
const vermerk = await db.bericht.create({
data: {
betreuteId,
art: 'VOLLEXPORT',
titel: 'Vollexport aller Daten',
stichtag: new Date(),
dateiname,
erstelltVonId: benutzer.id,
},
})
await protokollieren({
aktion: 'EXPORTIERT',
entitaet: 'Betreute',
entitaetId: betreuteId,
bezeichnung: `Vollexport ${dateiname}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
umfang: {
alt: null,
neu: {
dokumente: ja('dokumente'),
auditLog: ja('audit'),
berichte: ja('berichte'),
vermerk: vermerk.id,
},
},
},
})
const koerper = Readable.toWeb(strom) as ReadableStream<Uint8Array>
return new Response(koerper, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${dateiname}"`,
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
},
})
}