'use server' import { revalidatePath } from 'next/cache' import { z } from 'zod' import type { Prisma } from '@prisma/client' import { aenderungErmitteln, protokollieren } from '@/lib/audit' import { aktiveBetreute, erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung' import { sealen } from '@/lib/crypto/feld' import { db, nichtGeloescht } from '@/lib/db' import { bekannteAbsender } from '@/lib/dokumente/suche' import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung' import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf' import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher' import { DokumentMetadatenSchema, FristUebernehmenSchema, PosteingangSchema, UploadSchema, VerknuepfungSchema, ZIEL_SPALTE, tagsAufteilen, type Verknuepfungsziel, } from '@/lib/validierung/dokumente' /** * Server Actions der Dokumentenakte. * * Drei Dinge passieren in JEDER Aktion, ohne Ausnahme: * 1. `erfordereZugang(betreuteId, 'DOKUMENTE', …)` - serverseitig, nie * nur im UI ausgeblendet. * 2. Zod-Validierung der Eingabe. * 3. Ein Audit-Eintrag. Das Log ist mein Nachweis gegenueber Gericht und * moeglichen Miterben, was ich wann getan habe. * * Rueckgabe an Formulare ist immer ein Zustandsobjekt, nie ein geworfener * Fehler - `useActionState` soll die Meldung anzeigen koennen. */ export type Aktionszustand = { fehler?: string hinweis?: string felder?: Record /** Bei Erfolg: die Kennung des betroffenen Dokuments. */ dokumentId?: string /** Beim Sammel-Upload: was wurde uebernommen, was abgelehnt? */ ergebnisse?: { dateiname: string; dokumentId?: string; fehler?: string }[] } // ----------------------------------------------------------------------------- // Gemeinsame Hilfen // ----------------------------------------------------------------------------- /** Die gerade gewaehlte betreute Person - fuer alles, was neu entsteht. */ async function aktiveBetreuteId(): Promise { const benutzer = await erfordereBenutzer() const betreute = await aktiveBetreute(benutzer) if (!betreute) { throw new Error('Es ist keine betreute Person ausgewählt.') } return betreute.id } /** * Laedt ein Dokument und prueft dabei den Zugriff auf seine betreute * Person. Ohne diesen Umweg koennte eine fremde Kennung Daten offenlegen. */ async function dokumentMitZugang(dokumentId: string, art: 'lesen' | 'schreiben' = 'schreiben') { const dokument = await db.dokument.findFirst({ where: { id: dokumentId, ...nichtGeloescht }, }) if (!dokument) throw new Error('Dieses Dokument gibt es nicht (mehr).') const kontext = await erfordereZugang(dokument.betreuteId, 'DOKUMENTE', art) return { dokument, ...kontext } } function felderAus(fehler: z.ZodError): Record { const flach = fehler.flatten().fieldErrors const ergebnis: Record = {} for (const [feld, meldungen] of Object.entries(flach)) { if (meldungen && meldungen[0]) ergebnis[feld] = meldungen[0] } return ergebnis } function textOderNull(formular: FormData, name: string): string | undefined { const wert = formular.get(name) return typeof wert === 'string' ? wert : undefined } function alleSeiten(dokumentId?: string): void { revalidatePath('/dokumente') revalidatePath('/dokumente/posteingang') revalidatePath('/', 'layout') // Zaehler im Menue if (dokumentId) revalidatePath(`/dokumente/${dokumentId}`) } // ----------------------------------------------------------------------------- // Hochladen // ----------------------------------------------------------------------------- /** * Nimmt eine oder mehrere Dateien entgegen. * * Ablauf je Datei: Magic-Byte-Pruefung -> ablegen -> Datensatz mit * `ocrStatus: "ausstehend"` -> Erkennung im Hintergrund anstossen. * Eine abgelehnte Datei bricht den Stapel nicht ab; sie wird einzeln * gemeldet. Wer zehn Blaetter fotografiert hat, will nicht wegen des * dritten von vorne anfangen. */ export async function dokumenteHochladen( _vorher: Aktionszustand, formular: FormData, ): Promise { const betreuteId = await aktiveBetreuteId() const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben') const dateien = formular .getAll('dateien') .filter((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0) if (dateien.length === 0) { return { fehler: 'Bitte mindestens eine Datei auswählen oder ein Foto aufnehmen.' } } const gelesen = UploadSchema.safeParse({ titel: textOderNull(formular, 'titel'), art: textOderNull(formular, 'art') || undefined, richtung: textOderNull(formular, 'richtung') || undefined, eingangAm: textOderNull(formular, 'eingangAm'), absender: textOderNull(formular, 'absender'), aktenzeichen: textOderNull(formular, 'aktenzeichen'), ablageortPapier: textOderNull(formular, 'ablageortPapier'), vorgangId: textOderNull(formular, 'vorgangId') || undefined, }) if (!gelesen.success) { return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAus(gelesen.error) } } const eingabe = gelesen.data // Ein mitgegebener Vorgang muss zur selben betreuten Person gehoeren. let vorgangId: string | null = null if (eingabe.vorgangId) { const vorgang = await db.vorgang.findFirst({ where: { id: eingabe.vorgangId, betreuteId, ...nichtGeloescht }, select: { id: true }, }) if (!vorgang) return { fehler: 'Der gewählte Vorgang gehört nicht zu dieser Akte.' } vorgangId = vorgang.id } const ergebnisse: Aktionszustand['ergebnisse'] = [] let letzteId: string | undefined for (const datei of dateien) { const anzeigename = datei.name || 'Aufnahme' try { const inhalt = Buffer.from(await datei.arrayBuffer()) const ablage = await ablegenMitTyp( betreuteId, 'dokumente', anzeigename, inhalt, datei.type || 'application/octet-stream', ) const einzeln = dateien.length === 1 const titel = (einzeln ? eingabe.titel : null) ?? ablage.dateiname.replace(/\.[a-z0-9]{1,5}$/i, '') const dokument = await db.dokument.create({ data: { betreuteId, titel: titel.slice(0, 200) || 'Ohne Titel', art: eingabe.art ?? (ablage.typ.istBild ? 'FOTO' : 'SONSTIGES'), richtung: eingabe.richtung ?? 'EINGANG', eingangAm: eingabe.eingangAm ?? heuteAlsTag(), absender: einzeln ? eingabe.absender : null, aktenzeichen: einzeln ? eingabe.aktenzeichen : null, ablageortPapier: eingabe.ablageortPapier, objektSchluessel: ablage.schluessel, dateiname: ablage.dateiname, mimeTyp: ablage.typ.mimeTyp, groesseBytes: ablage.groesseBytes, pruefsumme: ablage.pruefsumme, // Die Erkennung laeuft gleich los, blockiert aber nichts. ocrStatus: 'ausstehend', posteingangStatus: 'NEU', ...(vorgangId ? { verknuepfungen: { create: [{ betreuteId, vorgangId }] } } : {}), }, }) await protokollieren({ aktion: 'ERSTELLT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: dokument.titel, betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { dateiname: { alt: null, neu: ablage.dateiname }, pruefsumme: { alt: null, neu: ablage.pruefsumme }, mimeTyp: { alt: null, neu: ablage.typ.mimeTyp }, }, }) ocrAnstossen(dokument.id) ergebnisse.push({ dateiname: anzeigename, dokumentId: dokument.id }) letzteId = dokument.id } catch (fehler) { const meldung = fehler instanceof DateiAbgelehnt ? fehler.message : `„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.` if (!(fehler instanceof DateiAbgelehnt)) { console.error('[dokumente] Upload gescheitert:', fehler) } ergebnisse.push({ dateiname: anzeigename, fehler: meldung }) } } alleSeiten() const uebernommen = ergebnisse.filter((e) => e.dokumentId).length const abgelehnt = ergebnisse.length - uebernommen if (uebernommen === 0) { return { fehler: ergebnisse[0]?.fehler ?? 'Keine Datei konnte übernommen werden.', ergebnisse } } return { hinweis: abgelehnt === 0 ? `${uebernommen} ${uebernommen === 1 ? 'Dokument' : 'Dokumente'} übernommen.` : `${uebernommen} übernommen, ${abgelehnt} abgelehnt.`, dokumentId: uebernommen === 1 ? letzteId : undefined, ergebnisse, } } function heuteAlsTag(): Date { const jetzt = new Date() return new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate())) } // ----------------------------------------------------------------------------- // Erkennungsvorschlaege // ----------------------------------------------------------------------------- export type Vorschlagsantwort = { fehler?: string ocrStatus?: string | null /** Serialisierbar - Daten werden als ISO-Text uebergeben. */ vorschlaege?: { absender: string | null schreibenVom: string | null aktenzeichen: string | null art: string | null richtung: string | null titel: string | null fristen: { datum: string | null titel: string begruendung: string textstelle: string rechtsbehelf: boolean sicherheit: string }[] } } /** * Liefert die Vorschlaege zu einem bereits hochgeladenen Dokument. * * Es wird nichts gespeichert und nichts angelegt - der Nutzer bestaetigt * jeden Wert einzeln. Eine automatisch erzeugte, falsch gerechnete Frist * waere schlimmer als gar keine. */ export async function vorschlaegeLesen(dokumentId: string): Promise { try { const { dokument } = await dokumentMitZugang(dokumentId, 'lesen') if (!dokument.ocrText || dokument.ocrText.trim().length < 10) { return { ocrStatus: dokument.ocrStatus, vorschlaege: undefined } } const absender = await bekannteAbsender(dokument.betreuteId) const ergebnis: Erkennungsergebnis = erkennungsvorschlaege(dokument.ocrText, { bekannteAbsender: absender, zugangAm: dokument.eingangAm, }) return { ocrStatus: dokument.ocrStatus, vorschlaege: { absender: ergebnis.absender?.wert ?? null, schreibenVom: ergebnis.schreibenVom?.wert.toISOString().slice(0, 10) ?? null, aktenzeichen: ergebnis.aktenzeichen?.wert ?? null, art: ergebnis.art?.wert ?? null, richtung: ergebnis.richtung?.wert ?? null, titel: ergebnis.titel, fristen: ergebnis.fristen.map((frist) => ({ datum: frist.datum ? frist.datum.toISOString().slice(0, 10) : null, titel: frist.titel, begruendung: frist.begruendung, textstelle: frist.textstelle, rechtsbehelf: frist.rechtsbehelf, sicherheit: frist.sicherheit, })), }, } } catch (fehler) { return { fehler: meldungAus(fehler) } } } // ----------------------------------------------------------------------------- // Metadaten // ----------------------------------------------------------------------------- export async function dokumentSpeichern( _vorher: Aktionszustand, formular: FormData, ): Promise { const dokumentId = String(formular.get('dokumentId') ?? '') try { const { dokument, benutzer } = await dokumentMitZugang(dokumentId) const gelesen = DokumentMetadatenSchema.safeParse({ titel: textOderNull(formular, 'titel') ?? '', art: textOderNull(formular, 'art') ?? 'SONSTIGES', richtung: textOderNull(formular, 'richtung') ?? 'EINGANG', schreibenVom: textOderNull(formular, 'schreibenVom'), eingangAm: textOderNull(formular, 'eingangAm'), versandAm: textOderNull(formular, 'versandAm'), absender: textOderNull(formular, 'absender'), empfaenger: textOderNull(formular, 'empfaenger'), aktenzeichen: textOderNull(formular, 'aktenzeichen'), ablageortPapier: textOderNull(formular, 'ablageortPapier'), wiedervorlageAm: textOderNull(formular, 'wiedervorlageAm'), ablaufAm: textOderNull(formular, 'ablaufAm'), posteingangStatus: textOderNull(formular, 'posteingangStatus') || undefined, notizen: textOderNull(formular, 'notizen'), tagIds: formular.getAll('tagIds').map(String).filter(Boolean), neueTags: textOderNull(formular, 'neueTags'), }) if (!gelesen.success) { return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAus(gelesen.error) } } const eingabe = gelesen.data // Tags anlegen, die es noch nicht gibt - immer innerhalb dieser Akte. const tagIds = await tagsSichern(dokument.betreuteId, eingabe.tagIds ?? [], eingabe.neueTags) const daten: Prisma.DokumentUpdateInput = { titel: eingabe.titel, art: eingabe.art, richtung: eingabe.richtung, schreibenVom: eingabe.schreibenVom, eingangAm: eingabe.eingangAm, versandAm: eingabe.versandAm, absender: eingabe.absender, empfaenger: eingabe.empfaenger, aktenzeichen: eingabe.aktenzeichen, ablageortPapier: eingabe.ablageortPapier, wiedervorlageAm: eingabe.wiedervorlageAm, ablaufAm: eingabe.ablaufAm, ...(eingabe.posteingangStatus ? { posteingangStatus: eingabe.posteingangStatus } : {}), ...sealen('Dokument', { notizen: eingabe.notizen }), tags: { set: tagIds.map((id) => ({ id })) }, } const nachher = await db.dokument.update({ where: { id: dokument.id }, data: daten }) await protokollieren({ aktion: 'GEAENDERT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: nachher.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: aenderungErmitteln(dokument, nachher), }) alleSeiten(dokument.id) return { hinweis: 'Gespeichert.', dokumentId: dokument.id } } catch (fehler) { return { fehler: meldungAus(fehler) } } } /** Legt fehlende Tags an und liefert alle Kennungen zurueck. */ async function tagsSichern( betreuteId: string, vorhandene: string[], neue: string | null, ): Promise { const geprueft = await db.tag.findMany({ where: { betreuteId, id: { in: vorhandene } }, select: { id: true }, }) const ids = new Set(geprueft.map((t) => t.id)) for (const name of tagsAufteilen(neue)) { const tag = await db.tag.upsert({ where: { betreuteId_name: { betreuteId, name } }, update: {}, create: { betreuteId, name }, select: { id: true }, }) ids.add(tag.id) } return [...ids] } // ----------------------------------------------------------------------------- // Posteingang // ----------------------------------------------------------------------------- /** NEU -> GESICHTET -> ZUGEORDNET -> BEANTWORTET */ export async function posteingangSetzen( dokumentId: string, status: string, ): Promise { try { const gelesen = PosteingangSchema.safeParse({ dokumentId, status }) if (!gelesen.success) return { fehler: 'Diese Stufe im Posteingang gibt es nicht.' } const { dokument, benutzer } = await dokumentMitZugang(gelesen.data.dokumentId) const nachher = await db.dokument.update({ where: { id: dokument.id }, data: { posteingangStatus: gelesen.data.status }, }) await protokollieren({ aktion: 'GEAENDERT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: dokument.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { posteingangStatus: { alt: dokument.posteingangStatus, neu: nachher.posteingangStatus }, }, }) alleSeiten(dokument.id) return { hinweis: 'Posteingang aktualisiert.', dokumentId: dokument.id } } catch (fehler) { return { fehler: meldungAus(fehler) } } } // ----------------------------------------------------------------------------- // Verknuepfungen // ----------------------------------------------------------------------------- /** * Prueft, ob das Ziel zur selben betreuten Person gehoert. * * Ohne diese Pruefung liesse sich ein Dokument an einen fremden Vorgang * haengen - und damit in eine fremde Akte tragen. */ async function zielGehoertZurAkte( ziel: Verknuepfungsziel, zielId: string, betreuteId: string, ): Promise { switch (ziel) { case 'vorgang': return Boolean(await db.vorgang.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } })) case 'frist': return Boolean(await db.frist.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } })) case 'buchung': return Boolean(await db.buchung.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } })) case 'vermoegensposten': return Boolean( await db.vermoegensposten.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } }), ) case 'immobilie': return Boolean(await db.immobilie.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } })) case 'vertrag': return Boolean(await db.vertrag.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } })) case 'kontaktprotokoll': return Boolean( await db.kontaktprotokoll.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } }), ) case 'gesundheitsereignis': return Boolean( await db.gesundheitsereignis.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } }), ) case 'genehmigung': return Boolean( await db.genehmigung.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } }), ) // Nachforderung und Bescheid haengen am Sozialhilfeantrag, nicht direkt // an der betreuten Person - die Pruefung geht deshalb ueber den Antrag. case 'nachforderung': return Boolean( await db.nachforderung.findFirst({ where: { id: zielId, antrag: { betreuteId } }, select: { id: true }, }), ) case 'bescheid': return Boolean( await db.bescheid.findFirst({ where: { id: zielId, antrag: { betreuteId } }, select: { id: true }, }), ) case 'kontakt': return Boolean(await db.kontakt.findFirst({ where: { id: zielId, betreuteId }, select: { id: true } })) } } /** * Verknuepft ein Dokument mit genau einer Fachentitaet. Genau eine * Zielspalte wird gesetzt - so bleibt die Akte referenzsicher. */ export async function dokumentVerknuepfen( _vorher: Aktionszustand, formular: FormData, ): Promise { try { const gelesen = VerknuepfungSchema.safeParse({ dokumentId: textOderNull(formular, 'dokumentId'), ziel: textOderNull(formular, 'ziel'), zielId: textOderNull(formular, 'zielId'), bemerkung: textOderNull(formular, 'bemerkung'), }) if (!gelesen.success) { return { fehler: 'Bitte wähle aus, womit das Dokument verknüpft werden soll.' } } const eingabe = gelesen.data const { dokument, benutzer } = await dokumentMitZugang(eingabe.dokumentId) if (!(await zielGehoertZurAkte(eingabe.ziel, eingabe.zielId, dokument.betreuteId))) { return { fehler: 'Dieses Ziel gehört nicht zur Akte dieser betreuten Person.' } } const spalte = ZIEL_SPALTE[eingabe.ziel] const schonDa = await db.dokumentVerknuepfung.findFirst({ where: { dokumentId: dokument.id, [spalte]: eingabe.zielId }, select: { id: true }, }) if (schonDa) { return { hinweis: 'Diese Verknüpfung besteht bereits.', dokumentId: dokument.id } } await db.dokumentVerknuepfung.create({ data: { betreuteId: dokument.betreuteId, dokumentId: dokument.id, bemerkung: eingabe.bemerkung, [spalte]: eingabe.zielId, } as Prisma.DokumentVerknuepfungUncheckedCreateInput, }) // Zugeordnet ist zugeordnet - der Posteingang zieht mit. if (dokument.posteingangStatus === 'NEU' || dokument.posteingangStatus === 'GESICHTET') { await db.dokument.update({ where: { id: dokument.id }, data: { posteingangStatus: 'ZUGEORDNET' }, }) } await protokollieren({ aktion: 'GEAENDERT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: dokument.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { verknuepfung: { alt: null, neu: `${eingabe.ziel}:${eingabe.zielId}` } }, }) alleSeiten(dokument.id) return { hinweis: 'Verknüpft.', dokumentId: dokument.id } } catch (fehler) { return { fehler: meldungAus(fehler) } } } export async function verknuepfungLoesen(verknuepfungId: string): Promise { try { const verknuepfung = await db.dokumentVerknuepfung.findUnique({ where: { id: verknuepfungId }, select: { id: true, betreuteId: true, dokumentId: true }, }) if (!verknuepfung) return { fehler: 'Diese Verknüpfung gibt es nicht mehr.' } const { benutzer } = await erfordereZugang(verknuepfung.betreuteId, 'DOKUMENTE', 'schreiben') await db.dokumentVerknuepfung.delete({ where: { id: verknuepfung.id } }) await protokollieren({ aktion: 'GEAENDERT', entitaet: 'Dokument', entitaetId: verknuepfung.dokumentId, bezeichnung: 'Verknüpfung gelöst', betreuteId: verknuepfung.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { verknuepfung: { alt: verknuepfung.id, neu: null } }, }) alleSeiten(verknuepfung.dokumentId) return { hinweis: 'Verknüpfung gelöst.' } } catch (fehler) { return { fehler: meldungAus(fehler) } } } // ----------------------------------------------------------------------------- // Frist aus einem Vorschlag // ----------------------------------------------------------------------------- /** * Uebernimmt einen erkannten Fristvorschlag - erst auf ausdruecklichen * Knopfdruck. Die erzeugte Frist ist mit dem Dokument verknuepft, damit * spaeter nachvollziehbar bleibt, aus welchem Schreiben sie stammt. */ export async function fristUebernehmen( _vorher: Aktionszustand, formular: FormData, ): Promise { try { const gelesen = FristUebernehmenSchema.safeParse({ dokumentId: textOderNull(formular, 'dokumentId'), titel: textOderNull(formular, 'titel'), datum: textOderNull(formular, 'datum'), typ: textOderNull(formular, 'typ') || undefined, beschreibung: textOderNull(formular, 'beschreibung'), rechtsgrundlage: textOderNull(formular, 'rechtsgrundlage'), ausschlussfrist: formular.get('ausschlussfrist') === 'ja', vorgangId: textOderNull(formular, 'vorgangId') || undefined, }) if (!gelesen.success) { return { fehler: 'Bitte prüfe Titel und Datum der Frist.', felder: felderAus(gelesen.error) } } const eingabe = gelesen.data const { dokument, benutzer } = await dokumentMitZugang(eingabe.dokumentId) await erfordereZugang(dokument.betreuteId, 'FRISTEN', 'schreiben') let vorgangId: string | null = null if (eingabe.vorgangId) { const vorgang = await db.vorgang.findFirst({ where: { id: eingabe.vorgangId, betreuteId: dokument.betreuteId, ...nichtGeloescht }, select: { id: true }, }) vorgangId = vorgang?.id ?? null } const frist = await db.frist.create({ data: { betreuteId: dokument.betreuteId, vorgangId, titel: eingabe.titel, datum: eingabe.datum, typ: eingabe.typ, beschreibung: eingabe.beschreibung ?? `Übernommen aus dem Dokument „${dokument.titel}“. Bitte das Zugangsdatum prüfen.`, rechtsgrundlage: eingabe.rechtsgrundlage, ausschlussfrist: eingabe.ausschlussfrist, }, }) await db.dokumentVerknuepfung.create({ data: { betreuteId: dokument.betreuteId, dokumentId: dokument.id, fristId: frist.id, bemerkung: 'Frist aus dem Schreiben übernommen', }, }) await protokollieren({ aktion: 'ERSTELLT', entitaet: 'Frist', entitaetId: frist.id, bezeichnung: frist.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { datum: { alt: null, neu: frist.datum }, quelle: { alt: null, neu: `Dokument ${dokument.id}` }, }, }) alleSeiten(dokument.id) revalidatePath('/fristen') return { hinweis: `Frist zum ${formatTag(frist.datum)} angelegt.`, dokumentId: dokument.id } } catch (fehler) { return { fehler: meldungAus(fehler) } } } function formatTag(datum: Date): string { const tag = String(datum.getUTCDate()).padStart(2, '0') const monat = String(datum.getUTCMonth() + 1).padStart(2, '0') return `${tag}.${monat}.${datum.getUTCFullYear()}` } // ----------------------------------------------------------------------------- // Papierkorb // ----------------------------------------------------------------------------- export async function dokumentLoeschen(dokumentId: string): Promise { try { const { dokument, benutzer } = await dokumentMitZugang(dokumentId) await db.dokument.update({ where: { id: dokument.id }, data: { geloeschtAm: new Date() }, }) await protokollieren({ aktion: 'GELOESCHT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: dokument.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, }) alleSeiten(dokument.id) return { hinweis: 'In den Papierkorb gelegt. 30 Tage lang wiederherstellbar.' } } catch (fehler) { return { fehler: meldungAus(fehler) } } } export async function dokumentWiederherstellen(dokumentId: string): Promise { try { const dokument = await db.dokument.findUnique({ where: { id: dokumentId } }) if (!dokument) return { fehler: 'Dieses Dokument gibt es nicht mehr.' } const { benutzer } = await erfordereZugang(dokument.betreuteId, 'DOKUMENTE', 'schreiben') await db.dokument.update({ where: { id: dokument.id }, data: { geloeschtAm: null } }) await protokollieren({ aktion: 'WIEDERHERGESTELLT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: dokument.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, }) alleSeiten(dokument.id) return { hinweis: 'Wiederhergestellt.' } } catch (fehler) { return { fehler: meldungAus(fehler) } } } /** * Endgueltig loeschen - Datensatz und Datei. Nur mit frischer Anmeldung: * hier ist nichts mehr zurueckzuholen, und der Nachweis gegenueber dem * Gericht ist danach weg. */ export async function dokumentEndgueltigLoeschen(dokumentId: string): Promise { try { const dokument = await db.dokument.findUnique({ where: { id: dokumentId } }) if (!dokument) return { fehler: 'Dieses Dokument gibt es nicht mehr.' } const { benutzer } = await erfordereZugang(dokument.betreuteId, 'DOKUMENTE', 'schreiben') await erfordereFrischeAnmeldung(benutzer, `/dokumente/${dokumentId}`) if (!dokument.geloeschtAm) { return { fehler: 'Bitte das Dokument zuerst in den Papierkorb legen.' } } if (dokument.objektSchluessel) { try { await entfernen(dokument.objektSchluessel) } catch (fehler) { console.error('[dokumente] Datei konnte nicht entfernt werden:', fehler) } } await db.dokument.delete({ where: { id: dokument.id } }) await protokollieren({ aktion: 'ENDGUELTIG_GELOESCHT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: dokument.titel, betreuteId: dokument.betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { pruefsumme: { alt: dokument.pruefsumme, neu: null } }, }) alleSeiten() return { hinweis: 'Endgültig gelöscht.' } } catch (fehler) { return { fehler: meldungAus(fehler) } } } // ----------------------------------------------------------------------------- // Vorschau // ----------------------------------------------------------------------------- /** * Eine kurzlebige, signierte URL fuer die Vorschau. Der Zugriff wird beim * Abruf noch einmal geprueft - der Link allein oeffnet nichts. */ export async function vorschauUrl(dokumentId: string): Promise<{ url?: string; fehler?: string }> { try { const { dokument } = await dokumentMitZugang(dokumentId, 'lesen') if (!dokument.objektSchluessel) return { fehler: 'Zu diesem Eintrag gibt es keine Datei.' } return { url: await signierteUrl(dokument.objektSchluessel, 300) } } catch (fehler) { return { fehler: meldungAus(fehler) } } } // ----------------------------------------------------------------------------- // Belegmappe // ----------------------------------------------------------------------------- export type Mappenantwort = { fehler?: string hinweis?: string url?: string dateiname?: string anzahl?: number /** Was nicht aufgenommen werden konnte. */ hinweise?: string[] } /** * Erzeugt die Belegmappe und legt sie unter "exporte" ab. Zurueck kommt * eine kurzlebige, signierte URL - die Datei selbst geht nie ungeschuetzt * durch die Gegend. * * Ein Export ist eine meldepflichtige Handlung: er verlaesst die * Anwendung. Deshalb steht er im Audit-Log. */ export async function belegmappeAnfordern( _vorher: Mappenantwort, formular: FormData, ): Promise { try { const betreuteId = await aktiveBetreuteId() const { benutzer, betreute } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen') const art = textOderNull(formular, 'art') ?? 'zeitraum' const { belegmappeErzeugen } = await import('@/lib/dokumente/belegmappe') let auswahl: Parameters[1] if (art === 'vorgang') { const vorgangId = textOderNull(formular, 'vorgangId') ?? '' const vorgang = await db.vorgang.findFirst({ where: { id: vorgangId, betreuteId, ...nichtGeloescht }, select: { id: true }, }) if (!vorgang) return { fehler: 'Bitte einen Vorgang aus dieser Akte wählen.' } auswahl = { art: 'vorgang', vorgangId: vorgang.id } } else { const spanne = z .object({ von: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), bis: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }) .safeParse({ von: textOderNull(formular, 'von'), bis: textOderNull(formular, 'bis') }) if (!spanne.success) { return { fehler: 'Bitte Anfang und Ende des Zeitraums angeben (TT.MM.JJJJ).' } } const von = new Date(`${spanne.data.von}T00:00:00.000Z`) const bis = new Date(`${spanne.data.bis}T00:00:00.000Z`) if (von > bis) return { fehler: 'Das Ende des Zeitraums liegt vor seinem Anfang.' } auswahl = { art: 'zeitraum', von, bis } } const mappe = await belegmappeErzeugen( betreuteId, auswahl, benutzer.name || benutzer.email, ) if (mappe.anzahl === 0) { return { fehler: 'Für diese Auswahl gibt es keine Belege mit Datei. Prüfe den Zeitraum oder den Vorgang.', hinweise: mappe.hinweise, } } const ablage = await ablegenMitTyp( betreuteId, 'exporte', mappe.dateiname, mappe.pdf, 'application/pdf', ) await protokollieren({ aktion: 'EXPORTIERT', entitaet: 'Dokument', bezeichnung: `Belegmappe (${mappe.anzahl} Belege) – ${betreute.vorname} ${betreute.nachname}`, betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, aenderung: { umfang: { alt: null, neu: `${mappe.anzahl} Belege` }, pruefsumme: { alt: null, neu: ablage.pruefsumme }, }, }) return { hinweis: `Belegmappe mit ${mappe.anzahl} Belegen erstellt.`, url: await signierteUrl(ablage.schluessel, 600), dateiname: mappe.dateiname, anzahl: mappe.anzahl, hinweise: mappe.hinweise, } } catch (fehler) { return { fehler: meldungAus(fehler) } } } // ----------------------------------------------------------------------------- function meldungAus(fehler: unknown): string { if (fehler instanceof DateiAbgelehnt) return fehler.message if (fehler instanceof Error && fehler.name === 'ZugriffVerweigert') return fehler.message // Ein Redirect (frische Anmeldung noetig) muss durchgereicht werden. if ( fehler && typeof fehler === 'object' && 'digest' in fehler && typeof (fehler as { digest?: unknown }).digest === 'string' && (fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT') ) { throw fehler } console.error('[dokumente]', fehler) return 'Das hat nicht geklappt. Bitte versuche es noch einmal.' }