import { Readable } from 'node:stream' import type { NextRequest } from 'next/server' import { aktiveBetreute, benutzerFuerRoute, 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 { // Eine Route antwortet mit einem Statuscode, nicht mit einer // Anmeldeseite als Dateiinhalt. const angemeldet = await benutzerFuerRoute() if (!angemeldet.ok) return angemeldet.antwort const benutzer = angemeldet.benutzer 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 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', }, }) }