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
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { BerichtArt } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
import { sealen, entschluesselnWennMoeglich } from '@/lib/crypto/feld'
|
||||
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
||||
import { ablegen } from '@/lib/speicher'
|
||||
import { belegmappeErzeugen } from '@/lib/dokumente/belegmappe'
|
||||
import type { Berichtskopf } from '@/lib/pdf/rahmen'
|
||||
import {
|
||||
jahresberichtEntwurfAusDatenbank,
|
||||
jahresberichtPdf,
|
||||
} from '@/lib/berichte/jahresbericht.tsx'
|
||||
import { docxErzeugen } from '@/lib/berichte/docx'
|
||||
import { unterlagenpaketErzeugen } from '@/lib/berichte/unterlagenpaket'
|
||||
import {
|
||||
belegmappeSchema,
|
||||
berichtAbgabeSchema,
|
||||
berichtDateiname,
|
||||
entwurfSpeichernSchema,
|
||||
jahresberichtErzeugenSchema,
|
||||
unterlagenpaketSchema,
|
||||
} from '@/lib/validierung/berichte'
|
||||
|
||||
/**
|
||||
* Server Actions rund um die Berichte.
|
||||
*
|
||||
* Zwei Regeln ziehen sich durch alle: Erstens wird jeder Zugriff
|
||||
* serverseitig gegen die betreuteId geprueft, auch wenn die Seite den
|
||||
* Knopf gar nicht erst angezeigt haette. Zweitens entsteht zu jedem
|
||||
* erzeugten Bericht ein Bericht-Datensatz - die Frage "was habe ich wann
|
||||
* in welcher Fassung abgegeben?" muss sich Jahre spaeter beantworten
|
||||
* lassen, auch gegenueber Miterben.
|
||||
*/
|
||||
|
||||
export type Aktionszustand = {
|
||||
fehler?: string
|
||||
felder?: Record<string, string>
|
||||
erfolg?: string
|
||||
berichtId?: string
|
||||
}
|
||||
|
||||
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
|
||||
const felder: Record<string, string> = {}
|
||||
for (const problem of fehler) {
|
||||
const schluessel = String(problem.path[0] ?? 'formular')
|
||||
if (!felder[schluessel]) felder[schluessel] = problem.message
|
||||
}
|
||||
return felder
|
||||
}
|
||||
|
||||
async function kopfBauen(
|
||||
betreuteId: string,
|
||||
titel: string,
|
||||
angaben: {
|
||||
zeitraumVon?: Date | null
|
||||
zeitraumBis?: Date | null
|
||||
stichtag?: Date | null
|
||||
rechtsgrundlage?: string
|
||||
erstelltVon: string
|
||||
erstelltAm: Date
|
||||
},
|
||||
): Promise<Berichtskopf> {
|
||||
const betreute = await db.betreute.findFirstOrThrow({
|
||||
where: { id: betreuteId, ...nichtGeloescht },
|
||||
select: {
|
||||
anrede: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
geburtsdatum: true,
|
||||
betreuungsgericht: true,
|
||||
aktenzeichenGericht: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
titel,
|
||||
betreute: {
|
||||
name: [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' '),
|
||||
geburtsdatum: betreute.geburtsdatum,
|
||||
},
|
||||
behoerde: betreute.betreuungsgericht ?? undefined,
|
||||
aktenzeichen: betreute.aktenzeichenGericht ?? undefined,
|
||||
zeitraumVon: angaben.zeitraumVon ?? null,
|
||||
zeitraumBis: angaben.zeitraumBis ?? null,
|
||||
stichtag: angaben.stichtag ?? null,
|
||||
rechtsgrundlage: angaben.rechtsgrundlage,
|
||||
erstelltAm: angaben.erstelltAm,
|
||||
erstelltVon: angaben.erstelltVon,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Jahresbericht
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Legt einen Jahresbericht an und fuellt ihn mit dem Textentwurf. Gibt es
|
||||
* fuer denselben Zeitraum schon einen, wird er zurueckgegeben statt eines
|
||||
* zweiten - sonst haette man am Ende drei Fassungen und wuesste nicht mehr,
|
||||
* welche abgegeben wurde.
|
||||
*/
|
||||
export async function jahresberichtAnlegen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = jahresberichtErzeugenSchema.safeParse({
|
||||
betreuteId: formular.get('betreuteId'),
|
||||
zeitraumVon: formular.get('zeitraumVon'),
|
||||
zeitraumBis: formular.get('zeitraumBis'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const { betreuteId, zeitraumVon, zeitraumBis } = gelesen.data
|
||||
const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') {
|
||||
return { fehler: 'Als Mitleser darfst du keine Berichte erzeugen.' }
|
||||
}
|
||||
|
||||
const vorhanden = await db.bericht.findFirst({
|
||||
where: { betreuteId, art: 'JAHRESBERICHT', zeitraumVon, zeitraumBis, ...nichtGeloescht },
|
||||
})
|
||||
if (vorhanden) {
|
||||
return {
|
||||
berichtId: vorhanden.id,
|
||||
erfolg: 'Für diesen Zeitraum gibt es bereits einen Entwurf. Er wird jetzt geöffnet.',
|
||||
}
|
||||
}
|
||||
|
||||
const entwurf = await jahresberichtEntwurfAusDatenbank(betreuteId, zeitraumVon, zeitraumBis)
|
||||
|
||||
const bericht = await db.bericht.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
art: 'JAHRESBERICHT',
|
||||
titel: `Jahresbericht ${zeitraumVon.getUTCFullYear()}/${zeitraumBis.getUTCFullYear()}`,
|
||||
zeitraumVon,
|
||||
zeitraumBis,
|
||||
erstelltVonId: benutzer.id,
|
||||
...sealen('Bericht', { entwurf }),
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: bericht.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/berichte')
|
||||
return { berichtId: bericht.id, erfolg: 'Der Entwurf steht. Jetzt bist du dran: durchlesen und überarbeiten.' }
|
||||
}
|
||||
|
||||
/** Zwischenspeichern des ueberarbeiteten Textes. */
|
||||
export async function entwurfSpeichern(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = entwurfSpeichernSchema.safeParse({
|
||||
berichtId: formular.get('berichtId'),
|
||||
entwurf: formular.get('entwurf'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Der Entwurf konnte nicht gespeichert werden.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const bericht = await db.bericht.findFirst({
|
||||
where: { id: gelesen.data.berichtId, ...nichtGeloescht },
|
||||
select: { id: true, betreuteId: true, titel: true },
|
||||
})
|
||||
if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
|
||||
|
||||
const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts speichern.' }
|
||||
|
||||
await db.bericht.update({
|
||||
where: { id: bericht.id },
|
||||
data: sealen('Bericht', { entwurf: gelesen.data.entwurf }),
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: bericht.titel,
|
||||
betreuteId: bericht.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: { entwurf: { alt: '(überarbeitet)', neu: `${gelesen.data.entwurf.length} Zeichen` } },
|
||||
})
|
||||
|
||||
revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
|
||||
return { erfolg: `Zwischengespeichert um ${new Date().toLocaleTimeString('de-DE')}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den Entwurf aus den aktuellen Daten neu. Der bisherige Text
|
||||
* wird dabei ersetzt - deshalb fragt die Oberflaeche vorher nach.
|
||||
*/
|
||||
export async function entwurfNeuBerechnen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const berichtId = String(formular.get('berichtId') ?? '')
|
||||
const bericht = await db.bericht.findFirst({
|
||||
where: { id: berichtId, art: 'JAHRESBERICHT', ...nichtGeloescht },
|
||||
})
|
||||
if (!bericht || !bericht.zeitraumVon || !bericht.zeitraumBis) {
|
||||
return { fehler: 'Zu diesem Bericht fehlt der Zeitraum.' }
|
||||
}
|
||||
|
||||
const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts ändern.' }
|
||||
|
||||
const entwurf = await jahresberichtEntwurfAusDatenbank(
|
||||
bericht.betreuteId,
|
||||
bericht.zeitraumVon,
|
||||
bericht.zeitraumBis,
|
||||
)
|
||||
await db.bericht.update({ where: { id: bericht.id }, data: sealen('Bericht', { entwurf }) })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: `${bericht.titel} (Entwurf neu berechnet)`,
|
||||
betreuteId: bericht.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
|
||||
return { erfolg: 'Der Entwurf wurde aus den aktuellen Daten neu aufgebaut.' }
|
||||
}
|
||||
|
||||
/** Erzeugt PDF oder DOCX aus dem gespeicherten Entwurfstext. */
|
||||
export async function jahresberichtAusgeben(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const berichtId = String(formular.get('berichtId') ?? '')
|
||||
const format = String(formular.get('format') ?? 'pdf') === 'docx' ? 'docx' : 'pdf'
|
||||
|
||||
const bericht = await db.bericht.findFirst({
|
||||
where: { id: berichtId, art: 'JAHRESBERICHT', ...nichtGeloescht },
|
||||
})
|
||||
if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
|
||||
|
||||
const { benutzer, betreute, zugang } = await erfordereZugang(
|
||||
bericht.betreuteId,
|
||||
'BERICHTE',
|
||||
'schreiben',
|
||||
)
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Berichte erzeugen.' }
|
||||
|
||||
const entwurf = entschluesselnWennMoeglich(bericht.entwurfVerschluesselt, 'entwurfVerschluesselt')
|
||||
if (!entwurf || entwurf.trim() === '') {
|
||||
return { fehler: 'Der Entwurf ist leer. Schreib erst den Text, dann die Datei.' }
|
||||
}
|
||||
|
||||
const erstelltAm = new Date()
|
||||
const kopf = await kopfBauen(bericht.betreuteId, bericht.titel, {
|
||||
zeitraumVon: bericht.zeitraumVon,
|
||||
zeitraumBis: bericht.zeitraumBis,
|
||||
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
|
||||
erstelltVon: benutzer.name,
|
||||
erstelltAm,
|
||||
})
|
||||
|
||||
const inhalt =
|
||||
format === 'docx' ? await docxErzeugen(kopf, entwurf) : await jahresberichtPdf(kopf, entwurf)
|
||||
const dateiname = berichtDateiname('jahresbericht', betreute.nachname, erstelltAm, format)
|
||||
const mimeTyp =
|
||||
format === 'docx'
|
||||
? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
: 'application/pdf'
|
||||
|
||||
const ziel = await ablegen(bericht.betreuteId, 'berichte', dateiname, inhalt, mimeTyp)
|
||||
|
||||
// Die PDF-Fassung ist die abzugebende und wird am Bericht vermerkt; die
|
||||
// DOCX ist Arbeitsfassung und bekommt einen eigenen Datensatz, damit die
|
||||
// abgegebene Fassung nicht ueberschrieben wird.
|
||||
if (format === 'pdf') {
|
||||
await db.bericht.update({
|
||||
where: { id: bericht.id },
|
||||
data: { objektSchluessel: ziel.schluessel, dateiname, pruefsumme: ziel.pruefsumme },
|
||||
})
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: `${bericht.titel} als ${format.toUpperCase()}`,
|
||||
betreuteId: bericht.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
|
||||
revalidatePath('/berichte')
|
||||
return {
|
||||
erfolg:
|
||||
format === 'docx'
|
||||
? 'Die Word-Fassung liegt bereit. Sie ist die Arbeitsfassung – abgegeben wird das PDF.'
|
||||
: 'Das PDF liegt bereit. Prüfe es, bevor du es abgibst.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Haelt fest, wann und wem der Bericht vorgelegt wurde. */
|
||||
export async function abgabeVermerken(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = berichtAbgabeSchema.safeParse({
|
||||
berichtId: formular.get('berichtId'),
|
||||
abgegebenAm: formular.get('abgegebenAm'),
|
||||
abgegebenAn: formular.get('abgegebenAn'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const bericht = await db.bericht.findFirst({
|
||||
where: { id: gelesen.data.berichtId, ...nichtGeloescht },
|
||||
select: { id: true, betreuteId: true, titel: true },
|
||||
})
|
||||
if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
|
||||
|
||||
const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts vermerken.' }
|
||||
|
||||
await db.bericht.update({
|
||||
where: { id: bericht.id },
|
||||
data: { abgegebenAm: gelesen.data.abgegebenAm, abgegebenAn: gelesen.data.abgegebenAn },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: `${bericht.titel} abgegeben an ${gelesen.data.abgegebenAn}`,
|
||||
betreuteId: bericht.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/berichte')
|
||||
return { erfolg: 'Abgabe vermerkt.' }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Belegmappe
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Belegmappe fuer einen Zeitraum oder einen Vorgang. Das Zusammenstellen
|
||||
* selbst liegt beim Dokumentenmodul - dort sitzt das Wissen darueber, wie
|
||||
* ein Beleg zu einer Buchung gehoert.
|
||||
*/
|
||||
export async function belegmappeAnlegen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = belegmappeSchema.safeParse({
|
||||
betreuteId: formular.get('betreuteId'),
|
||||
quelle: formular.get('quelle'),
|
||||
zeitraumVon: formular.get('zeitraumVon') ?? '',
|
||||
zeitraumBis: formular.get('zeitraumBis') ?? '',
|
||||
vorgangId: formular.get('vorgangId') ?? '',
|
||||
titel: formular.get('titel') ?? '',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const { betreuteId, quelle, zeitraumVon, zeitraumBis, vorgangId, titel } = gelesen.data
|
||||
const { benutzer, betreute, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Belegmappe erzeugen.' }
|
||||
|
||||
const erstelltAm = new Date()
|
||||
|
||||
let ergebnis: { pdf: Buffer; dateiname: string; anzahlBelege: number }
|
||||
try {
|
||||
ergebnis = await belegmappeErzeugen({
|
||||
betreuteId,
|
||||
zeitraumVon: quelle === 'zeitraum' ? zeitraumVon : null,
|
||||
zeitraumBis: quelle === 'zeitraum' ? zeitraumBis : null,
|
||||
vorgangId: quelle === 'vorgang' ? (vorgangId ?? null) : null,
|
||||
titel: titel && titel !== '' ? titel : 'Belegmappe',
|
||||
erstelltVon: benutzer.name,
|
||||
erstelltAm,
|
||||
})
|
||||
} catch (fehler) {
|
||||
return {
|
||||
fehler:
|
||||
'Die Belegmappe konnte nicht erzeugt werden: ' +
|
||||
`${(fehler as Error).message}. Prüfe, ob zu dieser Auswahl überhaupt Belege hinterlegt sind.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (ergebnis.anzahlBelege === 0) {
|
||||
return { fehler: 'Zu dieser Auswahl gibt es keine Belege. Wähle einen anderen Zeitraum oder Vorgang.' }
|
||||
}
|
||||
|
||||
const ziel = await ablegen(betreuteId, 'berichte', ergebnis.dateiname, ergebnis.pdf, 'application/pdf')
|
||||
|
||||
const bericht = await db.bericht.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
art: 'BELEGMAPPE',
|
||||
titel:
|
||||
titel && titel !== ''
|
||||
? titel
|
||||
: quelle === 'zeitraum'
|
||||
? `Belegmappe ${zeitraumVon?.getUTCFullYear() ?? ''}`
|
||||
: 'Belegmappe zum Vorgang',
|
||||
zeitraumVon: quelle === 'zeitraum' ? zeitraumVon : null,
|
||||
zeitraumBis: quelle === 'zeitraum' ? zeitraumBis : null,
|
||||
objektSchluessel: ziel.schluessel,
|
||||
dateiname: ergebnis.dateiname,
|
||||
pruefsumme: ziel.pruefsumme,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: `${bericht.titel} (${ergebnis.anzahlBelege} Belege, ${betreute.nachname})`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/berichte')
|
||||
return { berichtId: bericht.id, erfolg: `Belegmappe mit ${ergebnis.anzahlBelege} Belegen erzeugt.` }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Unterlagenpaket
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function unterlagenpaketAnlegen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = unterlagenpaketSchema.safeParse({
|
||||
betreuteId: formular.get('betreuteId'),
|
||||
antragId: formular.get('antragId'),
|
||||
nachforderungIds: formular.getAll('nachforderungIds').map(String),
|
||||
anschreibenZusatz: formular.get('anschreibenZusatz') ?? '',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const { betreuteId, antragId, nachforderungIds, anschreibenZusatz } = gelesen.data
|
||||
const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du kein Paket erzeugen.' }
|
||||
// Die Unterlagen stammen aus der Sozialhilfeakte - auch dort muss gelesen
|
||||
// werden duerfen.
|
||||
await erfordereZugang(betreuteId, 'SOZIALHILFE', 'lesen')
|
||||
|
||||
const erstelltAm = new Date()
|
||||
const ergebnis = await unterlagenpaketErzeugen({
|
||||
betreuteId,
|
||||
antragId,
|
||||
nachforderungIds,
|
||||
anschreibenZusatz,
|
||||
erstelltVon: benutzer.name,
|
||||
erstelltAm,
|
||||
})
|
||||
|
||||
const ziel = await ablegen(
|
||||
betreuteId,
|
||||
'berichte',
|
||||
ergebnis.dateiname,
|
||||
ergebnis.pdf,
|
||||
'application/pdf',
|
||||
)
|
||||
|
||||
const bericht = await db.bericht.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
art: 'UNTERLAGENPAKET_BEZIRK',
|
||||
titel: `Unterlagen an den Träger (${ergebnis.aufstellung.length} Positionen)`,
|
||||
stichtag: erstelltAm,
|
||||
objektSchluessel: ziel.schluessel,
|
||||
dateiname: ergebnis.dateiname,
|
||||
pruefsumme: ziel.pruefsumme,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Positionen, die tatsaechlich mitgehen, gelten als uebersandt.
|
||||
const uebersandt = ergebnis.aufstellung.filter((a) => a.beigefuegt).map((a) => a.unterlage.id)
|
||||
if (uebersandt.length > 0) {
|
||||
await db.nachforderung.updateMany({
|
||||
where: { id: { in: uebersandt }, antragId },
|
||||
data: { status: 'UEBERSANDT', uebersandtAm: erstelltAm },
|
||||
})
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: bericht.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/berichte')
|
||||
revalidatePath('/sozialhilfe')
|
||||
|
||||
const uebersprungen =
|
||||
ergebnis.uebersprungen.length > 0
|
||||
? ` Nicht lesbar und deshalb nicht enthalten: ${ergebnis.uebersprungen
|
||||
.map((u) => u.bezeichnung)
|
||||
.join(', ')}.`
|
||||
: ''
|
||||
|
||||
return {
|
||||
berichtId: bericht.id,
|
||||
erfolg: `Das Paket ist fertig: ${uebersandt.length} beigefügte Unterlagen.${uebersprungen}`,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Berichte loeschen (Papierkorb)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function berichtLoeschen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const berichtId = String(formular.get('berichtId') ?? '')
|
||||
const bericht = await db.bericht.findFirst({
|
||||
where: { id: berichtId, ...nichtGeloescht },
|
||||
select: { id: true, betreuteId: true, titel: true, art: true },
|
||||
})
|
||||
if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
|
||||
|
||||
const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
|
||||
|
||||
await db.bericht.update({ where: { id: bericht.id }, data: { geloeschtAm: new Date() } })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: bericht.titel,
|
||||
betreuteId: bericht.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/berichte')
|
||||
return { erfolg: 'In den Papierkorb verschoben. Dort liegt er 30 Tage.' }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vollexport
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Der Export selbst laeuft ueber die Route unter /berichte/export/datei,
|
||||
* weil ein ZIP als Strom ausgeliefert wird und eine Server Action keinen
|
||||
* Strom zurueckgeben kann. Diese Aktion sorgt fuer die frische Anmeldung
|
||||
* und schreibt Protokoll und Bericht-Datensatz - damit spaeter nachweisbar
|
||||
* ist, wann Daten das Haus verlassen haben.
|
||||
*/
|
||||
export async function exportVorbereiten(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
|
||||
|
||||
if (zugang.rolle !== 'BETREUER' && zugang.rolle !== 'ADMIN') {
|
||||
return { fehler: 'Einen Vollexport darf nur der Betreuer oder ein Administrator erzeugen.' }
|
||||
}
|
||||
|
||||
await erfordereFrischeAnmeldung(benutzer, '/berichte/export')
|
||||
return { erfolg: 'Bestätigt. Der Export kann jetzt geladen werden.' }
|
||||
}
|
||||
|
||||
/** Vermerkt einen erfolgten Export - aufgerufen von der Downloadroute. */
|
||||
export async function exportProtokollieren(angaben: {
|
||||
betreuteId: string
|
||||
dateiname: string
|
||||
art?: BerichtArt
|
||||
}): Promise<void> {
|
||||
const { benutzer } = await erfordereZugang(angaben.betreuteId, 'BERICHTE', 'schreiben')
|
||||
|
||||
const bericht = await db.bericht.create({
|
||||
data: {
|
||||
betreuteId: angaben.betreuteId,
|
||||
art: angaben.art ?? 'VOLLEXPORT',
|
||||
titel: 'Vollexport aller Daten',
|
||||
stichtag: new Date(),
|
||||
dateiname: angaben.dateiname,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'EXPORTIERT',
|
||||
entitaet: 'Betreute',
|
||||
entitaetId: angaben.betreuteId,
|
||||
bezeichnung: `Vollexport ${angaben.dateiname}`,
|
||||
betreuteId: angaben.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: { export: { alt: null, neu: bericht.id } },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use server'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
|
||||
import { passwortPruefen } from '@/lib/crypto/hash'
|
||||
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
|
||||
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
|
||||
|
||||
/**
|
||||
* Erneute Bestaetigung vor sensiblen Aktionen.
|
||||
*
|
||||
* Betroffen sind: Vollexport, endgueltiges Loeschen, Benutzerverwaltung,
|
||||
* Aendern von Rollen und Freigaben, neue Wiederherstellungscodes.
|
||||
*
|
||||
* Warum ueberhaupt? Eine offene Sitzung an einem unbeaufsichtigten Rechner
|
||||
* ist der wahrscheinlichste Angriffsweg in diesem Umfeld - nicht der
|
||||
* Einbruch in den Server. Wer alle Gesundheits- und Finanzdaten in einer
|
||||
* ZIP-Datei mitnehmen will, soll dafuer Passwort und Code erneut eingeben.
|
||||
*/
|
||||
|
||||
const BestaetigungSchema = z.object({
|
||||
passwort: z.string().min(1, 'Bitte dein Passwort eingeben.'),
|
||||
code: z.string().trim(),
|
||||
weiter: z.string().default('/heute'),
|
||||
})
|
||||
|
||||
export type BestaetigenZustand = { fehler?: string }
|
||||
|
||||
function sicheresZiel(ziel: string): string {
|
||||
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
|
||||
return ziel
|
||||
}
|
||||
|
||||
export async function anmeldungBestaetigen(
|
||||
_vorher: BestaetigenZustand,
|
||||
formular: FormData,
|
||||
): Promise<BestaetigenZustand> {
|
||||
const begonnen = Date.now()
|
||||
const benutzer = await erfordereBenutzer()
|
||||
|
||||
const gelesen = BestaetigungSchema.safeParse({
|
||||
passwort: formular.get('passwort'),
|
||||
code: formular.get('code'),
|
||||
weiter: formular.get('weiter') ?? '/heute',
|
||||
})
|
||||
|
||||
if (!gelesen.success) {
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
return { fehler: 'Bitte Passwort und Code eingeben.' }
|
||||
}
|
||||
|
||||
const frisch = await db.user.findUnique({ where: { id: benutzer.id } })
|
||||
if (!frisch) return { fehler: 'Der Zugang wurde zwischenzeitlich entfernt.' }
|
||||
|
||||
const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort)
|
||||
|
||||
let codeStimmt = true
|
||||
if (frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
|
||||
const secret = totpSecretEntschluesseln(frisch.totpSecretVerschluesselt)
|
||||
codeStimmt = totpPruefen(secret, frisch.email, gelesen.data.code).gueltig
|
||||
}
|
||||
|
||||
if (!passwortStimmt || !codeStimmt) {
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
return { fehler: 'Passwort oder Code stimmen nicht.' }
|
||||
}
|
||||
|
||||
await reauthBestaetigen(benutzer.id)
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
|
||||
redirect(sicheresZiel(gelesen.data.weiter))
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
'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.'
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import { blindindex } from '@/lib/crypto/hash'
|
||||
import { alsTag } from '@/lib/finanzen/datum'
|
||||
import { ibanMaskieren } from '@/lib/finanzen/iban'
|
||||
import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import'
|
||||
import { camtZuBuchungen } from '@/lib/finanzen/camt'
|
||||
import {
|
||||
buchungSchema,
|
||||
fehlerAuflisten,
|
||||
formularZuObjekt,
|
||||
importSchema,
|
||||
kontoSchema,
|
||||
saldostandSchema,
|
||||
vermoegenspostenSchema,
|
||||
wertstandSchema,
|
||||
} from '@/lib/validierung/finanzen'
|
||||
|
||||
/**
|
||||
* Server Actions des Finanzmoduls.
|
||||
*
|
||||
* Grundsaetze, die hier ohne Ausnahme gelten:
|
||||
*
|
||||
* - Jede Aktion prueft den Zugriff selbst (erfordereZugang), nicht nur
|
||||
* die Seite davor. Ein Formular laesst sich nachbauen.
|
||||
* - Jede Aktion validiert mit Zod, bevor irgendetwas geschrieben wird.
|
||||
* - Jede schreibende Aktion landet im Audit-Log. Das ist mein Nachweis
|
||||
* gegenueber Gericht und moeglichen Miterben.
|
||||
* - Fehler werden als Zustand zurueckgegeben, nicht geworfen - das
|
||||
* Formular soll die Eingaben behalten und die Meldung am Feld zeigen.
|
||||
*/
|
||||
|
||||
export type Formularzustand = {
|
||||
fehler?: string
|
||||
felder?: Record<string, string>
|
||||
erfolg?: string
|
||||
/** Frei verwendbare Rueckmeldung, z.B. die Zusammenfassung eines Imports. */
|
||||
meldung?: string[]
|
||||
}
|
||||
|
||||
const LEER: Formularzustand = {}
|
||||
|
||||
function zugriffsfehler(ausnahme: unknown): Formularzustand | null {
|
||||
if (ausnahme instanceof ZugriffVerweigert) {
|
||||
return { fehler: ausnahme.message }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Prisma meldet eine verletzte Eindeutigkeit mit P2002. */
|
||||
function istDublette(ausnahme: unknown): boolean {
|
||||
return (
|
||||
ausnahme instanceof Prisma.PrismaClientKnownRequestError && ausnahme.code === 'P2002'
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Konten
|
||||
// =============================================================================
|
||||
|
||||
export async function kontoSpeichern(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const kontoId = String(daten.get('kontoId') ?? '')
|
||||
if (!betreuteId) return { fehler: 'Es ist keine betreute Person ausgewählt.' }
|
||||
|
||||
let ziel: string
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
|
||||
const gepruft = kontoSchema.safeParse(formularZuObjekt(daten))
|
||||
if (!gepruft.success) {
|
||||
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
const ibanIndex = blindindex(werte.iban)
|
||||
|
||||
// "Kenne ich dieses Konto schon?" - beantwortbar ohne die IBAN im
|
||||
// Klartext zu halten.
|
||||
if (ibanIndex) {
|
||||
const vorhanden = await db.konto.findFirst({
|
||||
where: { betreuteId, ibanIndex, ...nichtGeloescht, ...(kontoId ? { id: { not: kontoId } } : {}) },
|
||||
select: { id: true, bezeichnung: true },
|
||||
})
|
||||
if (vorhanden) {
|
||||
return {
|
||||
fehler: `Diese IBAN ist bereits als „${vorhanden.bezeichnung}“ erfasst.`,
|
||||
felder: { iban: 'Dieses Konto gibt es schon.' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const felder = {
|
||||
bezeichnung: werte.bezeichnung,
|
||||
bank: werte.bank,
|
||||
art: werte.art,
|
||||
bic: werte.bic,
|
||||
istBetreuerkonto: werte.istBetreuerkonto,
|
||||
eroeffnetAm: werte.eroeffnetAm,
|
||||
geschlossenAm: werte.geschlossenAm,
|
||||
ibanIndex,
|
||||
...sealen('Konto', { iban: werte.iban }),
|
||||
}
|
||||
|
||||
if (kontoId) {
|
||||
const vorher = await db.konto.findFirst({
|
||||
where: { id: kontoId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorher) return { fehler: 'Dieses Konto gibt es nicht (mehr).' }
|
||||
|
||||
const nachher = await db.konto.update({ where: { id: kontoId }, data: felder })
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Konto',
|
||||
entitaetId: kontoId,
|
||||
bezeichnung: nachher.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorher, nachher),
|
||||
})
|
||||
ziel = `/finanzen/konten/${kontoId}`
|
||||
} else {
|
||||
const neu = await db.konto.create({ data: { betreuteId, ...felder } })
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Konto',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
ziel = `/finanzen/konten/${neu.id}`
|
||||
}
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Konto konnte nicht gespeichert werden:', ausnahme)
|
||||
return { fehler: 'Das Konto konnte nicht gespeichert werden. Bitte noch einmal versuchen.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/konten')
|
||||
redirect(ziel)
|
||||
}
|
||||
|
||||
export async function kontoLoeschen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const kontoId = String(daten.get('kontoId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
const konto = await db.konto.findFirst({ where: { id: kontoId, betreuteId, ...nichtGeloescht } })
|
||||
if (!konto) return { fehler: 'Dieses Konto gibt es nicht (mehr).' }
|
||||
|
||||
const buchungen = await db.buchung.count({ where: { kontoId, ...nichtGeloescht } })
|
||||
if (buchungen > 0) {
|
||||
return {
|
||||
fehler:
|
||||
`Zu diesem Konto gibt es noch ${buchungen} Buchung(en). Ein Konto mit Buchungen wird ` +
|
||||
'nicht gelöscht – trage stattdessen ein Schließungsdatum ein, dann bleibt die ' +
|
||||
'Rechnungslegung nachvollziehbar.',
|
||||
}
|
||||
}
|
||||
|
||||
await db.konto.update({ where: { id: kontoId }, data: { geloeschtAm: new Date() } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Konto',
|
||||
entitaetId: kontoId,
|
||||
bezeichnung: konto.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Konto konnte nicht gelöscht werden:', ausnahme)
|
||||
return { fehler: 'Das Konto konnte nicht in den Papierkorb gelegt werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen/konten')
|
||||
redirect('/finanzen/konten')
|
||||
}
|
||||
|
||||
/**
|
||||
* Saldostaende werden nie ueberschrieben. Gibt es zum selben Stichtag
|
||||
* schon einen Stand, wird das gemeldet - wer korrigieren will, loescht
|
||||
* den alten bewusst.
|
||||
*/
|
||||
export async function saldostandAnlegen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
|
||||
const gepruft = saldostandSchema.safeParse(formularZuObjekt(daten))
|
||||
if (!gepruft.success) {
|
||||
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true, bezeichnung: true },
|
||||
})
|
||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
||||
|
||||
try {
|
||||
const stand = await db.saldostand.create({
|
||||
data: {
|
||||
kontoId: konto.id,
|
||||
stichtag: werte.stichtag,
|
||||
betragCent: werte.betrag,
|
||||
quelle: werte.quelle,
|
||||
},
|
||||
})
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Saldostand',
|
||||
entitaetId: stand.id,
|
||||
bezeichnung: `${konto.bezeichnung} zum ${werte.stichtag.toISOString().slice(0, 10)}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
if (istDublette(ausnahme)) {
|
||||
return {
|
||||
fehler: 'Für dieses Konto gibt es zu diesem Stichtag bereits einen Stand.',
|
||||
felder: { stichtag: 'Stichtag ist schon belegt.' },
|
||||
}
|
||||
}
|
||||
throw ausnahme
|
||||
}
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Saldostand konnte nicht angelegt werden:', ausnahme)
|
||||
return { fehler: 'Der Kontostand konnte nicht gespeichert werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/konten')
|
||||
return { erfolg: 'Kontostand gespeichert.' }
|
||||
}
|
||||
|
||||
export async function saldostandLoeschen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const saldostandId = String(daten.get('saldostandId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
const stand = await db.saldostand.findFirst({
|
||||
where: { id: saldostandId, konto: { betreuteId } },
|
||||
include: { konto: { select: { bezeichnung: true } } },
|
||||
})
|
||||
if (!stand) return { fehler: 'Diesen Kontostand gibt es nicht (mehr).' }
|
||||
|
||||
await db.saldostand.delete({ where: { id: saldostandId } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Saldostand',
|
||||
entitaetId: saldostandId,
|
||||
bezeichnung: `${stand.konto.bezeichnung} zum ${stand.stichtag.toISOString().slice(0, 10)}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Saldostand konnte nicht gelöscht werden:', ausnahme)
|
||||
return { fehler: 'Der Kontostand konnte nicht gelöscht werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/konten')
|
||||
return { erfolg: 'Kontostand gelöscht.' }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Buchungen
|
||||
// =============================================================================
|
||||
|
||||
export async function buchungSpeichern(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const buchungId = String(daten.get('buchungId') ?? '')
|
||||
const weiterErfassen = daten.get('weiterErfassen') === 'on'
|
||||
|
||||
let ziel: string | null = null
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
|
||||
const gepruft = buchungSchema.safeParse(formularZuObjekt(daten))
|
||||
if (!gepruft.success) {
|
||||
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
if (werte.kontoId) {
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
if (werte.belegId) {
|
||||
const beleg = await db.dokument.findFirst({
|
||||
where: { id: werte.belegId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!beleg) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
|
||||
const felder = {
|
||||
kontoId: werte.kontoId,
|
||||
vorgangId: werte.vorgangId,
|
||||
datum: werte.datum,
|
||||
wertstellung: werte.wertstellung,
|
||||
betragCent: werte.betrag,
|
||||
richtung: werte.richtung,
|
||||
kategorie: werte.kategorie,
|
||||
gegenstelle: werte.gegenstelle,
|
||||
belegId: werte.belegId,
|
||||
belegFehltBegruendung: werte.belegId ? null : werte.belegFehltBegruendung,
|
||||
betrifftBetreute: werte.betrifftBetreute,
|
||||
vermoegenswirksam: werte.vermoegenswirksam,
|
||||
...sealen('Buchung', { verwendungszweck: werte.verwendungszweck }),
|
||||
}
|
||||
|
||||
if (buchungId) {
|
||||
const vorher = await db.buchung.findFirst({
|
||||
where: { id: buchungId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorher) return { fehler: 'Diese Buchung gibt es nicht (mehr).' }
|
||||
|
||||
const nachher = await db.buchung.update({ where: { id: buchungId }, data: felder })
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Buchung',
|
||||
entitaetId: buchungId,
|
||||
bezeichnung: `${werte.datum.toISOString().slice(0, 10)} · ${werte.gegenstelle ?? 'ohne Gegenstelle'}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorher, nachher),
|
||||
})
|
||||
ziel = '/finanzen/buchungen'
|
||||
} else {
|
||||
const neu = await db.buchung.create({ data: { betreuteId, ...felder } })
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Buchung',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: `${werte.datum.toISOString().slice(0, 10)} · ${werte.gegenstelle ?? 'ohne Gegenstelle'}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
ziel = weiterErfassen ? null : '/finanzen/buchungen'
|
||||
}
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Buchung konnte nicht gespeichert werden:', ausnahme)
|
||||
return { fehler: 'Die Buchung konnte nicht gespeichert werden. Bitte noch einmal versuchen.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/buchungen')
|
||||
if (ziel) redirect(ziel)
|
||||
return { erfolg: 'Buchung gespeichert. Du kannst gleich die nächste erfassen.' }
|
||||
}
|
||||
|
||||
export async function buchungLoeschen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const buchungId = String(daten.get('buchungId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
const buchung = await db.buchung.findFirst({
|
||||
where: { id: buchungId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!buchung) return { fehler: 'Diese Buchung gibt es nicht (mehr).' }
|
||||
|
||||
await db.buchung.update({ where: { id: buchungId }, data: { geloeschtAm: new Date() } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Buchung',
|
||||
entitaetId: buchungId,
|
||||
bezeichnung: `${buchung.datum.toISOString().slice(0, 10)} · ${buchung.gegenstelle ?? 'ohne Gegenstelle'}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Buchung konnte nicht gelöscht werden:', ausnahme)
|
||||
return { fehler: 'Die Buchung konnte nicht in den Papierkorb gelegt werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/buchungen')
|
||||
return { erfolg: 'Buchung in den Papierkorb gelegt.' }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Import
|
||||
// =============================================================================
|
||||
|
||||
const IMPORT_MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Liest eine CSV- oder CAMT-Datei und legt die enthaltenen Umsaetze an.
|
||||
*
|
||||
* Was schon einmal importiert wurde, wird uebersprungen (Dublettenschutz
|
||||
* ueber importReferenz) und im Ergebnis genannt. Importierte Buchungen
|
||||
* kommen ohne Kategorie herein - die wird nachtraeglich gesetzt; eine
|
||||
* automatisch geratene Kategorie waere in der Rechnungslegung schlimmer
|
||||
* als gar keine.
|
||||
*/
|
||||
export async function importAusfuehren(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
|
||||
const gepruft = importSchema.safeParse(formularZuObjekt(daten))
|
||||
if (!gepruft.success) {
|
||||
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
const datei = daten.get('datei')
|
||||
if (!(datei instanceof File) || datei.size === 0) {
|
||||
return { fehler: 'Bitte eine Datei auswählen.', felder: { datei: 'Es wurde keine Datei ausgewählt.' } }
|
||||
}
|
||||
if (datei.size > IMPORT_MAX_BYTES) {
|
||||
return { fehler: 'Die Datei ist größer als 8 MB. Bitte den Zeitraum kleiner wählen.' }
|
||||
}
|
||||
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
|
||||
})
|
||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
||||
|
||||
const puffer = Buffer.from(await datei.arrayBuffer())
|
||||
const istXml = /\.xml$/i.test(datei.name) || puffer.subarray(0, 512).toString('utf8').includes('<Document')
|
||||
|
||||
let gelesen
|
||||
try {
|
||||
gelesen = istXml ? camtZuBuchungen(puffer) : csvZuBuchungen(csvTextLesen(puffer))
|
||||
} catch (ausnahme) {
|
||||
return {
|
||||
fehler:
|
||||
ausnahme instanceof Error
|
||||
? ausnahme.message
|
||||
: 'Die Datei konnte nicht gelesen werden.',
|
||||
}
|
||||
}
|
||||
|
||||
if (gelesen.zeilen.length === 0) {
|
||||
return {
|
||||
fehler: 'In der Datei war kein einziger lesbarer Umsatz.',
|
||||
meldung: gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`),
|
||||
}
|
||||
}
|
||||
|
||||
const vorhanden = await db.buchung.findMany({
|
||||
where: { kontoId: konto.id, importReferenz: { not: null } },
|
||||
select: { importReferenz: true },
|
||||
})
|
||||
const { zeilen, dubletten } = dublettenMarkieren(
|
||||
gelesen.zeilen,
|
||||
vorhanden.map((b) => b.importReferenz).filter((r): r is string => r !== null),
|
||||
)
|
||||
|
||||
const anzulegen = zeilen.filter((z) => werte.dublettenUebernehmen || !z.dublette)
|
||||
|
||||
let angelegt = 0
|
||||
for (const zeile of anzulegen) {
|
||||
try {
|
||||
await db.buchung.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
kontoId: konto.id,
|
||||
datum: alsTag(zeile.datum),
|
||||
wertstellung: zeile.wertstellung ? alsTag(zeile.wertstellung) : null,
|
||||
betragCent: zeile.betragCent,
|
||||
richtung: zeile.richtung,
|
||||
kategorie: zeile.richtung === 'EINNAHME' ? 'SONSTIGE_EINNAHME' : 'SONSTIGE_AUSGABE',
|
||||
gegenstelle: zeile.gegenstelle,
|
||||
betrifftBetreute: werte.betrifftBetreute,
|
||||
importReferenz: werte.dublettenUebernehmen && zeile.dublette ? null : zeile.importReferenz,
|
||||
...sealen('Buchung', { verwendungszweck: zeile.verwendungszweck }),
|
||||
},
|
||||
})
|
||||
angelegt++
|
||||
} catch (ausnahme) {
|
||||
// Die Eindeutigkeit auf (kontoId, importReferenz) faengt Reste ab,
|
||||
// die zwischen Lesen und Schreiben dazugekommen sind.
|
||||
if (!istDublette(ausnahme)) throw ausnahme
|
||||
}
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Buchung',
|
||||
bezeichnung: `Import „${datei.name}“ auf ${konto.bezeichnung}: ${angelegt} Buchungen`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/buchungen')
|
||||
|
||||
const meldung = [
|
||||
`${angelegt} Buchung(en) angelegt.`,
|
||||
dubletten > 0
|
||||
? `${dubletten} Umsatz/Umsätze waren schon vorhanden und wurden übersprungen.`
|
||||
: 'Es gab keine Dubletten.',
|
||||
...(gelesen.fehler.length > 0
|
||||
? [`${gelesen.fehler.length} Zeile(n) konnten nicht gelesen werden:`]
|
||||
: []),
|
||||
...gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`),
|
||||
'Alle importierten Buchungen stehen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“. ' +
|
||||
'Bitte die Kategorien nachtragen – danach stimmen die Berichte.',
|
||||
]
|
||||
|
||||
return { erfolg: `Import abgeschlossen: ${angelegt} Buchung(en) angelegt.`, meldung }
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Import fehlgeschlagen:', ausnahme)
|
||||
return { fehler: 'Der Import ist fehlgeschlagen. Bitte die Datei prüfen.' }
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Vermoegensposten und Wertstaende
|
||||
// =============================================================================
|
||||
|
||||
export async function vermoegenspostenSpeichern(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const postenId = String(daten.get('postenId') ?? '')
|
||||
|
||||
let ziel: string
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
|
||||
const gepruft = vermoegenspostenSchema.safeParse(formularZuObjekt(daten))
|
||||
if (!gepruft.success) {
|
||||
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
if (werte.kontoId) {
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
|
||||
const felder = {
|
||||
bezeichnung: werte.bezeichnung,
|
||||
art: werte.art,
|
||||
kontoId: werte.kontoId,
|
||||
immobilieId: werte.immobilieId,
|
||||
vertragId: werte.vertragId,
|
||||
bewertungSozialhilfe: werte.bewertungSozialhilfe,
|
||||
...sealen('Vermoegensposten', {
|
||||
bewertungBegruendung: werte.bewertungBegruendung,
|
||||
notizen: werte.notizen,
|
||||
}),
|
||||
}
|
||||
|
||||
if (postenId) {
|
||||
const vorher = await db.vermoegensposten.findFirst({
|
||||
where: { id: postenId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorher) return { fehler: 'Diesen Posten gibt es nicht (mehr).' }
|
||||
const nachher = await db.vermoegensposten.update({ where: { id: postenId }, data: felder })
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vermoegensposten',
|
||||
entitaetId: postenId,
|
||||
bezeichnung: nachher.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorher, nachher),
|
||||
})
|
||||
ziel = `/finanzen/vermoegen/${postenId}`
|
||||
} else {
|
||||
const neu = await db.vermoegensposten.create({ data: { betreuteId, ...felder } })
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Vermoegensposten',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
ziel = `/finanzen/vermoegen/${neu.id}`
|
||||
}
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Vermögensposten konnte nicht gespeichert werden:', ausnahme)
|
||||
return { fehler: 'Der Posten konnte nicht gespeichert werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/vermoegen')
|
||||
redirect(ziel)
|
||||
}
|
||||
|
||||
export async function vermoegenspostenLoeschen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const postenId = String(daten.get('postenId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
const posten = await db.vermoegensposten.findFirst({
|
||||
where: { id: postenId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!posten) return { fehler: 'Diesen Posten gibt es nicht (mehr).' }
|
||||
|
||||
await db.vermoegensposten.update({ where: { id: postenId }, data: { geloeschtAm: new Date() } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Vermoegensposten',
|
||||
entitaetId: postenId,
|
||||
bezeichnung: posten.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Vermögensposten konnte nicht gelöscht werden:', ausnahme)
|
||||
return { fehler: 'Der Posten konnte nicht in den Papierkorb gelegt werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen/vermoegen')
|
||||
redirect('/finanzen/vermoegen')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Wertstand wird angelegt, nie geaendert. Jede Vermoegensuebersicht
|
||||
* muss spaeter reproduzierbar bleiben - deshalb bleibt auch ein
|
||||
* ueberholter Wert stehen.
|
||||
*/
|
||||
export async function wertstandAnlegen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
|
||||
const gepruft = wertstandSchema.safeParse(formularZuObjekt(daten))
|
||||
if (!gepruft.success) {
|
||||
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
const posten = await db.vermoegensposten.findFirst({
|
||||
where: { id: werte.vermoegenspostenId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true, bezeichnung: true, art: true },
|
||||
})
|
||||
if (!posten) return { fehler: 'Dieser Posten gehört nicht zu dieser betreuten Person.' }
|
||||
|
||||
if (posten.art === 'VERBINDLICHKEIT' && werte.wert < 0n) {
|
||||
return {
|
||||
fehler: 'Bitte den Betrag der Verbindlichkeit positiv angeben.',
|
||||
felder: {
|
||||
wert:
|
||||
'Verbindlichkeiten werden mit positivem Betrag geführt – dass sie das Vermögen mindern, ' +
|
||||
'ergibt sich aus der Art des Postens.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (werte.nachweisDokumentId) {
|
||||
const dokument = await db.dokument.findFirst({
|
||||
where: { id: werte.nachweisDokumentId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!dokument) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const stand = await db.wertstand.create({
|
||||
data: {
|
||||
vermoegenspostenId: posten.id,
|
||||
stichtag: werte.stichtag,
|
||||
wertCent: werte.wert,
|
||||
wertquelle: werte.wertquelle,
|
||||
nachweisDokumentId: werte.nachweisDokumentId,
|
||||
bemerkung: werte.bemerkung,
|
||||
},
|
||||
})
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Wertstand',
|
||||
entitaetId: stand.id,
|
||||
bezeichnung: `${posten.bezeichnung} zum ${werte.stichtag.toISOString().slice(0, 10)}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
if (istDublette(ausnahme)) {
|
||||
return {
|
||||
fehler: 'Für diesen Posten gibt es zu diesem Stichtag schon einen Wert.',
|
||||
felder: { stichtag: 'Stichtag ist schon belegt.' },
|
||||
}
|
||||
}
|
||||
throw ausnahme
|
||||
}
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Wertstand konnte nicht angelegt werden:', ausnahme)
|
||||
return { fehler: 'Der Wert konnte nicht gespeichert werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen')
|
||||
revalidatePath('/finanzen/vermoegen')
|
||||
return { erfolg: 'Wert gespeichert. Frühere Werte bleiben erhalten.' }
|
||||
}
|
||||
|
||||
export async function wertstandLoeschen(
|
||||
_zustand: Formularzustand,
|
||||
daten: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const wertstandId = String(daten.get('wertstandId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
|
||||
const stand = await db.wertstand.findFirst({
|
||||
where: { id: wertstandId, vermoegensposten: { betreuteId } },
|
||||
include: { vermoegensposten: { select: { bezeichnung: true } } },
|
||||
})
|
||||
if (!stand) return { fehler: 'Diesen Wert gibt es nicht (mehr).' }
|
||||
|
||||
await db.wertstand.delete({ where: { id: wertstandId } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Wertstand',
|
||||
entitaetId: wertstandId,
|
||||
bezeichnung: `${stand.vermoegensposten.bezeichnung} zum ${stand.stichtag.toISOString().slice(0, 10)}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[finanzen] Wertstand konnte nicht gelöscht werden:', ausnahme)
|
||||
return { fehler: 'Der Wert konnte nicht gelöscht werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/finanzen/vermoegen')
|
||||
return { erfolg: 'Wert gelöscht.' }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Berichte
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Haelt fest, dass ein Bericht erzeugt wurde. Die PDF-Datei selbst wird
|
||||
* beim Abruf erzeugt und ausgeliefert; hier steht nur, was wann in
|
||||
* welcher Fassung abgegeben wurde.
|
||||
*/
|
||||
export async function berichtVermerken(eingabe: {
|
||||
betreuteId: string
|
||||
art: 'VERMOEGENSVERZEICHNIS' | 'VERMOEGENSUEBERSICHT' | 'RECHNUNGSLEGUNG'
|
||||
titel: string
|
||||
stichtag?: Date | null
|
||||
zeitraumVon?: Date | null
|
||||
zeitraumBis?: Date | null
|
||||
dateiname: string
|
||||
}): Promise<void> {
|
||||
const { benutzer } = await erfordereZugang(eingabe.betreuteId, 'FINANZEN', 'lesen')
|
||||
|
||||
const bericht = await db.bericht.create({
|
||||
data: {
|
||||
betreuteId: eingabe.betreuteId,
|
||||
art: eingabe.art,
|
||||
titel: eingabe.titel,
|
||||
stichtag: eingabe.stichtag ?? null,
|
||||
zeitraumVon: eingabe.zeitraumVon ?? null,
|
||||
zeitraumBis: eingabe.zeitraumBis ?? null,
|
||||
dateiname: eingabe.dateiname,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'EXPORTIERT',
|
||||
entitaet: 'Bericht',
|
||||
entitaetId: bericht.id,
|
||||
bezeichnung: eingabe.titel,
|
||||
betreuteId: eingabe.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
/** Kleine Hilfe fuer Formulare, die nur den Anfangszustand brauchen. */
|
||||
export async function leererZustand(): Promise<Formularzustand> {
|
||||
return LEER
|
||||
}
|
||||
|
||||
/** Zeigt die IBAN eines Kontos maskiert - fuer Listen ohne Klartext. */
|
||||
export async function kontoIbanMaskiert(
|
||||
betreuteId: string,
|
||||
kontoId: string,
|
||||
): Promise<string | null> {
|
||||
await erfordereZugang(betreuteId, 'FINANZEN', 'lesen')
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: kontoId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!konto) return null
|
||||
const offen = oeffnen('Konto', konto)
|
||||
return offen.iban ? ibanMaskieren(offen.iban) : null
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Modul } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
import { passwortHashen } from '@/lib/crypto/hash'
|
||||
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
||||
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
||||
import {
|
||||
benutzerAktivSchema,
|
||||
benutzerAnlegenSchema,
|
||||
darfBenutzerVerwalten,
|
||||
darfFreigabenVergeben,
|
||||
freigabeAenderung,
|
||||
freigabenNormalisieren,
|
||||
freigabenSchema,
|
||||
letzterAdminGeschuetzt,
|
||||
rolleAendernSchema,
|
||||
sitzungenBeendenSchema,
|
||||
} from '@/lib/validierung/freigaben'
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung und Modulfreigaben.
|
||||
*
|
||||
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
|
||||
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
|
||||
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
|
||||
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
|
||||
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
|
||||
* wem wann was freigegeben hat.
|
||||
*/
|
||||
|
||||
export type Aktionszustand = {
|
||||
fehler?: string
|
||||
felder?: Record<string, string>
|
||||
erfolg?: string
|
||||
warnung?: string
|
||||
}
|
||||
|
||||
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
|
||||
const felder: Record<string, string> = {}
|
||||
for (const problem of fehler) {
|
||||
const schluessel = String(problem.path[0] ?? 'formular')
|
||||
if (!felder[schluessel]) felder[schluessel] = problem.message
|
||||
}
|
||||
return felder
|
||||
}
|
||||
|
||||
async function administratorSein(weiter: string) {
|
||||
const benutzer = await erfordereBenutzer(weiter)
|
||||
if (!darfBenutzerVerwalten(benutzer.rolle)) {
|
||||
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
|
||||
}
|
||||
await erfordereFrischeAnmeldung(benutzer, weiter)
|
||||
return benutzer
|
||||
}
|
||||
|
||||
async function anzahlAktiverAdmins(): Promise<number> {
|
||||
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Benutzer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function benutzerAnlegen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = benutzerAnlegenSchema.safeParse({
|
||||
name: formular.get('name'),
|
||||
email: formular.get('email'),
|
||||
rolle: formular.get('rolle'),
|
||||
passwort: formular.get('passwort'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const admin = await administratorSein('/einstellungen/benutzer')
|
||||
|
||||
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
|
||||
if (vorhanden) {
|
||||
return {
|
||||
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
|
||||
felder: { email: 'Bereits vergeben.' },
|
||||
}
|
||||
}
|
||||
|
||||
const neu = await db.user.create({
|
||||
data: {
|
||||
name: gelesen.data.name,
|
||||
email: gelesen.data.email,
|
||||
rolle: gelesen.data.rolle,
|
||||
passwortHash: await passwortHashen(gelesen.data.passwort),
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'User',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
|
||||
userId: admin.id,
|
||||
userEmail: admin.email,
|
||||
userName: admin.name,
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/benutzer')
|
||||
return {
|
||||
erfolg:
|
||||
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
|
||||
`werden – ohne ihn kommt niemand an Fachdaten.`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function rolleAendern(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = rolleAendernSchema.safeParse({
|
||||
userId: formular.get('userId'),
|
||||
rolle: formular.get('rolle'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const admin = await administratorSein('/einstellungen/benutzer')
|
||||
|
||||
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
|
||||
|
||||
const schutz = letzterAdminGeschuetzt({
|
||||
zielRolleVorher: ziel.rolle,
|
||||
zielRolleNachher: gelesen.data.rolle,
|
||||
zielBleibtAktiv: ziel.aktiv,
|
||||
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
|
||||
})
|
||||
if (schutz) return { fehler: schutz }
|
||||
|
||||
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
|
||||
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
|
||||
await db.user.update({
|
||||
where: { id: ziel.id },
|
||||
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ROLLE_GEAENDERT',
|
||||
entitaet: 'User',
|
||||
entitaetId: ziel.id,
|
||||
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
|
||||
userId: admin.id,
|
||||
userEmail: admin.email,
|
||||
userName: admin.name,
|
||||
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/benutzer')
|
||||
return {
|
||||
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function benutzerAktivSetzen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = benutzerAktivSchema.safeParse({
|
||||
userId: formular.get('userId'),
|
||||
aktiv: formular.get('aktiv') === 'ja',
|
||||
})
|
||||
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
|
||||
|
||||
const admin = await administratorSein('/einstellungen/benutzer')
|
||||
|
||||
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||
if (ziel.id === admin.id && !gelesen.data.aktiv) {
|
||||
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
|
||||
}
|
||||
|
||||
const schutz = letzterAdminGeschuetzt({
|
||||
zielRolleVorher: ziel.rolle,
|
||||
zielRolleNachher: ziel.rolle,
|
||||
zielBleibtAktiv: gelesen.data.aktiv,
|
||||
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
|
||||
})
|
||||
if (schutz) return { fehler: schutz }
|
||||
|
||||
await db.user.update({
|
||||
where: { id: ziel.id },
|
||||
data: {
|
||||
aktiv: gelesen.data.aktiv,
|
||||
// Wer abgeschaltet wird, ist sofort abgemeldet.
|
||||
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'User',
|
||||
entitaetId: ziel.id,
|
||||
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
|
||||
userId: admin.id,
|
||||
userEmail: admin.email,
|
||||
userName: admin.name,
|
||||
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/benutzer')
|
||||
return {
|
||||
erfolg: gelesen.data.aktiv
|
||||
? `${ziel.name} kann sich wieder anmelden.`
|
||||
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function sitzungenBeenden(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
|
||||
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
|
||||
|
||||
const admin = await administratorSein('/einstellungen/benutzer')
|
||||
|
||||
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||
|
||||
await db.user.update({
|
||||
where: { id: ziel.id },
|
||||
data: { sitzungsGeneration: { increment: 1 } },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'User',
|
||||
entitaetId: ziel.id,
|
||||
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
|
||||
userId: admin.id,
|
||||
userEmail: admin.email,
|
||||
userName: admin.name,
|
||||
aenderung: {
|
||||
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/benutzer')
|
||||
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Freigaben je betreuter Person
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function freigabenSpeichern(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = freigabenSchema.safeParse({
|
||||
userId: formular.get('userId'),
|
||||
betreuteId: formular.get('betreuteId'),
|
||||
rolle: formular.get('rolle'),
|
||||
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||
}
|
||||
|
||||
const { userId, betreuteId, rolle } = gelesen.data
|
||||
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
||||
|
||||
if (!darfFreigabenVergeben(zugang)) {
|
||||
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
|
||||
}
|
||||
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
|
||||
|
||||
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
|
||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||
|
||||
const module = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
|
||||
|
||||
const vorher = await db.betreuungsZugang.findUnique({
|
||||
where: { userId_betreuteId: { userId, betreuteId } },
|
||||
select: { rolle: true, freigegebeneModule: true },
|
||||
})
|
||||
|
||||
const aenderung = freigabeAenderung(
|
||||
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
|
||||
{ rolle, freigegebeneModule: module },
|
||||
)
|
||||
|
||||
await db.betreuungsZugang.upsert({
|
||||
where: { userId_betreuteId: { userId, betreuteId } },
|
||||
create: { userId, betreuteId, rolle, freigegebeneModule: module },
|
||||
update: { rolle, freigegebeneModule: module },
|
||||
})
|
||||
|
||||
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
|
||||
// weiterbenutzen koennen.
|
||||
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
|
||||
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
|
||||
}
|
||||
|
||||
const beschreibung = [
|
||||
aenderung.rolleGeaendert
|
||||
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
|
||||
: null,
|
||||
aenderung.hinzugefuegt.length > 0
|
||||
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
|
||||
: null,
|
||||
aenderung.entfernt.length > 0
|
||||
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
|
||||
await protokollieren({
|
||||
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
|
||||
entitaet: 'BetreuungsZugang',
|
||||
entitaetId: userId,
|
||||
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
|
||||
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: module },
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/freigaben')
|
||||
revalidatePath('/', 'layout')
|
||||
|
||||
const warnung =
|
||||
aenderung.sensibelHinzugefuegt.length > 0
|
||||
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
|
||||
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
|
||||
: undefined
|
||||
|
||||
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
|
||||
}
|
||||
|
||||
export async function zugangEntziehen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const userId = String(formular.get('userId') ?? '')
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
|
||||
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
||||
if (!darfFreigabenVergeben(zugang)) {
|
||||
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
|
||||
}
|
||||
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
|
||||
|
||||
if (userId === benutzer.id) {
|
||||
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
|
||||
}
|
||||
|
||||
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
|
||||
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
|
||||
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'FREIGABE_GEAENDERT',
|
||||
entitaet: 'BetreuungsZugang',
|
||||
entitaetId: userId,
|
||||
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/freigaben')
|
||||
revalidatePath('/', 'layout')
|
||||
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import { heuteAlsTag, tagesdatum } from '@/lib/fristen/datum'
|
||||
import { leerstandCheckliste, naechsteFaelligkeit } from '@/lib/immobilie/leerstand-checkliste'
|
||||
import { INVENTAR_WERTQUELLE, inventarNotizLesen, inventarNotizSchreiben } from '@/lib/immobilie/inventar'
|
||||
import {
|
||||
BelastungSchema,
|
||||
CheckpunktSchema,
|
||||
ImmobilieSchema,
|
||||
InventarSchema,
|
||||
StatusSozialhilfeSchema,
|
||||
WertstandSchema,
|
||||
} from '@/lib/validierung/immobilie'
|
||||
import { formularLesen, haken, zodFehler, type Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
/**
|
||||
* Server Actions des Immobilienmoduls.
|
||||
*
|
||||
* Jede Aktion prueft den Zugriff serverseitig gegen die betreuteId, jede
|
||||
* validiert mit Zod, jede protokolliert, jede aktualisiert die betroffene
|
||||
* Seite. Fehler werden als Zustand zurueckgegeben und nicht geworfen -
|
||||
* ein geworfener Fehler zeigt eine Fehlerseite und verliert die Eingaben.
|
||||
*/
|
||||
|
||||
const MODUL = 'IMMOBILIE' as const
|
||||
|
||||
async function zugangOderFehler(betreuteId: string) {
|
||||
return erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
}
|
||||
|
||||
function alsFehler(fehler: unknown): Formularzustand {
|
||||
if (fehler instanceof ZugriffVerweigert) return { fehler: fehler.message }
|
||||
console.error('[immobilie]', fehler)
|
||||
return {
|
||||
fehler: 'Das Speichern hat nicht geklappt. Bitte noch einmal versuchen.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Laedt eine Immobilie und stellt sicher, dass sie zur betreuten Person gehoert. */
|
||||
async function immobilieLaden(betreuteId: string, immobilieId: string) {
|
||||
const immobilie = await db.immobilie.findFirst({
|
||||
where: { id: immobilieId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!immobilie) throw new ZugriffVerweigert('Dieses Objekt gibt es nicht.')
|
||||
return immobilie
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Stammdaten
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function immobilieSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
let ziel: string | null = null
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
|
||||
const gelesen = ImmobilieSchema.safeParse({
|
||||
...formularLesen(formular),
|
||||
checklisteUebernehmen: haken(formular, 'checklisteUebernehmen'),
|
||||
})
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const daten = gelesen.data
|
||||
const gemeinsam = {
|
||||
bezeichnung: daten.bezeichnung,
|
||||
strasse: daten.strasse,
|
||||
plz: daten.plz,
|
||||
ort: daten.ort,
|
||||
grundbuchamt: daten.grundbuchamt,
|
||||
grundbuchBlatt: daten.grundbuchBlatt,
|
||||
gemarkung: daten.gemarkung,
|
||||
flurstueck: daten.flurstueck,
|
||||
eigentumsanteil: daten.eigentumsanteil,
|
||||
wohnflaecheQm: daten.wohnflaecheQm,
|
||||
grundstueckQm: daten.grundstueckQm,
|
||||
baujahr: daten.baujahr,
|
||||
bewohntStatus: daten.bewohntStatus,
|
||||
leerstandSeit: daten.leerstandSeit,
|
||||
...sealen('Immobilie', { notizen: daten.notizen }),
|
||||
} satisfies Prisma.ImmobilieUncheckedUpdateInput
|
||||
|
||||
let zielId = immobilieId
|
||||
|
||||
if (immobilieId) {
|
||||
const vorher = await immobilieLaden(betreuteId, immobilieId)
|
||||
const nachher = await db.immobilie.update({ where: { id: immobilieId }, data: gemeinsam })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Immobilie',
|
||||
entitaetId: nachher.id,
|
||||
bezeichnung: nachher.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(
|
||||
oeffnen('Immobilie', vorher),
|
||||
oeffnen('Immobilie', nachher),
|
||||
),
|
||||
})
|
||||
} else {
|
||||
const neu = await db.immobilie.create({ data: { betreuteId, ...gemeinsam } })
|
||||
zielId = neu.id
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Immobilie',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (daten.checklisteUebernehmen && daten.bewohntStatus === 'leerstehend') {
|
||||
await checklisteAnlegen(betreuteId, zielId, daten.leerstandSeit)
|
||||
}
|
||||
|
||||
revalidatePath('/immobilie')
|
||||
revalidatePath(`/immobilie/${zielId}`)
|
||||
ziel = `/immobilie/${zielId}`
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
|
||||
// Der Umweg ueber die Variable ist Absicht: redirect() wirft, und in
|
||||
// einem try-Block landete der Wurf im catch und wuerde als Fehler
|
||||
// ausgegeben statt weiterzuleiten.
|
||||
redirect(ziel)
|
||||
}
|
||||
|
||||
export async function immobilieLoeschen(betreuteId: string, immobilieId: string): Promise<void> {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
const immobilie = await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
await db.immobilie.update({
|
||||
where: { id: immobilieId },
|
||||
data: { geloeschtAm: new Date() },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Immobilie',
|
||||
entitaetId: immobilieId,
|
||||
bezeichnung: immobilie.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/immobilie')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Status im Sozialhilfeverfahren
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function statusSozialhilfeSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
const vorher = await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const gelesen = StatusSozialhilfeSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const nachher = await db.immobilie.update({
|
||||
where: { id: immobilieId },
|
||||
data: {
|
||||
statusSozialhilfe: gelesen.data.statusSozialhilfe,
|
||||
...sealen('Immobilie', { statusBegruendung: gelesen.data.begruendung }),
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Immobilie',
|
||||
entitaetId: immobilieId,
|
||||
bezeichnung: `${nachher.bezeichnung} — Status im Sozialhilfeverfahren`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(
|
||||
{ statusSozialhilfe: vorher.statusSozialhilfe },
|
||||
{ statusSozialhilfe: nachher.statusSozialhilfe },
|
||||
),
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}`)
|
||||
return { erfolg: 'Status gespeichert.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Belastungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function belastungSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
const belastungId = String(formular.get('belastungId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
const immobilie = await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const gelesen = BelastungSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const daten = {
|
||||
art: gelesen.data.art,
|
||||
abteilung: gelesen.data.abteilung,
|
||||
laufendeNr: gelesen.data.laufendeNr,
|
||||
glaeubiger: gelesen.data.glaeubiger,
|
||||
betragCent: gelesen.data.betrag,
|
||||
bemerkung: gelesen.data.bemerkung,
|
||||
geloeschtImGrundbuchAm: gelesen.data.geloeschtImGrundbuchAm,
|
||||
}
|
||||
|
||||
if (belastungId) {
|
||||
const vorhanden = await db.belastung.findFirst({
|
||||
where: { id: belastungId, immobilieId },
|
||||
})
|
||||
if (!vorhanden) return { fehler: 'Diese Belastung gibt es nicht.' }
|
||||
|
||||
await db.belastung.update({ where: { id: belastungId }, data: daten })
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Belastung',
|
||||
entitaetId: belastungId,
|
||||
bezeichnung: `${daten.art} · ${immobilie.bezeichnung}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorhanden, { ...vorhanden, ...daten }),
|
||||
})
|
||||
} else {
|
||||
const neu = await db.belastung.create({ data: { immobilieId, ...daten } })
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Belastung',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: `${daten.art} · ${immobilie.bezeichnung}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}`)
|
||||
return { erfolg: 'Belastung gespeichert.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
export async function belastungLoeschen(
|
||||
betreuteId: string,
|
||||
immobilieId: string,
|
||||
belastungId: string,
|
||||
): Promise<void> {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const belastung = await db.belastung.findFirst({ where: { id: belastungId, immobilieId } })
|
||||
if (!belastung) throw new ZugriffVerweigert('Diese Belastung gibt es nicht.')
|
||||
|
||||
await db.belastung.delete({ where: { id: belastungId } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Belastung',
|
||||
entitaetId: belastungId,
|
||||
bezeichnung: `${belastung.art} ${belastung.laufendeNr ?? ''}`.trim(),
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}`)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Wert-Historie
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Der Vermoegensposten zur Immobilie. Er wird bei Bedarf angelegt, damit
|
||||
* die Wert-Historie und die Vermoegensuebersicht dieselbe Quelle benutzen.
|
||||
*/
|
||||
async function vermoegenspostenZurImmobilie(betreuteId: string, immobilieId: string, bezeichnung: string) {
|
||||
const vorhanden = await db.vermoegensposten.findFirst({
|
||||
where: { betreuteId, immobilieId, art: 'IMMOBILIE', ...nichtGeloescht },
|
||||
})
|
||||
if (vorhanden) return vorhanden
|
||||
|
||||
return db.vermoegensposten.create({
|
||||
data: { betreuteId, immobilieId, art: 'IMMOBILIE', bezeichnung },
|
||||
})
|
||||
}
|
||||
|
||||
export async function wertstandErfassen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
const immobilie = await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const gelesen = WertstandSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const posten = await vermoegenspostenZurImmobilie(betreuteId, immobilieId, immobilie.bezeichnung)
|
||||
|
||||
const vorhanden = await db.wertstand.findUnique({
|
||||
where: {
|
||||
vermoegenspostenId_stichtag: {
|
||||
vermoegenspostenId: posten.id,
|
||||
stichtag: gelesen.data.stichtag,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (vorhanden) {
|
||||
return {
|
||||
fehler:
|
||||
'Zu diesem Stichtag steht bereits ein Wert in der Historie. Wähle einen ' +
|
||||
'anderen Stichtag — bestehende Werte werden nicht überschrieben, damit ' +
|
||||
'jede Vermögensübersicht reproduzierbar bleibt.',
|
||||
felder: { stichtag: 'Stichtag bereits belegt.' },
|
||||
}
|
||||
}
|
||||
|
||||
const wertstand = await db.wertstand.create({
|
||||
data: {
|
||||
vermoegenspostenId: posten.id,
|
||||
stichtag: gelesen.data.stichtag,
|
||||
wertCent: gelesen.data.wert,
|
||||
wertquelle: gelesen.data.wertquelle,
|
||||
bemerkung: gelesen.data.bemerkung,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Wertstand',
|
||||
entitaetId: wertstand.id,
|
||||
bezeichnung: `${immobilie.bezeichnung} — Wert zum Stichtag (${gelesen.data.wertquelle})`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}`)
|
||||
return { erfolg: 'Wert in die Historie aufgenommen.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Leerstands-Checkliste
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Uebernimmt die Pflichten-Checkliste. Abgeglichen wird ueber den Titel:
|
||||
* Ein Punkt, den es schon gibt, wird nicht noch einmal angelegt.
|
||||
*/
|
||||
async function checklisteAnlegen(
|
||||
betreuteId: string,
|
||||
immobilieId: string,
|
||||
leerstandSeit: Date | null,
|
||||
): Promise<number> {
|
||||
const punkte = leerstandCheckliste(leerstandSeit, heuteAlsTag())
|
||||
const vorhandene = await db.immobilienCheckpunkt.findMany({
|
||||
where: { immobilieId },
|
||||
select: { titel: true },
|
||||
})
|
||||
const bekannt = new Set(vorhandene.map((p) => p.titel))
|
||||
|
||||
const neue = punkte.filter((p) => !bekannt.has(p.titel))
|
||||
if (neue.length === 0) return 0
|
||||
|
||||
await db.immobilienCheckpunkt.createMany({
|
||||
data: neue.map((punkt) => ({
|
||||
immobilieId,
|
||||
titel: punkt.titel,
|
||||
beschreibung: punkt.beschreibung,
|
||||
faelligAm: punkt.faelligAm,
|
||||
wiederholungRrule: punkt.wiederholungRrule ?? null,
|
||||
reihenfolge: punkt.reihenfolge,
|
||||
})),
|
||||
})
|
||||
|
||||
return neue.length
|
||||
}
|
||||
|
||||
export async function checklisteUebernehmen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
const immobilie = await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const anzahl = await checklisteAnlegen(betreuteId, immobilieId, immobilie.leerstandSeit)
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'ImmobilienCheckpunkt',
|
||||
entitaetId: immobilieId,
|
||||
bezeichnung: `Leerstands-Checkliste übernommen (${anzahl} Punkte) — ${immobilie.bezeichnung}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}/checkliste`)
|
||||
return {
|
||||
erfolg:
|
||||
anzahl === 0
|
||||
? 'Alle Punkte der Checkliste stehen bereits in der Liste.'
|
||||
: `${anzahl} Punkte übernommen.`,
|
||||
}
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkpunktSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
const checkpunktId = String(formular.get('checkpunktId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const gelesen = CheckpunktSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const daten = {
|
||||
titel: gelesen.data.titel,
|
||||
beschreibung: gelesen.data.beschreibung,
|
||||
faelligAm: gelesen.data.faelligAm,
|
||||
wiederholungRrule: gelesen.data.wiederholungRrule,
|
||||
}
|
||||
|
||||
if (checkpunktId) {
|
||||
const vorhanden = await db.immobilienCheckpunkt.findFirst({
|
||||
where: { id: checkpunktId, immobilieId },
|
||||
})
|
||||
if (!vorhanden) return { fehler: 'Diesen Punkt gibt es nicht.' }
|
||||
await db.immobilienCheckpunkt.update({ where: { id: checkpunktId }, data: daten })
|
||||
} else {
|
||||
await db.immobilienCheckpunkt.create({ data: { immobilieId, ...daten } })
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: checkpunktId ? 'GEAENDERT' : 'ERSTELLT',
|
||||
entitaet: 'ImmobilienCheckpunkt',
|
||||
entitaetId: checkpunktId || null,
|
||||
bezeichnung: daten.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}/checkliste`)
|
||||
return { erfolg: 'Punkt gespeichert.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hakt einen Punkt ab. Bei einem wiederkehrenden Punkt wird er nicht
|
||||
* geschlossen, sondern auf den naechsten Termin gesetzt - ein
|
||||
* Kontrollgang ist nie "erledigt", er ist nur bis naechste Woche erledigt.
|
||||
*/
|
||||
export async function checkpunktErledigen(
|
||||
betreuteId: string,
|
||||
immobilieId: string,
|
||||
checkpunktId: string,
|
||||
erledigt: boolean,
|
||||
): Promise<void> {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const punkt = await db.immobilienCheckpunkt.findFirst({
|
||||
where: { id: checkpunktId, immobilieId },
|
||||
})
|
||||
if (!punkt) throw new ZugriffVerweigert('Diesen Punkt gibt es nicht.')
|
||||
|
||||
const heute = heuteAlsTag()
|
||||
|
||||
if (erledigt && punkt.wiederholungRrule) {
|
||||
const naechste = naechsteFaelligkeit(punkt.wiederholungRrule, heute)
|
||||
await db.immobilienCheckpunkt.update({
|
||||
where: { id: checkpunktId },
|
||||
data: { erledigt: false, erledigtAm: heute, faelligAm: naechste ?? punkt.faelligAm },
|
||||
})
|
||||
} else {
|
||||
await db.immobilienCheckpunkt.update({
|
||||
where: { id: checkpunktId },
|
||||
data: { erledigt, erledigtAm: erledigt ? heute : null },
|
||||
})
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'ImmobilienCheckpunkt',
|
||||
entitaetId: checkpunktId,
|
||||
bezeichnung: punkt.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: { erledigt: { alt: punkt.erledigt, neu: erledigt } },
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}/checkliste`)
|
||||
revalidatePath(`/immobilie/${immobilieId}`)
|
||||
}
|
||||
|
||||
export async function checkpunktLoeschen(
|
||||
betreuteId: string,
|
||||
immobilieId: string,
|
||||
checkpunktId: string,
|
||||
): Promise<void> {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const punkt = await db.immobilienCheckpunkt.findFirst({
|
||||
where: { id: checkpunktId, immobilieId },
|
||||
})
|
||||
if (!punkt) throw new ZugriffVerweigert('Diesen Punkt gibt es nicht.')
|
||||
|
||||
await db.immobilienCheckpunkt.delete({ where: { id: checkpunktId } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'ImmobilienCheckpunkt',
|
||||
entitaetId: checkpunktId,
|
||||
bezeichnung: punkt.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}/checkliste`)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Inventar
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function inventarSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const immobilieId = String(formular.get('immobilieId') ?? '')
|
||||
const postenId = String(formular.get('postenId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const gelesen = InventarSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const notiz = inventarNotizSchreiben({
|
||||
raum: gelesen.data.raum,
|
||||
verbleib: gelesen.data.verbleib,
|
||||
bemerkung: gelesen.data.bemerkung,
|
||||
})
|
||||
|
||||
const daten = {
|
||||
bezeichnung: gelesen.data.bezeichnung,
|
||||
art: 'WERTGEGENSTAND' as const,
|
||||
immobilieId,
|
||||
...sealen('Vermoegensposten', { notizen: notiz }),
|
||||
}
|
||||
|
||||
let zielId = postenId
|
||||
|
||||
if (postenId) {
|
||||
const vorhanden = await db.vermoegensposten.findFirst({
|
||||
where: { id: postenId, betreuteId, immobilieId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorhanden) return { fehler: 'Diesen Gegenstand gibt es nicht.' }
|
||||
await db.vermoegensposten.update({ where: { id: postenId }, data: daten })
|
||||
} else {
|
||||
const neu = await db.vermoegensposten.create({ data: { betreuteId, ...daten } })
|
||||
zielId = neu.id
|
||||
}
|
||||
|
||||
// Geschaetzter Wert als Wertstand zum heutigen Tag - Historie, kein
|
||||
// Ueberschreiben. Wird am selben Tag zweimal gespeichert, gewinnt der
|
||||
// letzte Eintrag desselben Tages.
|
||||
if (gelesen.data.geschaetzterWert !== null) {
|
||||
const stichtag = tagesdatum(heuteAlsTag())
|
||||
await db.wertstand.upsert({
|
||||
where: { vermoegenspostenId_stichtag: { vermoegenspostenId: zielId, stichtag } },
|
||||
create: {
|
||||
vermoegenspostenId: zielId,
|
||||
stichtag,
|
||||
wertCent: gelesen.data.geschaetzterWert,
|
||||
wertquelle: INVENTAR_WERTQUELLE,
|
||||
},
|
||||
update: { wertCent: gelesen.data.geschaetzterWert, wertquelle: INVENTAR_WERTQUELLE },
|
||||
})
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: postenId ? 'GEAENDERT' : 'ERSTELLT',
|
||||
entitaet: 'Vermoegensposten',
|
||||
entitaetId: zielId,
|
||||
bezeichnung: `Inventar: ${gelesen.data.bezeichnung}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}/inventar`)
|
||||
return { erfolg: 'Gegenstand gespeichert.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
export async function inventarLoeschen(
|
||||
betreuteId: string,
|
||||
immobilieId: string,
|
||||
postenId: string,
|
||||
): Promise<void> {
|
||||
const { benutzer } = await zugangOderFehler(betreuteId)
|
||||
await immobilieLaden(betreuteId, immobilieId)
|
||||
|
||||
const posten = await db.vermoegensposten.findFirst({
|
||||
where: { id: postenId, betreuteId, immobilieId, ...nichtGeloescht },
|
||||
})
|
||||
if (!posten) throw new ZugriffVerweigert('Diesen Gegenstand gibt es nicht.')
|
||||
|
||||
await db.vermoegensposten.update({
|
||||
where: { id: postenId },
|
||||
data: { geloeschtAm: new Date() },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Vermoegensposten',
|
||||
entitaetId: postenId,
|
||||
bezeichnung: `Inventar: ${posten.bezeichnung}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/immobilie/${immobilieId}/inventar`)
|
||||
}
|
||||
|
||||
/** Liest den Inventarposten fuer das Bearbeitungsformular auf. */
|
||||
export async function inventarLesen(betreuteId: string, postenId: string) {
|
||||
await erfordereZugang(betreuteId, MODUL, 'lesen')
|
||||
const posten = await db.vermoegensposten.findFirst({
|
||||
where: { id: postenId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!posten) return null
|
||||
const offen = oeffnen('Vermoegensposten', posten)
|
||||
return { bezeichnung: posten.bezeichnung, ...inventarNotizLesen(offen.notizen) }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use server'
|
||||
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
||||
import { passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
|
||||
|
||||
/**
|
||||
* Passwortwechsel.
|
||||
*
|
||||
* Danach wird sitzungsGeneration hochgezaehlt. Das macht alle bestehenden
|
||||
* Sitzungen ungueltig - auch die auf Geraeten, an denen man gerade nicht
|
||||
* sitzt. Genau darum aendert man ja das Passwort.
|
||||
*
|
||||
* Die eigene Sitzung faellt dabei mit; der Benutzer meldet sich neu an.
|
||||
* Das ist unbequem und trotzdem richtig: ein Wechsel, der offene Sitzungen
|
||||
* stehen laesst, schuetzt vor nichts.
|
||||
*/
|
||||
|
||||
const PASSWORT_MINDESTLAENGE = 12
|
||||
|
||||
const PasswortSchema = z
|
||||
.object({
|
||||
aktuell: z.string().min(1, 'Bitte das bisherige Passwort eingeben.'),
|
||||
neu: z
|
||||
.string()
|
||||
.min(
|
||||
PASSWORT_MINDESTLAENGE,
|
||||
`Bitte mindestens ${PASSWORT_MINDESTLAENGE} Zeichen. Vier zufällige Wörter sind ` +
|
||||
`leichter zu merken und sicherer als ein kurzes Kunstwort.`,
|
||||
)
|
||||
.max(200),
|
||||
wiederholung: z.string(),
|
||||
})
|
||||
.refine((daten) => daten.neu === daten.wiederholung, {
|
||||
message: 'Die beiden neuen Passwörter stimmen nicht überein.',
|
||||
path: ['wiederholung'],
|
||||
})
|
||||
.refine((daten) => daten.neu !== daten.aktuell, {
|
||||
message: 'Das neue Passwort muss sich vom bisherigen unterscheiden.',
|
||||
path: ['neu'],
|
||||
})
|
||||
|
||||
export type PasswortZustand = {
|
||||
fehler?: string
|
||||
felder?: Partial<Record<'aktuell' | 'neu' | 'wiederholung', string>>
|
||||
erfolg?: boolean
|
||||
}
|
||||
|
||||
export async function passwortAendern(
|
||||
_vorher: PasswortZustand,
|
||||
formular: FormData,
|
||||
): Promise<PasswortZustand> {
|
||||
const begonnen = Date.now()
|
||||
const benutzer = await erfordereBenutzer()
|
||||
|
||||
const gelesen = PasswortSchema.safeParse({
|
||||
aktuell: formular.get('aktuell'),
|
||||
neu: formular.get('neu'),
|
||||
wiederholung: formular.get('wiederholung'),
|
||||
})
|
||||
|
||||
if (!gelesen.success) {
|
||||
const felder = gelesen.error.flatten().fieldErrors
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: {
|
||||
aktuell: felder.aktuell?.[0],
|
||||
neu: felder.neu?.[0],
|
||||
wiederholung: felder.wiederholung?.[0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const stimmt = await passwortPruefen(benutzer.passwortHash, gelesen.data.aktuell)
|
||||
if (!stimmt) {
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
return { fehler: 'Das bisherige Passwort stimmt nicht.', felder: { aktuell: ' ' } }
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: {
|
||||
passwortHash: await passwortHashen(gelesen.data.neu),
|
||||
passwortGeaendertAm: new Date(),
|
||||
// Beendet alle bestehenden Sitzungen, die eigene eingeschlossen.
|
||||
sitzungsGeneration: { increment: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'PASSWORT_GEAENDERT',
|
||||
entitaet: 'User',
|
||||
entitaetId: benutzer.id,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
return { erfolg: true }
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Modul } from '@prisma/client'
|
||||
import { db, papierkorbGrenze } from '@/lib/db'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
||||
import { entfernen } from '@/lib/speicher'
|
||||
import { papierkorbSchema } from '@/lib/validierung/freigaben'
|
||||
|
||||
/**
|
||||
* Papierkorb.
|
||||
*
|
||||
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
|
||||
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
|
||||
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
|
||||
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
|
||||
*
|
||||
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
|
||||
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
|
||||
* es ihn gab und wer ihn entfernt hat.
|
||||
*/
|
||||
|
||||
export type Aktionszustand = {
|
||||
fehler?: string
|
||||
erfolg?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
|
||||
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
|
||||
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
|
||||
*/
|
||||
type Eintragsart = {
|
||||
bezeichnung: string
|
||||
mehrzahl: string
|
||||
modul: Modul
|
||||
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
|
||||
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
|
||||
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type PapierkorbEintrag = {
|
||||
id: string
|
||||
entitaet: string
|
||||
bezeichnung: string
|
||||
geloeschtAm: Date
|
||||
}
|
||||
|
||||
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
|
||||
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
|
||||
entitaet: string,
|
||||
bezeichnung: string,
|
||||
mehrzahl: string,
|
||||
modul: Modul,
|
||||
zugriff: {
|
||||
findMany: (args: unknown) => Promise<T[]>
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
delete: (args: unknown) => Promise<unknown>
|
||||
},
|
||||
titelFeld: string,
|
||||
): Eintragsart {
|
||||
return {
|
||||
bezeichnung,
|
||||
mehrzahl,
|
||||
modul,
|
||||
laden: async (betreuteId, seit) => {
|
||||
const treffer = await zugriff.findMany({
|
||||
where: { betreuteId, geloeschtAm: { gte: seit } },
|
||||
orderBy: { geloeschtAm: 'desc' },
|
||||
})
|
||||
return treffer.map((datensatz) => ({
|
||||
id: datensatz.id,
|
||||
entitaet,
|
||||
bezeichnung:
|
||||
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
|
||||
geloeschtAm: datensatz.geloeschtAm!,
|
||||
}))
|
||||
},
|
||||
wiederherstellen: async (id, betreuteId) => {
|
||||
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
|
||||
},
|
||||
endgueltigLoeschen: async (id, betreuteId) => {
|
||||
await zugriff.delete({ where: { id, betreuteId } })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eintragsarten(): Record<string, Eintragsart> {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const z = db as any
|
||||
return {
|
||||
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
|
||||
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
|
||||
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
|
||||
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
|
||||
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
|
||||
Vermoegensposten: einfach(
|
||||
'Vermoegensposten',
|
||||
'Vermögensposten',
|
||||
'Vermögensposten',
|
||||
'FINANZEN',
|
||||
z.vermoegensposten,
|
||||
'bezeichnung',
|
||||
),
|
||||
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
|
||||
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
|
||||
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
|
||||
Kontaktprotokoll: einfach(
|
||||
'Kontaktprotokoll',
|
||||
'Kontaktprotokoll',
|
||||
'Kontaktprotokolle',
|
||||
'KONTAKTE',
|
||||
z.kontaktprotokoll,
|
||||
'gespraechspartner',
|
||||
),
|
||||
Gesundheitsereignis: einfach(
|
||||
'Gesundheitsereignis',
|
||||
'Gesundheitsereignis',
|
||||
'Gesundheitsereignisse',
|
||||
'GESUNDHEIT',
|
||||
z.gesundheitsereignis,
|
||||
'titel',
|
||||
),
|
||||
Genehmigung: einfach(
|
||||
'Genehmigung',
|
||||
'Genehmigung',
|
||||
'Genehmigungen',
|
||||
'VORGAENGE',
|
||||
z.genehmigung,
|
||||
'titel',
|
||||
),
|
||||
SozialhilfeAntrag: einfach(
|
||||
'SozialhilfeAntrag',
|
||||
'Sozialhilfeantrag',
|
||||
'Sozialhilfeanträge',
|
||||
'SOZIALHILFE',
|
||||
z.sozialhilfeAntrag,
|
||||
'leistungsart',
|
||||
),
|
||||
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
|
||||
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
|
||||
export async function papierkorbLaden(betreuteId: string): Promise<
|
||||
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
|
||||
> {
|
||||
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
|
||||
const seit = papierkorbGrenze()
|
||||
|
||||
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
|
||||
for (const [art, beschreibung] of Object.entries(eintragsarten())) {
|
||||
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
|
||||
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
|
||||
continue
|
||||
}
|
||||
const eintraege = await beschreibung.laden(betreuteId, seit)
|
||||
if (eintraege.length > 0) {
|
||||
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
|
||||
}
|
||||
}
|
||||
return gruppen
|
||||
}
|
||||
|
||||
export async function wiederherstellen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = papierkorbSchema.safeParse({
|
||||
betreuteId: formular.get('betreuteId'),
|
||||
entitaet: formular.get('entitaet'),
|
||||
entitaetId: formular.get('entitaetId'),
|
||||
})
|
||||
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
|
||||
|
||||
const art = eintragsarten()[gelesen.data.entitaet]
|
||||
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
|
||||
|
||||
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
|
||||
|
||||
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'WIEDERHERGESTELLT',
|
||||
entitaet: gelesen.data.entitaet,
|
||||
entitaetId: gelesen.data.entitaetId,
|
||||
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
|
||||
betreuteId: gelesen.data.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/papierkorb')
|
||||
return { erfolg: `${art.bezeichnung} ist wieder da.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
|
||||
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
|
||||
*/
|
||||
export async function endgueltigLoeschen(
|
||||
_zustand: Aktionszustand,
|
||||
formular: FormData,
|
||||
): Promise<Aktionszustand> {
|
||||
const gelesen = papierkorbSchema.safeParse({
|
||||
betreuteId: formular.get('betreuteId'),
|
||||
entitaet: formular.get('entitaet'),
|
||||
entitaetId: formular.get('entitaetId'),
|
||||
})
|
||||
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
|
||||
|
||||
const art = eintragsarten()[gelesen.data.entitaet]
|
||||
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
|
||||
|
||||
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
|
||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
|
||||
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
|
||||
|
||||
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
|
||||
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
|
||||
// die ausdruecklich verschwinden sollten.
|
||||
if (gelesen.data.entitaet === 'Dokument' || gelesen.data.entitaet === 'Bericht') {
|
||||
const tabelle = gelesen.data.entitaet === 'Dokument' ? db.dokument : db.bericht
|
||||
const datensatz = await tabelle.findFirst({
|
||||
where: { id: gelesen.data.entitaetId, betreuteId: gelesen.data.betreuteId },
|
||||
select: { objektSchluessel: true },
|
||||
})
|
||||
if (datensatz?.objektSchluessel) {
|
||||
try {
|
||||
await entfernen(datensatz.objektSchluessel)
|
||||
} catch (fehler) {
|
||||
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ENDGUELTIG_GELOESCHT',
|
||||
entitaet: gelesen.data.entitaet,
|
||||
entitaetId: gelesen.data.entitaetId,
|
||||
bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
|
||||
betreuteId: gelesen.data.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/einstellungen/papierkorb')
|
||||
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import type { GesundheitsArt } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { detailsFormatieren, detailfelderFuer } from '@/lib/pflege/ereignis-details'
|
||||
import { PFLEGEGRAD_RECHTSGRUNDLAGE_BEGUTACHTUNG } from '@/lib/pflege/konstanten'
|
||||
import {
|
||||
ErmittelterWilleSchema,
|
||||
GesundheitsereignisSchema,
|
||||
HeimvertragSchema,
|
||||
HoeherstufungSchema,
|
||||
} from '@/lib/validierung/pflege'
|
||||
import { formularLesen, zodFehler, type Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
/**
|
||||
* Server Actions fuer Heim, Pflege und Gesundheitsverlauf.
|
||||
*
|
||||
* Gesundheitsdaten sind besondere Kategorien nach Art. 9 DSGVO. Jeder
|
||||
* Freitext wandert deshalb ueber sealen('Gesundheitsereignis', ...) in die
|
||||
* Datenbank, und im Audit-Log steht nur der Titel, nie die Beschreibung.
|
||||
*/
|
||||
|
||||
const MODUL = 'GESUNDHEIT' as const
|
||||
|
||||
function alsFehler(fehler: unknown): Formularzustand {
|
||||
if (fehler instanceof ZugriffVerweigert) return { fehler: fehler.message }
|
||||
console.error('[pflege]', fehler)
|
||||
return { fehler: 'Das Speichern hat nicht geklappt. Bitte noch einmal versuchen.' }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Heimvertrag
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function heimvertragSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
let ziel: string | null = null
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
|
||||
const gelesen = HeimvertragSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
const daten = gelesen.data
|
||||
|
||||
const felder = {
|
||||
einrichtung: daten.einrichtung,
|
||||
traeger: daten.traeger,
|
||||
strasse: daten.strasse,
|
||||
plz: daten.plz,
|
||||
ort: daten.ort,
|
||||
vertragAm: daten.vertragAm,
|
||||
einzugAm: daten.einzugAm,
|
||||
auszugAm: daten.auszugAm,
|
||||
kuendigungsfristTage: daten.kuendigungsfristTage,
|
||||
pflegeEigenanteilCent: daten.pflegeEigenanteil,
|
||||
unterkunftVerpflegungCent: daten.unterkunftVerpflegung,
|
||||
investitionskostenCent: daten.investitionskosten,
|
||||
ausbildungsumlageCent: daten.ausbildungsumlage,
|
||||
...sealen('Heimvertrag', { notizen: daten.notizen }),
|
||||
}
|
||||
|
||||
const vorher = await db.heimvertrag.findUnique({ where: { betreuteId } })
|
||||
const nachher = await db.heimvertrag.upsert({
|
||||
where: { betreuteId },
|
||||
create: { betreuteId, ...felder },
|
||||
update: felder,
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: vorher ? 'GEAENDERT' : 'ERSTELLT',
|
||||
entitaet: 'Heimvertrag',
|
||||
entitaetId: nachher.id,
|
||||
bezeichnung: nachher.einrichtung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: vorher
|
||||
? aenderungErmitteln(oeffnen('Heimvertrag', vorher), oeffnen('Heimvertrag', nachher))
|
||||
: undefined,
|
||||
})
|
||||
|
||||
revalidatePath('/pflege')
|
||||
ziel = '/pflege'
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
|
||||
redirect(ziel)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Gesundheitsverlauf
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liest die Zusatzfelder der Schnellerfassung aus dem Formular. Sie
|
||||
* heissen dort "detail_<name>" und werden zusammen mit dem Freitext zu
|
||||
* einer Beschreibung zusammengesetzt, die anschliessend verschluesselt
|
||||
* wird.
|
||||
*/
|
||||
function detailwerteLesen(formular: FormData, art: GesundheitsArt): Record<string, string> {
|
||||
const werte: Record<string, string> = {}
|
||||
for (const feld of detailfelderFuer(art)) {
|
||||
const roh = formular.get(`detail_${feld.name}`)
|
||||
if (typeof roh !== 'string') continue
|
||||
const wert = roh.trim()
|
||||
if (wert === '') continue
|
||||
werte[feld.name] = feld.typ === 'jaNein' ? (wert === 'on' ? 'ja' : wert) : wert
|
||||
}
|
||||
return werte
|
||||
}
|
||||
|
||||
export async function gesundheitsereignisSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const ereignisId = String(formular.get('ereignisId') ?? '')
|
||||
let ziel: string | null = null
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
|
||||
const gelesen = GesundheitsereignisSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
const daten = gelesen.data
|
||||
|
||||
const beschreibung = detailsFormatieren(
|
||||
daten.art,
|
||||
detailwerteLesen(formular, daten.art),
|
||||
daten.beschreibung,
|
||||
)
|
||||
|
||||
const felder = {
|
||||
datum: daten.datum,
|
||||
bisDatum: daten.bisDatum,
|
||||
art: daten.art,
|
||||
titel: daten.titel,
|
||||
behandler: daten.behandler,
|
||||
einrichtung: daten.einrichtung,
|
||||
abwesenheitstage: daten.abwesenheitstage,
|
||||
...sealen('Gesundheitsereignis', { beschreibung }),
|
||||
}
|
||||
|
||||
if (ereignisId) {
|
||||
const vorhanden = await db.gesundheitsereignis.findFirst({
|
||||
where: { id: ereignisId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorhanden) return { fehler: 'Diesen Eintrag gibt es nicht.' }
|
||||
|
||||
const nachher = await db.gesundheitsereignis.update({
|
||||
where: { id: ereignisId },
|
||||
data: felder,
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Gesundheitsereignis',
|
||||
entitaetId: ereignisId,
|
||||
bezeichnung: nachher.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
// Bewusst ohne die Beschreibung: Diagnosen gehoeren nicht ins Log,
|
||||
// auch nicht verschluesselt.
|
||||
aenderung: aenderungErmitteln(
|
||||
{ datum: vorhanden.datum, bisDatum: vorhanden.bisDatum, art: vorhanden.art, titel: vorhanden.titel, abwesenheitstage: vorhanden.abwesenheitstage },
|
||||
{ datum: nachher.datum, bisDatum: nachher.bisDatum, art: nachher.art, titel: nachher.titel, abwesenheitstage: nachher.abwesenheitstage },
|
||||
),
|
||||
})
|
||||
} else {
|
||||
const neu = await db.gesundheitsereignis.create({ data: { betreuteId, ...felder } })
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Gesundheitsereignis',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/pflege/verlauf')
|
||||
revalidatePath('/pflege')
|
||||
ziel = '/pflege/verlauf'
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
|
||||
redirect(ziel)
|
||||
}
|
||||
|
||||
export async function gesundheitsereignisLoeschen(
|
||||
betreuteId: string,
|
||||
ereignisId: string,
|
||||
): Promise<void> {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
|
||||
const ereignis = await db.gesundheitsereignis.findFirst({
|
||||
where: { id: ereignisId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!ereignis) throw new ZugriffVerweigert('Diesen Eintrag gibt es nicht.')
|
||||
|
||||
await db.gesundheitsereignis.update({
|
||||
where: { id: ereignisId },
|
||||
data: { geloeschtAm: new Date() },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Gesundheitsereignis',
|
||||
entitaetId: ereignisId,
|
||||
bezeichnung: ereignis.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/pflege/verlauf')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ermittelter Wille (§ 1821 BGB)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Der nach § 1821 BGB zu ermittelnde Wille der betreuten Person.
|
||||
*
|
||||
* Seit der Reform 2023 ist er die Leitschnur des Betreuerhandelns: Der
|
||||
* Betreuer hat den Wuenschen der betreuten Person zu entsprechen, auch
|
||||
* wenn er sie fuer unvernuenftig haelt - Grenze ist die erhebliche Gefahr
|
||||
* fuer Person oder Vermoegen. Deshalb gehoert dieses Feld gepflegt und
|
||||
* nicht einmal zu Beginn ausgefuellt und dann vergessen.
|
||||
*/
|
||||
export async function ermitteltenWillenSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
|
||||
const gelesen = ErmittelterWilleSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
await db.betreute.update({
|
||||
where: { id: betreuteId },
|
||||
data: sealen('Betreute', { ermittelterWille: gelesen.data.ermittelterWille }),
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Betreute',
|
||||
entitaetId: betreuteId,
|
||||
bezeichnung: 'Ermittelter Wille (§ 1821 BGB)',
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/pflege/wille')
|
||||
return { erfolg: 'Gespeichert.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Pflegegrad: Termin fuer die Hoeherstufungspruefung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Setzt den Termin, an dem geprueft wird, ob ein Hoeherstufungsantrag
|
||||
* sinnvoll ist. Geschrieben wird eine Frist mit dem stabilen
|
||||
* Systemschluessel "pflegegrad-pruefung" - denselben, den das Fristenmodul
|
||||
* fuer diese Pflichtfrist benutzt. Deshalb upsert und nicht create: Es
|
||||
* soll genau eine solche Frist geben.
|
||||
*/
|
||||
export async function hoeherstufungsterminSetzen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer, betreute } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
|
||||
const gelesen = HoeherstufungSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const beschreibung =
|
||||
(gelesen.data.begruendung ? `${gelesen.data.begruendung}\n\n` : '') +
|
||||
`Aktueller Pflegegrad: ${betreute.pflegegrad ?? 'nicht erfasst'}` +
|
||||
(betreute.pflegegradSeit ? ` (seit ${formatDatum(betreute.pflegegradSeit)})` : '') +
|
||||
`. Prüfen: Haben Exazerbationen, Krankenhaustage oder der Hilfebedarf ` +
|
||||
`zugenommen? Der Verlauf unter /pflege/verlauf liefert die Zahlen. ` +
|
||||
`Eine Verschlechterung rechtfertigt einen Antrag auf Neubegutachtung.`
|
||||
|
||||
await db.frist.upsert({
|
||||
where: {
|
||||
betreuteId_systemSchluessel: { betreuteId, systemSchluessel: 'pflegegrad-pruefung' },
|
||||
},
|
||||
create: {
|
||||
betreuteId,
|
||||
systemSchluessel: 'pflegegrad-pruefung',
|
||||
titel: 'Pflegegrad überprüfen — Höherstufung sinnvoll?',
|
||||
beschreibung,
|
||||
datum: gelesen.data.pruefungAm,
|
||||
typ: 'SELBST_GESETZT',
|
||||
rechtsgrundlage: PFLEGEGRAD_RECHTSGRUNDLAGE_BEGUTACHTUNG,
|
||||
vorwarnungTage: [30, 7],
|
||||
},
|
||||
update: {
|
||||
datum: gelesen.data.pruefungAm,
|
||||
beschreibung,
|
||||
status: 'OFFEN',
|
||||
erledigtAm: null,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: null,
|
||||
bezeichnung: `Pflegegradprüfung auf ${formatDatum(gelesen.data.pruefungAm)} gesetzt`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/pflege')
|
||||
revalidatePath('/fristen')
|
||||
return { erfolg: `Termin auf den ${formatDatum(gelesen.data.pruefungAm)} gesetzt.` }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE, wiedervorlageAm } from '@/lib/vertraege/rueckkaufswert'
|
||||
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
||||
import {
|
||||
KuendigungVermerkenSchema,
|
||||
RueckkaufswertAnfrageSchema,
|
||||
RueckkaufswertSchema,
|
||||
VertragSchema,
|
||||
} from '@/lib/validierung/vertraege'
|
||||
import { formularLesen, haken, zodFehler, type Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
/**
|
||||
* Server Actions fuer Versicherungen und Vertraege.
|
||||
*
|
||||
* Die Vertragsnummer wird verschluesselt abgelegt. Sie ist zusammen mit
|
||||
* dem Namen der Gesellschaft der Schluessel zum Vertrag - wer beides hat,
|
||||
* kann bei vielen Gesellschaften telefonisch Auskunft bekommen.
|
||||
*/
|
||||
|
||||
const MODUL = 'VERTRAEGE' as const
|
||||
|
||||
function alsFehler(fehler: unknown): Formularzustand {
|
||||
if (fehler instanceof ZugriffVerweigert) return { fehler: fehler.message }
|
||||
console.error('[vertraege]', fehler)
|
||||
return { fehler: 'Das Speichern hat nicht geklappt. Bitte noch einmal versuchen.' }
|
||||
}
|
||||
|
||||
async function vertragLaden(betreuteId: string, vertragId: string) {
|
||||
const vertrag = await db.vertrag.findFirst({
|
||||
where: { id: vertragId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vertrag) throw new ZugriffVerweigert('Diesen Vertrag gibt es nicht.')
|
||||
return vertrag
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Stammdaten
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function vertragSpeichern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const vertragId = String(formular.get('vertragId') ?? '')
|
||||
let ziel: string | null = null
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
|
||||
const gelesen = VertragSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
const daten = gelesen.data
|
||||
|
||||
const felder = {
|
||||
bezeichnung: daten.bezeichnung,
|
||||
gesellschaft: daten.gesellschaft,
|
||||
art: daten.art,
|
||||
status: daten.status,
|
||||
beginnAm: daten.beginnAm,
|
||||
endeAm: daten.endeAm,
|
||||
kuendigungsfristMonate: daten.kuendigungsfristMonate,
|
||||
kuendigungZum: daten.kuendigungZum,
|
||||
gekuendigtAm: daten.gekuendigtAm,
|
||||
beitragCent: daten.beitrag,
|
||||
rhythmus: daten.rhythmus,
|
||||
bewertungSozialhilfe: daten.bewertungSozialhilfe,
|
||||
...sealen('Vertrag', {
|
||||
vertragsnummer: daten.vertragsnummer,
|
||||
bewertungBegruendung: daten.bewertungBegruendung,
|
||||
notizen: daten.notizen,
|
||||
}),
|
||||
}
|
||||
|
||||
if (vertragId) {
|
||||
const vorher = await vertragLaden(betreuteId, vertragId)
|
||||
const nachher = await db.vertrag.update({ where: { id: vertragId }, data: felder })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vertrag',
|
||||
entitaetId: vertragId,
|
||||
bezeichnung: nachher.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(oeffnen('Vertrag', vorher), oeffnen('Vertrag', nachher)),
|
||||
})
|
||||
ziel = `/vertraege/${vertragId}`
|
||||
} else {
|
||||
const neu = await db.vertrag.create({ data: { betreuteId, ...felder } })
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Vertrag',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
ziel = `/vertraege/${neu.id}`
|
||||
}
|
||||
|
||||
revalidatePath('/vertraege')
|
||||
revalidatePath(ziel)
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
|
||||
redirect(ziel)
|
||||
}
|
||||
|
||||
export async function vertragLoeschen(betreuteId: string, vertragId: string): Promise<void> {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
const vertrag = await vertragLaden(betreuteId, vertragId)
|
||||
|
||||
await db.vertrag.update({ where: { id: vertragId }, data: { geloeschtAm: new Date() } })
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Vertrag',
|
||||
entitaetId: vertragId,
|
||||
bezeichnung: vertrag.bezeichnung,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/vertraege')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rueckkaufswert
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function vermoegenspostenZumVertrag(betreuteId: string, vertragId: string, bezeichnung: string) {
|
||||
const vorhanden = await db.vermoegensposten.findFirst({
|
||||
where: { betreuteId, vertragId, art: 'VERSICHERUNG', ...nichtGeloescht },
|
||||
})
|
||||
if (vorhanden) return vorhanden
|
||||
return db.vermoegensposten.create({
|
||||
data: { betreuteId, vertragId, art: 'VERSICHERUNG', bezeichnung },
|
||||
})
|
||||
}
|
||||
|
||||
export async function rueckkaufswertErfassen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const vertragId = String(formular.get('vertragId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
const vertrag = await vertragLaden(betreuteId, vertragId)
|
||||
|
||||
const gelesen = RueckkaufswertSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const posten = await vermoegenspostenZumVertrag(betreuteId, vertragId, vertrag.bezeichnung)
|
||||
|
||||
const vorhanden = await db.wertstand.findUnique({
|
||||
where: {
|
||||
vermoegenspostenId_stichtag: {
|
||||
vermoegenspostenId: posten.id,
|
||||
stichtag: gelesen.data.stichtag,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (vorhanden) {
|
||||
return {
|
||||
fehler:
|
||||
'Zu diesem Stichtag steht bereits ein Rückkaufswert in der Historie. ' +
|
||||
'Bestehende Werte werden nicht überschrieben — wähle einen anderen Stichtag.',
|
||||
felder: { stichtag: 'Stichtag bereits belegt.' },
|
||||
}
|
||||
}
|
||||
|
||||
const wertstand = await db.wertstand.create({
|
||||
data: {
|
||||
vermoegenspostenId: posten.id,
|
||||
stichtag: gelesen.data.stichtag,
|
||||
wertCent: gelesen.data.wert,
|
||||
wertquelle: gelesen.data.wertquelle,
|
||||
bemerkung: gelesen.data.bemerkung,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Wertstand',
|
||||
entitaetId: wertstand.id,
|
||||
bezeichnung: `${vertrag.bezeichnung} — Rückkaufswert zum ${formatDatum(gelesen.data.stichtag)}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/vertraege/${vertragId}`)
|
||||
return { erfolg: 'Rückkaufswert in die Historie aufgenommen.' }
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermerkt die Anfrage beim Versicherer und legt die Wiedervorlage in vier
|
||||
* Wochen an.
|
||||
*
|
||||
* Die Frist wird direkt ueber Prisma geschrieben - das Fristenmodul baut
|
||||
* ein anderer Agent, seine Dateien werden hier nicht importiert. Der
|
||||
* Systemschluessel haengt an der Vertrags-Id, damit dieselbe Anfrage nicht
|
||||
* zweimal eine Frist erzeugt; eine erneute Anfrage schiebt den Termin.
|
||||
*/
|
||||
export async function rueckkaufswertAnfordern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const vertragId = String(formular.get('vertragId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
const vertrag = await vertragLaden(betreuteId, vertragId)
|
||||
|
||||
const gelesen = RueckkaufswertAnfrageSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const heute = heuteAlsTag()
|
||||
const wiedervorlage = wiedervorlageAm(heute)
|
||||
|
||||
await db.vertrag.update({
|
||||
where: { id: vertragId },
|
||||
data: { rueckkaufswertAngefragtAm: heute },
|
||||
})
|
||||
|
||||
const systemSchluessel = `rueckkaufswert-vertrag-${vertragId}`
|
||||
await db.frist.upsert({
|
||||
where: { betreuteId_systemSchluessel: { betreuteId, systemSchluessel } },
|
||||
create: {
|
||||
betreuteId,
|
||||
systemSchluessel,
|
||||
titel: `Rückkaufswert nachfassen: ${vertrag.bezeichnung}`,
|
||||
beschreibung:
|
||||
`Am ${formatDatum(heute)} wurde bei ${vertrag.gesellschaft ?? 'der Gesellschaft'} ` +
|
||||
`der Rückkaufswert zum ${formatDatum(gelesen.data.stichtag)} angefordert. ` +
|
||||
`Ist bis heute keine Antwort da: telefonisch nachfassen und den Anruf im ` +
|
||||
`Kontaktprotokoll festhalten. Der Bezirk wartet auf diese Zahl.`,
|
||||
datum: wiedervorlage,
|
||||
typ: 'WIEDERVORLAGE',
|
||||
vorwarnungTage: [7, 1],
|
||||
},
|
||||
update: {
|
||||
datum: wiedervorlage,
|
||||
status: 'OFFEN',
|
||||
erledigtAm: null,
|
||||
beschreibung:
|
||||
`Zuletzt angefordert am ${formatDatum(heute)} — Stichtag ` +
|
||||
`${formatDatum(gelesen.data.stichtag)}. Wenn bis heute nichts da ist, nachfassen.`,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vertrag',
|
||||
entitaetId: vertragId,
|
||||
bezeichnung: `${vertrag.bezeichnung} — Rückkaufswert angefordert`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
rueckkaufswertAngefragtAm: {
|
||||
alt: vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null,
|
||||
neu: heute.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/vertraege/${vertragId}`)
|
||||
revalidatePath('/fristen')
|
||||
return {
|
||||
erfolg:
|
||||
`Anfrage vermerkt. Wiedervorlage am ${formatDatum(wiedervorlage)} angelegt ` +
|
||||
`(${RUECKKAUFSWERT_WIEDERVORLAGE_TAGE} Tage).`,
|
||||
}
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Kuendigung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Traegt die abgeschickte Kuendigung ein. Ohne bestaetigten Versandnachweis
|
||||
* geht das nicht durch die Validierung - der Zugang muss beweisbar sein.
|
||||
* Zusaetzlich wird eine Wiedervorlage angelegt, damit die Bestaetigung der
|
||||
* Gesellschaft nicht vergessen wird.
|
||||
*/
|
||||
export async function kuendigungVermerken(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const vertragId = String(formular.get('vertragId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
const vertrag = await vertragLaden(betreuteId, vertragId)
|
||||
|
||||
const gelesen = KuendigungVermerkenSchema.safeParse({
|
||||
...formularLesen(formular),
|
||||
nachweisVorhanden: haken(formular, 'nachweisVorhanden'),
|
||||
genehmigungGeklaert: haken(formular, 'genehmigungGeklaert'),
|
||||
})
|
||||
if (!gelesen.success) return zodFehler(gelesen.error)
|
||||
|
||||
const offen = oeffnen('Vertrag', vertrag)
|
||||
const vermerk = [
|
||||
offen.notizen,
|
||||
`Kündigung am ${formatDatum(gelesen.data.gekuendigtAm)} versandt ` +
|
||||
`(${gelesen.data.versandart}).` +
|
||||
(gelesen.data.genehmigungGeklaert
|
||||
? ' Genehmigungsfrage vorab mit dem Rechtspfleger geklärt.'
|
||||
: ' Genehmigungsfrage: nicht als geklärt vermerkt.') +
|
||||
(gelesen.data.bemerkung ? ` ${gelesen.data.bemerkung}` : ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
await db.vertrag.update({
|
||||
where: { id: vertragId },
|
||||
data: {
|
||||
status: 'GEKUENDIGT',
|
||||
gekuendigtAm: gelesen.data.gekuendigtAm,
|
||||
kuendigungZum: gelesen.data.kuendigungZum,
|
||||
...sealen('Vertrag', { notizen: vermerk }),
|
||||
},
|
||||
})
|
||||
|
||||
const systemSchluessel = `kuendigungsbestaetigung-vertrag-${vertragId}`
|
||||
const nachfassen = wiedervorlageAm(gelesen.data.gekuendigtAm)
|
||||
await db.frist.upsert({
|
||||
where: { betreuteId_systemSchluessel: { betreuteId, systemSchluessel } },
|
||||
create: {
|
||||
betreuteId,
|
||||
systemSchluessel,
|
||||
titel: `Kündigungsbestätigung nachfassen: ${vertrag.bezeichnung}`,
|
||||
beschreibung:
|
||||
'Liegt die schriftliche Bestätigung der Gesellschaft vor? Ohne sie ist ' +
|
||||
'nicht belegt, zu welchem Termin der Vertrag endet. Bestätigung als ' +
|
||||
'Dokument hochladen und mit dem Vertrag verknüpfen.',
|
||||
datum: nachfassen,
|
||||
typ: 'WIEDERVORLAGE',
|
||||
vorwarnungTage: [7, 1],
|
||||
},
|
||||
update: { datum: nachfassen, status: 'OFFEN', erledigtAm: null },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vertrag',
|
||||
entitaetId: vertragId,
|
||||
bezeichnung: `${vertrag.bezeichnung} — gekündigt`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
status: { alt: vertrag.status, neu: 'GEKUENDIGT' },
|
||||
gekuendigtAm: {
|
||||
alt: vertrag.gekuendigtAm?.toISOString() ?? null,
|
||||
neu: gelesen.data.gekuendigtAm.toISOString(),
|
||||
},
|
||||
versandart: { alt: null, neu: gelesen.data.versandart },
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/vertraege/${vertragId}`)
|
||||
revalidatePath('/vertraege')
|
||||
revalidatePath('/fristen')
|
||||
return {
|
||||
erfolg:
|
||||
`Kündigung vermerkt. Wiedervorlage für die Bestätigung am ` +
|
||||
`${formatDatum(nachfassen)} angelegt.`,
|
||||
}
|
||||
} catch (fehler) {
|
||||
return alsFehler(fehler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Vorgang mit Genehmigungsschritt an? Nein - Vorgaenge baut ein
|
||||
* anderes Modul. Hier wird nur der Hinweis vermerkt, dass die
|
||||
* Genehmigungsfrage offen ist, damit sie nicht untergeht.
|
||||
*/
|
||||
export async function genehmigungsfrageVermerken(
|
||||
betreuteId: string,
|
||||
vertragId: string,
|
||||
): Promise<void> {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, MODUL, 'schreiben')
|
||||
const vertrag = await vertragLaden(betreuteId, vertragId)
|
||||
|
||||
const systemSchluessel = `genehmigungsfrage-vertrag-${vertragId}`
|
||||
const datum = heuteAlsTag()
|
||||
|
||||
await db.frist.upsert({
|
||||
where: { betreuteId_systemSchluessel: { betreuteId, systemSchluessel } },
|
||||
create: {
|
||||
betreuteId,
|
||||
systemSchluessel,
|
||||
titel: `Rechtspfleger fragen: Genehmigung für ${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]}?`,
|
||||
beschreibung:
|
||||
`Vor der Verwertung von "${vertrag.bezeichnung}" beim zuständigen ` +
|
||||
`Rechtspfleger klären, ob eine Genehmigung nach § 1850 BGB erforderlich ist. ` +
|
||||
`Die Genehmigung muss VOR dem Rechtsgeschäft vorliegen (§ 1858 BGB). ` +
|
||||
`Ergebnis im Kontaktprotokoll festhalten.`,
|
||||
datum,
|
||||
typ: 'SELBST_GESETZT',
|
||||
rechtsgrundlage: '§ 1850 BGB',
|
||||
vorwarnungTage: [1],
|
||||
},
|
||||
update: { status: 'OFFEN', erledigtAm: null, datum },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: null,
|
||||
bezeichnung: `Genehmigungsfrage vermerkt — ${vertrag.bezeichnung}`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath(`/vertraege/${vertragId}`)
|
||||
revalidatePath('/fristen')
|
||||
}
|
||||
Reference in New Issue
Block a user