'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 wiederherstellen: (id: string, betreuteId: string) => Promise endgueltigLoeschen: (id: string, betreuteId: string) => Promise } export type PapierkorbEintrag = { id: string entitaet: string bezeichnung: string geloeschtAm: Date } /** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */ function einfach( entitaet: string, bezeichnung: string, mehrzahl: string, modul: Modul, zugriff: { findMany: (args: unknown) => Promise update: (args: unknown) => Promise delete: (args: unknown) => Promise }, 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)[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 { /* 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'), Schreibvorlage: einfach( 'Schreibvorlage', 'Vorlage', 'Vorlagen', 'DOKUMENTE', z.schreibvorlage, 'name', ), 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 } /** * Haengt an diesem Datensatz eine Datei im Objektspeicher? * * Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber * eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell * eigene Signaturen, und eine Variable, die mal das eine und mal das * andere Delegate haelt, laesst sich nicht mehr aufrufen. */ async function objektSchluesselFinden( entitaet: string, entitaetId: string, betreuteId: string, ): Promise { if (entitaet === 'Dokument') { const dokument = await db.dokument.findFirst({ where: { id: entitaetId, betreuteId }, select: { objektSchluessel: true }, }) return dokument?.objektSchluessel ?? null } if (entitaet === 'Bericht') { const bericht = await db.bericht.findFirst({ where: { id: entitaetId, betreuteId }, select: { objektSchluessel: true }, }) return bericht?.objektSchluessel ?? null } return null } export async function wiederherstellen( _zustand: Aktionszustand, formular: FormData, ): Promise { 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 { 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. const schluessel = await objektSchluesselFinden( gelesen.data.entitaet, gelesen.data.entitaetId, gelesen.data.betreuteId, ) if (schluessel) { try { await entfernen(schluessel) } 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.` } }