Files
betreuungs-cockpit/src/server/actions/papierkorb.ts
T
Florian Wilde 1ed19eb76e Zeilenenden der 24 verbliebenen Dateien normalisieren
Ausschliesslich Zeilenenden. Nachgewiesen mit
"git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich
kein Zeichen Inhalt geaendert.

Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit
echten Aenderungen zu verstecken, macht die Historie an dieser Stelle
unlesbar. So laesst er sich beim Durchsehen ueberspringen.

Refs #26
2026-08-23 23:37:02 +02:00

294 lines
9.8 KiB
TypeScript

'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'),
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<string | null> {
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<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.
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.` }
}