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
979 lines
33 KiB
TypeScript
979 lines
33 KiB
TypeScript
'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.'
|
||
}
|