Zwei Fehler, beide erst im Betrieb sichtbar. 1. Schritt 3 der Ersteinrichtung meldete "Bitte pruefe die markierten Felder" - und markierte keines. Man kam dort nicht weiter. Das kurze Formular schickt neun Felder, das vollstaendige unter Stammdaten alle. Beide gehen durch dasselbe Schema, und die optionalen Felder waren zwar nullable, aber nicht optional: Ein Feld, das gar nicht abgeschickt wurde, ist undefined, nicht null. Beanstandet wurden also zwei Dutzend Felder, die auf dem Bildschirm nie standen. Damit das nicht wiederkommt, unterscheidet die Aktion jetzt zwischen Eingabefehler und Formular-Schema-Bruch: Beanstandet die Pruefung ein Feld, das nicht abgeschickt wurde, sagt sie das ausdruecklich und schreibt es ins Serverlog, statt es dem Nutzer als Eingabefehler unterzuschieben, den er nirgends finden kann. 2. Eine betreute Person liess sich nicht endgueltig loeschen. Postgres setzt beim Loeschen den Verweis im Audit-Log auf NULL, und genau dieses UPDATE hat der Unveraenderlichkeits-Trigger abgewiesen. Damit scheiterte auch das Leeren des Papierkorbs nach 30 Tagen. Der Trigger erlaubt jetzt genau eine Ausnahme: das Nullen der beiden Fremdschluessel. Jede andere Aenderung bleibt verboten, auch das Setzen eines Fremdschluessels auf einen anderen Wert. Der Nachweis bleibt vollstaendig - es verschwindet nur der Verweis auf einen Datensatz, den es nicht mehr gibt. Geprueft: sieben neue Tests gegen die tatsaechlichen Formularinhalte, der Ablauf einmal komplett im Browser bis zum Dashboard, und der Trigger gegen eine laufende Datenbank - Loeschen gelingt, Faelschen scheitert. Etappe: 8 Phase: Haertung
257 lines
8.5 KiB
TypeScript
257 lines
8.5 KiB
TypeScript
'use server'
|
|
|
|
import { redirect } from 'next/navigation'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { z } from 'zod'
|
|
import { db, nichtGeloescht } from '@/lib/db'
|
|
import { sealen, oeffnen } from '@/lib/crypto/feld'
|
|
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
|
import { erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
|
import {
|
|
BETREUTE_GEHEIME_FELDER,
|
|
BetreuteSchema,
|
|
formularLesen,
|
|
} from '@/lib/validierung/betreute'
|
|
|
|
/**
|
|
* Stammdaten der betreuten Person anlegen und aendern.
|
|
*
|
|
* Das Bestellungsdatum ist hier das wichtigste Feld: aus ihm leiten sich
|
|
* das Betreuungsjahr und damit fast alle gesetzlichen Fristen ab. Wird es
|
|
* gesetzt oder geaendert, werden die Pflichtfristen neu abgeleitet.
|
|
*/
|
|
|
|
export type BetreuteZustand = {
|
|
fehler?: string
|
|
felder?: Record<string, string>
|
|
/** Rohwerte, damit ein langes Formular nach einem Fehler nicht leer ist. */
|
|
werte?: Record<string, string>
|
|
}
|
|
|
|
/** Teilt die geprueften Daten in Klartextfelder und zu verschluesselnde. */
|
|
function aufteilen(daten: z.infer<typeof BetreuteSchema>) {
|
|
const geheim: Record<string, string | null> = {}
|
|
const offen: Record<string, unknown> = {}
|
|
|
|
for (const [name, wert] of Object.entries(daten)) {
|
|
if ((BETREUTE_GEHEIME_FELDER as readonly string[]).includes(name)) {
|
|
geheim[name] = wert as string | null
|
|
} else {
|
|
offen[name] = wert
|
|
}
|
|
}
|
|
|
|
return { offen, verschluesselt: sealen('Betreute', geheim) }
|
|
}
|
|
|
|
function felderAusFehler(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?.[0]) ergebnis[feld] = meldungen[0]
|
|
}
|
|
return ergebnis
|
|
}
|
|
|
|
/**
|
|
* Baut die Antwort auf einen Pruefungsfehler.
|
|
*
|
|
* Der wichtige Teil ist die Unterscheidung: Beanstandet die Pruefung ein
|
|
* Feld, das gar nicht abgeschickt wurde, dann passt das Formular nicht zum
|
|
* Schema - und die Meldung liesse sich auf dem Bildschirm keinem Feld
|
|
* zuordnen. Der Nutzer saehe "Bitte pruefe die markierten Felder", ohne
|
|
* dass irgendetwas markiert waere, und kaeme nie weiter.
|
|
*
|
|
* Das ist ein Fehler im Programm, keiner in der Eingabe. Er wird deshalb
|
|
* benannt und ins Serverlog geschrieben, statt ihn dem Nutzer als
|
|
* Eingabefehler unterzuschieben.
|
|
*/
|
|
function antwortAufFehler(fehler: z.ZodError, formular: FormData): BetreuteZustand {
|
|
const felder = felderAusFehler(fehler)
|
|
const gesendet = new Set(formular.keys())
|
|
const nichtZuordenbar = Object.entries(felder).filter(([feld]) => !gesendet.has(feld))
|
|
|
|
if (nichtZuordenbar.length > 0) {
|
|
const liste = nichtZuordenbar.map(([feld, meldung]) => `${feld}: ${meldung}`).join('; ')
|
|
console.error(
|
|
`[stammdaten] Das Formular passt nicht zum Schema. Beanstandet wurden ` +
|
|
`Felder, die es gar nicht abgeschickt hat: ${liste}`,
|
|
)
|
|
return {
|
|
fehler:
|
|
'Das Formular passt nicht zur Prüfung — betroffen sind Felder, die ' +
|
|
'hier gar nicht stehen: ' +
|
|
nichtZuordenbar.map(([feld]) => feld).join(', ') +
|
|
'. Das ist ein Fehler in der Anwendung, nicht in deiner Eingabe. ' +
|
|
'Einzelheiten stehen im Serverlog.',
|
|
felder,
|
|
werte: werteMerken(formular),
|
|
}
|
|
}
|
|
|
|
return {
|
|
fehler: 'Bitte prüfe die markierten Felder.',
|
|
felder,
|
|
werte: werteMerken(formular),
|
|
}
|
|
}
|
|
|
|
function werteMerken(formular: FormData): Record<string, string> {
|
|
const werte: Record<string, string> = {}
|
|
for (const [name, wert] of formular.entries()) {
|
|
if (typeof wert === 'string') werte[name] = wert
|
|
}
|
|
return werte
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Anlegen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export async function betreuteAnlegen(
|
|
_vorher: BetreuteZustand,
|
|
formular: FormData,
|
|
): Promise<BetreuteZustand> {
|
|
const benutzer = await erfordereBenutzer()
|
|
|
|
// Nur Betreuer und Administratoren duerfen eine Betreuung anlegen.
|
|
if (benutzer.rolle === 'MITLESER') {
|
|
return { fehler: 'Als Mitleser kannst du keine Betreuung anlegen.' }
|
|
}
|
|
|
|
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
|
|
if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
|
|
|
|
const { offen, verschluesselt } = aufteilen(gelesen.data)
|
|
|
|
const betreute = await db.$transaction(async (tx) => {
|
|
const angelegt = await tx.betreute.create({
|
|
data: { ...offen, ...verschluesselt } as never,
|
|
})
|
|
|
|
// Der Anlegende bekommt sofort vollen Zugang - sonst saehe er die
|
|
// gerade angelegte Person nicht.
|
|
await tx.betreuungsZugang.create({
|
|
data: { userId: benutzer.id, betreuteId: angelegt.id, rolle: 'BETREUER' },
|
|
})
|
|
|
|
await protokollieren({
|
|
tx,
|
|
aktion: 'ERSTELLT',
|
|
entitaet: 'Betreute',
|
|
entitaetId: angelegt.id,
|
|
bezeichnung: `${angelegt.vorname} ${angelegt.nachname}`,
|
|
betreuteId: angelegt.id,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
})
|
|
|
|
return angelegt
|
|
})
|
|
|
|
// Aus dem Bestellungsdatum die gesetzlichen Pflichtfristen ableiten.
|
|
await pflichtfristenAbleitenWennMoeglich(betreute.id)
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/heute')
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Aendern
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export async function betreuteAendern(
|
|
_vorher: BetreuteZustand,
|
|
formular: FormData,
|
|
): Promise<BetreuteZustand> {
|
|
const betreuteId = String(formular.get('betreuteId') ?? '')
|
|
if (!betreuteId) return { fehler: 'Es fehlt die Angabe, wer geändert werden soll.' }
|
|
|
|
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
|
|
|
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
|
|
if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
|
|
|
|
const { offen, verschluesselt } = aufteilen(gelesen.data)
|
|
|
|
// Fuer das Protokoll wird der Klartext verglichen, nicht der Umschlag -
|
|
// der aendert sich bei jedem Speichern, auch wenn der Inhalt gleich ist.
|
|
const vorherOffen = oeffnen('Betreute', betreute as unknown as Record<string, unknown>)
|
|
const nachher = { ...offen, ...gelesen.data }
|
|
|
|
const bestellungGeaendert =
|
|
betreute.bestellungAm?.getTime() !== (gelesen.data.bestellungAm?.getTime() ?? undefined)
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.betreute.update({
|
|
where: { id: betreuteId },
|
|
data: { ...offen, ...verschluesselt } as never,
|
|
})
|
|
|
|
await protokollieren({
|
|
tx,
|
|
aktion: 'GEAENDERT',
|
|
entitaet: 'Betreute',
|
|
entitaetId: betreuteId,
|
|
bezeichnung: `${gelesen.data.vorname} ${gelesen.data.nachname}`,
|
|
betreuteId,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
aenderung: aenderungErmitteln(vorherOffen, nachher),
|
|
})
|
|
})
|
|
|
|
// Aendert sich das Bestellungsdatum, verschiebt sich das gesamte
|
|
// Betreuungsjahr - die Pflichtfristen muessen neu abgeleitet werden.
|
|
if (bestellungGeaendert) {
|
|
await pflichtfristenAbleitenWennMoeglich(betreuteId)
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/stammdaten')
|
|
}
|
|
|
|
/**
|
|
* Ruft die Fristenableitung auf, sobald das Fristenmodul vorhanden ist.
|
|
*
|
|
* Der dynamische Import haelt die Stammdaten unabhaengig: fehlt das Modul
|
|
* (frueher Bauzustand), wird die Ableitung uebersprungen, statt dass das
|
|
* Speichern der Stammdaten scheitert.
|
|
*/
|
|
async function pflichtfristenAbleitenWennMoeglich(betreuteId: string): Promise<void> {
|
|
try {
|
|
const modul = await import('@/lib/fristen/pflichtfristen')
|
|
await modul.pflichtfristenSchreiben(betreuteId)
|
|
} catch (fehler) {
|
|
console.warn('[stammdaten] Pflichtfristen konnten nicht abgeleitet werden:', fehler)
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Papierkorb
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export async function betreuteInPapierkorb(betreuteId: string): Promise<void> {
|
|
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
|
|
|
await db.betreute.update({
|
|
where: { id: betreuteId, ...nichtGeloescht },
|
|
data: { geloeschtAm: new Date() },
|
|
})
|
|
|
|
await protokollieren({
|
|
aktion: 'GELOESCHT',
|
|
entitaet: 'Betreute',
|
|
entitaetId: betreuteId,
|
|
bezeichnung: `${betreute.vorname} ${betreute.nachname}`,
|
|
betreuteId,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
})
|
|
|
|
revalidatePath('/', 'layout')
|
|
}
|