Files
betreuungs-cockpit/src/server/actions/dokumente.ts
T
Florian Wilde 3833a9dd8f 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
2026-08-22 02:35:08 +02:00

979 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, string>
/** 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<string> {
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<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
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<Aktionszustand> {
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<Vorschlagsantwort> {
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<Aktionszustand> {
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<string[]> {
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<Aktionszustand> {
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<boolean> {
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<Aktionszustand> {
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<Aktionszustand> {
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<Aktionszustand> {
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<Aktionszustand> {
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<Aktionszustand> {
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<Aktionszustand> {
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<Mappenantwort> {
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<typeof belegmappeErzeugen>[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.'
}