Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.
Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
[Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
"Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
sind im Vollexport ihrer Akte enthalten.
Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.
Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.
Refs #7
294 lines
10 KiB
TypeScript
294 lines
10 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.` }
|
|
}
|