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:
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user