'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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) } }