feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig. Ersteinrichtung in drei Schritten, bewusst ausserhalb des Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert. Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf. Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich sonst dauerhaft aussperren. Erst danach gibt es die Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash. Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts gerechnet statt ueber eine Differenz, damit Schaltjahre und der 29. Februar von selbst stimmen. Daran haengen Jahresbericht, Rechnungslegung und die Ausschlussfrist der Aufwandspauschale. Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch dann speicherbar bleiben, wenn das Fristenmodul fehlt. Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu. Die Aenderungen liegen verschluesselt, weil dort Diagnosen und Kontostaende stehen koennen. Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang. Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026, leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk mit acht Nachforderungen. Nur ausserhalb der Produktion. Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
'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
|
||||
}
|
||||
|
||||
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 {
|
||||
fehler: 'Bitte prüfe die markierten Felder.',
|
||||
felder: felderAusFehler(gelesen.error),
|
||||
werte: werteMerken(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 {
|
||||
fehler: 'Bitte prüfe die markierten Felder.',
|
||||
felder: felderAusFehler(gelesen.error),
|
||||
werte: werteMerken(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')
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||
import { pflichtfristenSchreibenAusfuehrlich } from '@/lib/fristen/pflichtfristen'
|
||||
import { naechsteFristAusErledigter } from '@/lib/fristen/wiederholung'
|
||||
import {
|
||||
FristErledigenSchema,
|
||||
FristSchema,
|
||||
FristVerschiebenSchema,
|
||||
fristFormularLesen,
|
||||
type FristZustand,
|
||||
} from '@/lib/validierung/fristen'
|
||||
|
||||
/**
|
||||
* Server Actions fuer Fristen.
|
||||
*
|
||||
* Jede Aktion durchlaeuft dieselben vier Schritte, in dieser Reihenfolge:
|
||||
* Zugriff pruefen (gegen die betreuteId, nicht gegen das UI), Eingabe mit
|
||||
* Zod validieren, schreiben, protokollieren. Das Audit-Log ist kein
|
||||
* Beiwerk - es ist der Nachweis gegenueber Gericht und Miterben, wann
|
||||
* welche Frist gesetzt, verschoben oder abgehakt wurde.
|
||||
*/
|
||||
|
||||
export type FristAktionErgebnis = { ok: true; hinweis?: string } | { ok: false; fehler: string }
|
||||
|
||||
const LISTE = '/fristen'
|
||||
|
||||
function pfadeAuffrischen(fristId?: string): void {
|
||||
revalidatePath(LISTE)
|
||||
revalidatePath('/fristen/kalender')
|
||||
revalidatePath('/heute')
|
||||
// Der Zaehler in der Navigation haengt am Layout.
|
||||
revalidatePath('/', 'layout')
|
||||
if (fristId) revalidatePath(`/fristen/${fristId}`)
|
||||
}
|
||||
|
||||
/** Uebersetzt geworfene Zugriffsfehler in eine Meldung fuer das Formular. */
|
||||
function alsFehler(fehler: unknown): string {
|
||||
if (fehler instanceof ZugriffVerweigert) return fehler.message
|
||||
console.error('[fristen] Aktion fehlgeschlagen:', fehler)
|
||||
return 'Das hat nicht geklappt. Bitte versuche es noch einmal.'
|
||||
}
|
||||
|
||||
async function fristLaden(betreuteId: string, fristId: string) {
|
||||
const frist = await db.frist.findFirst({
|
||||
where: { id: fristId, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!frist) throw new ZugriffVerweigert('Diese Frist gibt es nicht (mehr).')
|
||||
return frist
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Anlegen und Aendern
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function fristAnlegen(
|
||||
_vorher: FristZustand,
|
||||
formular: FormData,
|
||||
): Promise<FristZustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const roh = fristFormularLesen(formular)
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
|
||||
const gelesen = FristSchema.safeParse(roh)
|
||||
if (!gelesen.success) return fehlerAusZod(gelesen.error, roh)
|
||||
|
||||
const daten = gelesen.data
|
||||
const vorgang = await vorgangPruefen(betreuteId, daten.vorgangId)
|
||||
|
||||
const frist = await db.frist.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
vorgangId: vorgang,
|
||||
titel: daten.titel,
|
||||
beschreibung: daten.beschreibung,
|
||||
datum: daten.datum,
|
||||
typ: daten.typ,
|
||||
rechtsgrundlage: daten.rechtsgrundlage,
|
||||
vorwarnungTage: daten.vorwarnungTage,
|
||||
wiederholungRrule: daten.wiederholungRrule,
|
||||
ausschlussfrist: daten.ausschlussfrist,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: frist.id,
|
||||
bezeichnung: frist.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
pfadeAuffrischen(frist.id)
|
||||
} catch (fehler) {
|
||||
return { fehler: alsFehler(fehler), werte: rohAlsWerte(roh) }
|
||||
}
|
||||
|
||||
// redirect() wirft intern - deshalb steht es ausserhalb des try-Blocks.
|
||||
redirect(LISTE)
|
||||
}
|
||||
|
||||
export async function fristAendern(
|
||||
_vorher: FristZustand,
|
||||
formular: FormData,
|
||||
): Promise<FristZustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const fristId = String(formular.get('fristId') ?? '')
|
||||
const roh = fristFormularLesen(formular)
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
const vorher = await fristLaden(betreuteId, fristId)
|
||||
|
||||
const gelesen = FristSchema.safeParse(roh)
|
||||
if (!gelesen.success) return fehlerAusZod(gelesen.error, roh)
|
||||
|
||||
const daten = gelesen.data
|
||||
const vorgang = await vorgangPruefen(betreuteId, daten.vorgangId)
|
||||
|
||||
const nachher = await db.frist.update({
|
||||
where: { id: vorher.id },
|
||||
data: {
|
||||
vorgangId: vorgang,
|
||||
titel: daten.titel,
|
||||
beschreibung: daten.beschreibung,
|
||||
datum: daten.datum,
|
||||
typ: daten.typ,
|
||||
rechtsgrundlage: daten.rechtsgrundlage,
|
||||
vorwarnungTage: daten.vorwarnungTage,
|
||||
wiederholungRrule: daten.wiederholungRrule,
|
||||
ausschlussfrist: daten.ausschlussfrist,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: nachher.id,
|
||||
bezeichnung: nachher.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorher, nachher),
|
||||
})
|
||||
|
||||
pfadeAuffrischen(nachher.id)
|
||||
} catch (fehler) {
|
||||
return { fehler: alsFehler(fehler), werte: rohAlsWerte(roh) }
|
||||
}
|
||||
|
||||
redirect(`/fristen/${fristId}`)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Erledigen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hakt eine Frist ab. Ist sie wiederkehrend, entsteht in derselben
|
||||
* Transaktion die naechste - verkettet ueber vorgaengerFristId. Beides
|
||||
* gehoert zusammen: Eine erledigte Wiedervorlage ohne Nachfolger waere
|
||||
* eine Luecke in der Kette, die spaeter niemand mehr bemerkt.
|
||||
*/
|
||||
export async function fristErledigen(
|
||||
betreuteId: string,
|
||||
fristId: string,
|
||||
eingabe?: { erledigtAm?: string; notiz?: string },
|
||||
): Promise<FristAktionErgebnis> {
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
const frist = await fristLaden(betreuteId, fristId)
|
||||
|
||||
if (frist.status === 'ERLEDIGT') {
|
||||
return { ok: true, hinweis: 'Diese Frist war schon erledigt.' }
|
||||
}
|
||||
|
||||
const gelesen = FristErledigenSchema.safeParse({
|
||||
erledigtAm: eingabe?.erledigtAm ?? '',
|
||||
notiz: eingabe?.notiz ?? '',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { ok: false, fehler: 'Das Erledigungsdatum ist nicht lesbar.' }
|
||||
}
|
||||
|
||||
const erledigtAm = gelesen.data.erledigtAm ?? heuteAlsTag()
|
||||
const nachfolger = naechsteFristAusErledigter({
|
||||
id: frist.id,
|
||||
betreuteId: frist.betreuteId,
|
||||
vorgangId: frist.vorgangId,
|
||||
titel: frist.titel,
|
||||
beschreibung: frist.beschreibung,
|
||||
datum: frist.datum,
|
||||
typ: frist.typ,
|
||||
rechtsgrundlage: frist.rechtsgrundlage,
|
||||
vorwarnungTage: frist.vorwarnungTage,
|
||||
wiederholungRrule: frist.wiederholungRrule,
|
||||
ausschlussfrist: frist.ausschlussfrist,
|
||||
})
|
||||
|
||||
const notiz = gelesen.data.notiz
|
||||
const neueBeschreibung = notiz
|
||||
? `${frist.beschreibung ? `${frist.beschreibung}\n\n` : ''}Erledigt: ${notiz}`
|
||||
: frist.beschreibung
|
||||
|
||||
const neu = await db.$transaction(async (tx) => {
|
||||
await tx.frist.update({
|
||||
where: { id: frist.id },
|
||||
data: { status: 'ERLEDIGT', erledigtAm, beschreibung: neueBeschreibung },
|
||||
})
|
||||
|
||||
if (!nachfolger) return null
|
||||
return tx.frist.create({ data: nachfolger })
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: frist.id,
|
||||
bezeichnung: frist.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
status: { alt: frist.status, neu: 'ERLEDIGT' },
|
||||
erledigtAm: { alt: null, neu: erledigtAm.toISOString() },
|
||||
},
|
||||
})
|
||||
|
||||
if (neu) {
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: `${neu.titel} (Folgetermin)`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
pfadeAuffrischen(fristId)
|
||||
return {
|
||||
ok: true,
|
||||
hinweis: neu
|
||||
? `Erledigt. Der nächste Termin steht auf dem ${neu.datum.toLocaleDateString('de-DE', { timeZone: 'UTC' })}.`
|
||||
: 'Erledigt.',
|
||||
}
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: alsFehler(fehler) }
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Verschieben
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verschiebt den Stichtag. Die alte Frist bleibt als VERSCHOBEN stehen und
|
||||
* die neue zeigt ueber vorgaengerFristId auf sie - so bleibt nachweisbar,
|
||||
* dass hier etwas bewegt wurde und wann.
|
||||
*
|
||||
* Ausschlussfristen lassen sich nicht verschieben: Ein Verfallsdatum aus
|
||||
* dem Gesetz aendert sich nicht dadurch, dass man es im Kalender umtraegt.
|
||||
*/
|
||||
export async function fristVerschieben(
|
||||
betreuteId: string,
|
||||
fristId: string,
|
||||
neuesDatum: string,
|
||||
grund?: string,
|
||||
): Promise<FristAktionErgebnis> {
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
const frist = await fristLaden(betreuteId, fristId)
|
||||
|
||||
if (frist.ausschlussfrist) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler:
|
||||
'Eine Ausschlussfrist lässt sich nicht verschieben — das Datum steht im ' +
|
||||
'Gesetz. Wenn es falsch ist, ändere die Frist statt sie zu verschieben.',
|
||||
}
|
||||
}
|
||||
|
||||
const gelesen = FristVerschiebenSchema.safeParse({ datum: neuesDatum, grund: grund ?? '' })
|
||||
if (!gelesen.success) {
|
||||
return { ok: false, fehler: 'Bitte ein gültiges neues Datum wählen.' }
|
||||
}
|
||||
|
||||
const anmerkung = gelesen.data.grund
|
||||
? `Verschoben vom ${frist.datum.toLocaleDateString('de-DE', { timeZone: 'UTC' })}: ${gelesen.data.grund}`
|
||||
: `Verschoben vom ${frist.datum.toLocaleDateString('de-DE', { timeZone: 'UTC' })}.`
|
||||
|
||||
const neu = await db.$transaction(async (tx) => {
|
||||
await tx.frist.update({
|
||||
where: { id: frist.id },
|
||||
data: { status: 'VERSCHOBEN' },
|
||||
})
|
||||
return tx.frist.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
vorgangId: frist.vorgangId,
|
||||
titel: frist.titel,
|
||||
beschreibung: frist.beschreibung
|
||||
? `${frist.beschreibung}\n\n${anmerkung}`
|
||||
: anmerkung,
|
||||
datum: gelesen.data.datum,
|
||||
typ: frist.typ,
|
||||
rechtsgrundlage: frist.rechtsgrundlage,
|
||||
vorwarnungTage: frist.vorwarnungTage,
|
||||
wiederholungRrule: frist.wiederholungRrule,
|
||||
ausschlussfrist: frist.ausschlussfrist,
|
||||
vorgaengerFristId: frist.id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: frist.id,
|
||||
bezeichnung: frist.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
status: { alt: frist.status, neu: 'VERSCHOBEN' },
|
||||
datum: { alt: frist.datum.toISOString(), neu: gelesen.data.datum.toISOString() },
|
||||
},
|
||||
})
|
||||
|
||||
pfadeAuffrischen(neu.id)
|
||||
return { ok: true, hinweis: 'Die Frist steht jetzt auf dem neuen Datum.' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: alsFehler(fehler) }
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Entfallen lassen und Papierkorb
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* "Entfallen" heisst: Die Frist gab es, sie ist aber gegenstandslos
|
||||
* geworden - der Antrag wurde zurueckgenommen, das Konto aufgeloest. Der
|
||||
* Eintrag bleibt sichtbar, damit die Akte lueckenlos bleibt.
|
||||
*/
|
||||
export async function fristEntfallenLassen(
|
||||
betreuteId: string,
|
||||
fristId: string,
|
||||
grund?: string,
|
||||
): Promise<FristAktionErgebnis> {
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
const frist = await fristLaden(betreuteId, fristId)
|
||||
|
||||
const anmerkung = grund?.trim()
|
||||
? `Entfallen: ${grund.trim().slice(0, 500)}`
|
||||
: 'Entfallen — gegenstandslos geworden.'
|
||||
|
||||
await db.frist.update({
|
||||
where: { id: frist.id },
|
||||
data: {
|
||||
status: 'ENTFALLEN',
|
||||
beschreibung: frist.beschreibung ? `${frist.beschreibung}\n\n${anmerkung}` : anmerkung,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: frist.id,
|
||||
bezeichnung: frist.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: { status: { alt: frist.status, neu: 'ENTFALLEN' } },
|
||||
})
|
||||
|
||||
pfadeAuffrischen(fristId)
|
||||
return { ok: true, hinweis: 'Die Frist ist als entfallen vermerkt.' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: alsFehler(fehler) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Papierkorb statt Loeschen: geloeschtAm wird gesetzt, der Datensatz bleibt
|
||||
* 30 Tage bestehen. Endgueltig entfernt wird nur im Papierkorb selbst.
|
||||
*/
|
||||
export async function fristInPapierkorb(
|
||||
betreuteId: string,
|
||||
fristId: string,
|
||||
): Promise<FristAktionErgebnis> {
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
const frist = await fristLaden(betreuteId, fristId)
|
||||
|
||||
await db.frist.update({
|
||||
where: { id: frist.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Frist',
|
||||
entitaetId: frist.id,
|
||||
bezeichnung: frist.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
pfadeAuffrischen(fristId)
|
||||
return { ok: true, hinweis: 'Die Frist liegt jetzt im Papierkorb.' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: alsFehler(fehler) }
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Pflichtfristen ableiten
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Leitet die gesetzlichen Pflichtfristen aus dem Bestellungsdatum neu ab.
|
||||
* Mehrfaches Aufrufen ist unschaedlich: Der eindeutige Schluessel
|
||||
* betreuteId + systemSchluessel verhindert Dubletten, und was bereits
|
||||
* erledigt ist, wird nicht wieder aufgemacht.
|
||||
*/
|
||||
export async function pflichtfristenNeuAbleiten(
|
||||
betreuteId: string,
|
||||
jahreVoraus = 3,
|
||||
): Promise<FristAktionErgebnis> {
|
||||
try {
|
||||
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'FRISTEN', 'schreiben')
|
||||
|
||||
if (!betreute.bestellungAm) {
|
||||
return {
|
||||
ok: false,
|
||||
fehler:
|
||||
'Ohne Bestellungsdatum lässt sich keine gesetzliche Frist berechnen. ' +
|
||||
'Trag es zuerst in den Stammdaten ein.',
|
||||
}
|
||||
}
|
||||
|
||||
const bilanz = await pflichtfristenSchreibenAusfuehrlich(betreuteId, jahreVoraus)
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Frist',
|
||||
bezeichnung: `Pflichtfristen abgeleitet (${bilanz.angelegt} neu, ${bilanz.aktualisiert} angepasst)`,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
pfadeAuffrischen()
|
||||
|
||||
if (bilanz.angelegt === 0 && bilanz.aktualisiert === 0) {
|
||||
return { ok: true, hinweis: 'Alle Pflichtfristen standen schon im Kalender.' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
hinweis:
|
||||
`${bilanz.angelegt} Frist${bilanz.angelegt === 1 ? '' : 'en'} neu angelegt` +
|
||||
(bilanz.aktualisiert > 0 ? `, ${bilanz.aktualisiert} angepasst.` : '.'),
|
||||
}
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: alsFehler(fehler) }
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Der Vorgang muss zur selben betreuten Person gehoeren - sonst nicht setzen. */
|
||||
async function vorgangPruefen(betreuteId: string, vorgangId: string | null): Promise<string | null> {
|
||||
if (!vorgangId) return null
|
||||
const vorgang = await db.vorgang.findFirst({
|
||||
where: { id: vorgangId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
return vorgang?.id ?? null
|
||||
}
|
||||
|
||||
function fehlerAusZod(
|
||||
fehler: { flatten: () => { fieldErrors: Record<string, string[] | undefined> } },
|
||||
roh: Record<string, unknown>,
|
||||
): FristZustand {
|
||||
const felder = fehler.flatten().fieldErrors
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: {
|
||||
titel: felder.titel?.[0],
|
||||
beschreibung: felder.beschreibung?.[0],
|
||||
datum: felder.datum?.[0],
|
||||
typ: felder.typ?.[0],
|
||||
rechtsgrundlage: felder.rechtsgrundlage?.[0],
|
||||
vorwarnungTage: felder.vorwarnungTage?.[0],
|
||||
wiederholungRrule: felder.wiederholungRrule?.[0],
|
||||
ausschlussfrist: felder.ausschlussfrist?.[0],
|
||||
vorgangId: felder.vorgangId?.[0],
|
||||
},
|
||||
werte: rohAlsWerte(roh),
|
||||
}
|
||||
}
|
||||
|
||||
/** Die eingegebenen Werte zurueck ins Formular, damit nichts verloren geht. */
|
||||
function rohAlsWerte(roh: Record<string, unknown>): Record<string, string | string[]> {
|
||||
const werte: Record<string, string | string[]> = {}
|
||||
for (const [name, wert] of Object.entries(roh)) {
|
||||
if (Array.isArray(wert)) werte[name] = wert.map(String)
|
||||
else if (typeof wert === 'boolean') werte[name] = wert ? 'on' : ''
|
||||
else werte[name] = String(wert ?? '')
|
||||
}
|
||||
return werte
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { GenehmigungStatus } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import {
|
||||
felderAusFehler,
|
||||
formularLesen,
|
||||
GenehmigungBeschlussSchema,
|
||||
GenehmigungSchema,
|
||||
GenehmigungStatusSchema,
|
||||
type Formularzustand,
|
||||
} from '@/lib/validierung/vorgaenge'
|
||||
import {
|
||||
darfStatusWechseln,
|
||||
datumsfeldFuer,
|
||||
GENEHMIGUNG_STATUS_BESCHRIFTUNG,
|
||||
} from '@/lib/vorgaenge/genehmigungskette'
|
||||
import { alsTag } from '@/lib/vorgaenge/vorlagen'
|
||||
|
||||
/**
|
||||
* Server Actions für die Genehmigungskette.
|
||||
*
|
||||
* Die Kernregel steht in src/lib/vorgaenge/genehmigungskette.ts und wird
|
||||
* hier ein zweites Mal angewendet - absichtlich. Ein Auswahlfeld, das den
|
||||
* unerlaubten Status gar nicht erst anbietet, ist Bequemlichkeit; die
|
||||
* Sperre gegen einen Vollzug ohne Beschlussdokument ist eine Haftungsfrage
|
||||
* (§ 1858 BGB) und gehört deshalb auf den Server.
|
||||
*/
|
||||
|
||||
async function genehmigungMitZugang(genehmigungId: string) {
|
||||
const genehmigung = await db.genehmigung.findFirst({
|
||||
where: { id: genehmigungId, ...nichtGeloescht },
|
||||
})
|
||||
if (!genehmigung) return null
|
||||
|
||||
const kontext = await erfordereZugang(genehmigung.betreuteId, 'VORGAENGE', 'schreiben')
|
||||
return { genehmigung, kontext }
|
||||
}
|
||||
|
||||
function pfadeAuffrischen(vorgangId: string): void {
|
||||
revalidatePath('/vorgaenge')
|
||||
revalidatePath(`/vorgaenge/${vorgangId}`)
|
||||
revalidatePath('/heute')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Anlegen und Ändern
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function genehmigungAnlegen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const gelesen = GenehmigungSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const vorgang = await db.vorgang.findFirst({
|
||||
where: { id: gelesen.data.vorgangId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||
|
||||
const { benutzer } = await erfordereZugang(vorgang.betreuteId, 'VORGAENGE', 'schreiben')
|
||||
|
||||
const eingabe = gelesen.data
|
||||
|
||||
const genehmigung = await db.$transaction(async (tx) => {
|
||||
const neu = await tx.genehmigung.create({
|
||||
data: {
|
||||
betreuteId: vorgang.betreuteId,
|
||||
vorgangId: vorgang.id,
|
||||
titel: eingabe.titel,
|
||||
rechtsgrundlage: eingabe.rechtsgrundlage,
|
||||
status: 'GEPLANT',
|
||||
mitteilungAm: eingabe.mitteilungAm,
|
||||
antragAm: eingabe.antragAm,
|
||||
anhoerungAm: eingabe.anhoerungAm,
|
||||
beschlussAm: eingabe.beschlussAm,
|
||||
rechtskraftAm: eingabe.rechtskraftAm,
|
||||
// Der Vollzug wird nie beim Anlegen gesetzt - er verlangt einen
|
||||
// hinterlegten Beschluss und laeuft ueber den Statuswechsel.
|
||||
vollzugAm: null,
|
||||
...sealen('Genehmigung', { notizen: eingabe.notizen }),
|
||||
},
|
||||
})
|
||||
|
||||
// Ein Vorgang mit Genehmigung ist genehmigungspflichtig - auch wenn
|
||||
// beim Anlegen das Haekchen vergessen wurde.
|
||||
if (!vorgang.genehmigungspflichtig) {
|
||||
await tx.vorgang.update({
|
||||
where: { id: vorgang.id },
|
||||
data: { genehmigungspflichtig: true },
|
||||
})
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
tx,
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Genehmigung',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: `${neu.titel} (${neu.rechtsgrundlage})`,
|
||||
betreuteId: vorgang.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
titel: { alt: null, neu: neu.titel },
|
||||
rechtsgrundlage: { alt: null, neu: neu.rechtsgrundlage },
|
||||
},
|
||||
})
|
||||
|
||||
return neu
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
return { erfolg: `Genehmigung „${genehmigung.titel}“ angelegt.` }
|
||||
}
|
||||
|
||||
export async function genehmigungAendern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const genehmigungId = String(formular.get('genehmigungId') ?? '')
|
||||
const geladen = await genehmigungMitZugang(genehmigungId)
|
||||
if (!geladen) return { fehler: 'Diese Genehmigung gibt es nicht (mehr).' }
|
||||
|
||||
const { genehmigung, kontext } = geladen
|
||||
|
||||
const gelesen = GenehmigungSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const eingabe = gelesen.data
|
||||
const vorherOffen = oeffnen('Genehmigung', genehmigung)
|
||||
|
||||
const geaendert = await db.genehmigung.update({
|
||||
where: { id: genehmigung.id },
|
||||
data: {
|
||||
titel: eingabe.titel,
|
||||
rechtsgrundlage: eingabe.rechtsgrundlage,
|
||||
mitteilungAm: eingabe.mitteilungAm,
|
||||
antragAm: eingabe.antragAm,
|
||||
anhoerungAm: eingabe.anhoerungAm,
|
||||
beschlussAm: eingabe.beschlussAm,
|
||||
rechtskraftAm: eingabe.rechtskraftAm,
|
||||
...sealen('Genehmigung', { notizen: eingabe.notizen }),
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Genehmigung',
|
||||
entitaetId: genehmigung.id,
|
||||
bezeichnung: geaendert.titel,
|
||||
betreuteId: genehmigung.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: {
|
||||
...aenderungErmitteln(genehmigung, geaendert),
|
||||
...(vorherOffen.notizen !== (eingabe.notizen ?? null)
|
||||
? { notizen: { alt: vorherOffen.notizen, neu: eingabe.notizen } }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
pfadeAuffrischen(genehmigung.vorgangId)
|
||||
return { erfolg: 'Die Genehmigung ist gespeichert.' }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Statuswechsel - die abnahmerelevante Stelle
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function genehmigungStatusWechseln(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const gelesen = GenehmigungStatusSchema.safeParse({
|
||||
genehmigungId: formular.get('genehmigungId'),
|
||||
status: formular.get('status'),
|
||||
datum: formular.get('datum') ?? '',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const geladen = await genehmigungMitZugang(gelesen.data.genehmigungId)
|
||||
if (!geladen) return { fehler: 'Diese Genehmigung gibt es nicht (mehr).' }
|
||||
|
||||
const { genehmigung, kontext } = geladen
|
||||
const neuerStatus = gelesen.data.status as GenehmigungStatus
|
||||
|
||||
// Hier, nicht nur im UI: das Auswahlfeld laesst sich faelschen.
|
||||
const pruefung = darfStatusWechseln(genehmigung, neuerStatus)
|
||||
if (!pruefung.erlaubt) {
|
||||
return { fehler: pruefung.grund ?? 'Dieser Statuswechsel ist nicht möglich.' }
|
||||
}
|
||||
|
||||
const datum = gelesen.data.datum ?? alsTag(new Date())
|
||||
const feld = datumsfeldFuer(neuerStatus)
|
||||
|
||||
const geaendert = await db.genehmigung.update({
|
||||
where: { id: genehmigung.id },
|
||||
data: {
|
||||
status: neuerStatus,
|
||||
...(feld ? { [feld]: datum } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
// Der Vorgang folgt der Kette: solange das Gericht am Zug ist, wartet er
|
||||
// auf die Genehmigung; ist sie vollzogen, ist der Vorgang meist erledigt.
|
||||
const vorgangsStatus =
|
||||
neuerStatus === 'VOLLZOGEN'
|
||||
? 'ERLEDIGT'
|
||||
: neuerStatus === 'MITGETEILT' || neuerStatus === 'BEANTRAGT' || neuerStatus === 'ANGEHOERT'
|
||||
? 'WARTET_AUF_GENEHMIGUNG'
|
||||
: null
|
||||
|
||||
if (vorgangsStatus) {
|
||||
await db.vorgang.update({
|
||||
where: { id: genehmigung.vorgangId },
|
||||
data: {
|
||||
status: vorgangsStatus,
|
||||
erledigtAm: vorgangsStatus === 'ERLEDIGT' ? new Date() : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Genehmigung',
|
||||
entitaetId: genehmigung.id,
|
||||
bezeichnung: geaendert.titel,
|
||||
betreuteId: genehmigung.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: { status: { alt: genehmigung.status, neu: neuerStatus } },
|
||||
})
|
||||
|
||||
pfadeAuffrischen(genehmigung.vorgangId)
|
||||
return {
|
||||
erfolg: `Status: ${GENEHMIGUNG_STATUS_BESCHRIFTUNG[neuerStatus]}.`,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Beschlussdokument
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verknüpft den Genehmigungsbeschluss als Dokument.
|
||||
*
|
||||
* Ohne diese Verknüpfung bleibt der Vollzug gesperrt. Das Dokument muss
|
||||
* zur selben betreuten Person gehören - sonst hinge ein fremder Beschluss
|
||||
* an dieser Akte.
|
||||
*/
|
||||
export async function beschlussVerknuepfen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const gelesen = GenehmigungBeschlussSchema.safeParse({
|
||||
genehmigungId: formular.get('genehmigungId'),
|
||||
dokumentId: formular.get('dokumentId') ?? '',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const geladen = await genehmigungMitZugang(gelesen.data.genehmigungId)
|
||||
if (!geladen) return { fehler: 'Diese Genehmigung gibt es nicht (mehr).' }
|
||||
|
||||
const { genehmigung, kontext } = geladen
|
||||
const dokumentId = gelesen.data.dokumentId
|
||||
|
||||
if (dokumentId) {
|
||||
const dokument = await db.dokument.findFirst({
|
||||
where: { id: dokumentId, betreuteId: genehmigung.betreuteId, ...nichtGeloescht },
|
||||
select: { id: true, titel: true },
|
||||
})
|
||||
if (!dokument) {
|
||||
return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
}
|
||||
|
||||
await db.genehmigung.update({
|
||||
where: { id: genehmigung.id },
|
||||
data: { beschlussDokumentId: dokumentId },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Genehmigung',
|
||||
entitaetId: genehmigung.id,
|
||||
bezeichnung: genehmigung.titel,
|
||||
betreuteId: genehmigung.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: {
|
||||
beschlussDokumentId: { alt: genehmigung.beschlussDokumentId, neu: dokumentId },
|
||||
},
|
||||
})
|
||||
|
||||
pfadeAuffrischen(genehmigung.vorgangId)
|
||||
return {
|
||||
erfolg: dokumentId
|
||||
? 'Beschluss hinterlegt. Der Vollzug lässt sich jetzt eintragen.'
|
||||
: 'Verknüpfung zum Beschluss gelöst.',
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Papierkorb
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function genehmigungInPapierkorb(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const geladen = await genehmigungMitZugang(String(formular.get('genehmigungId') ?? ''))
|
||||
if (!geladen) return { fehler: 'Diese Genehmigung gibt es nicht (mehr).' }
|
||||
|
||||
const { genehmigung, kontext } = geladen
|
||||
|
||||
if (genehmigung.status === 'VOLLZOGEN') {
|
||||
return {
|
||||
fehler:
|
||||
'Eine vollzogene Genehmigung bleibt in der Akte. Sie ist der Nachweis gegenüber ' +
|
||||
'Gericht und Miterben.',
|
||||
}
|
||||
}
|
||||
|
||||
await db.genehmigung.update({
|
||||
where: { id: genehmigung.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Genehmigung',
|
||||
entitaetId: genehmigung.id,
|
||||
bezeichnung: genehmigung.titel,
|
||||
betreuteId: genehmigung.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
})
|
||||
|
||||
pfadeAuffrischen(genehmigung.vorgangId)
|
||||
return { erfolg: 'Die Genehmigung liegt im Papierkorb.' }
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { erfordereZugang } from '@/lib/auth/sitzung'
|
||||
import { sealen } from '@/lib/crypto/feld'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import {
|
||||
KontaktSchema,
|
||||
KontaktprotokollSchema,
|
||||
kontaktFormularLesen,
|
||||
protokollFormularLesen,
|
||||
} from '@/lib/validierung/kontakte'
|
||||
|
||||
/**
|
||||
* Server Actions fuer Kontakte und Kontaktprotokolle.
|
||||
*
|
||||
* Jede Aktion prueft zuerst den Zugang gegen die betreuteId - auch dann,
|
||||
* wenn die Seite das schon getan hat. Die betreuteId kommt aus dem
|
||||
* Formular; das ist unbedenklich, weil erfordereZugang() genau gegen
|
||||
* diesen Wert prueft und wirft, wenn der Benutzer dort nichts zu suchen
|
||||
* hat.
|
||||
*
|
||||
* Die Freitexte werden ueber sealen() verschluesselt. Sie werden nie im
|
||||
* Klartext gespeichert und stehen deshalb auch nicht im Audit-Log-Diff -
|
||||
* aenderungErmitteln() ueberspringt Felder mit dem Suffix.
|
||||
*/
|
||||
|
||||
export type KontaktZustand = {
|
||||
fehler?: string
|
||||
felder?: Record<string, string>
|
||||
werte?: Record<string, string>
|
||||
}
|
||||
|
||||
const LEER: KontaktZustand = {}
|
||||
|
||||
function felderAusZod(fehler: { fieldErrors: Record<string, string[] | undefined> }) {
|
||||
const felder: Record<string, string> = {}
|
||||
for (const [feld, meldungen] of Object.entries(fehler.fieldErrors)) {
|
||||
const erste = meldungen?.[0]
|
||||
if (erste) felder[feld] = erste
|
||||
}
|
||||
return felder
|
||||
}
|
||||
|
||||
/** Alle Formularwerte als Text zurueck ans Formular, damit nichts verloren geht. */
|
||||
function werteAusFormular(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
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Kontakt
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Legt einen Kontakt an oder aendert ihn. Ist ein Feld "id" gesetzt, wird
|
||||
* geaendert; sonst wird angelegt.
|
||||
*/
|
||||
export async function kontaktSpeichern(
|
||||
_vorher: KontaktZustand,
|
||||
formular: FormData,
|
||||
): Promise<KontaktZustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const id = String(formular.get('id') ?? '').trim()
|
||||
|
||||
if (!betreuteId) {
|
||||
return { fehler: 'Es ist keine betreute Person ausgewählt.' }
|
||||
}
|
||||
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
|
||||
|
||||
const gelesen = KontaktSchema.safeParse(kontaktFormularLesen(formular))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: felderAusZod(gelesen.error.flatten()),
|
||||
werte: werteAusFormular(formular),
|
||||
}
|
||||
}
|
||||
|
||||
const { notizen, ...offen } = gelesen.data
|
||||
const daten = { ...offen, ...sealen('Kontakt', { notizen }) }
|
||||
|
||||
let zielId = id
|
||||
|
||||
if (id) {
|
||||
const vorhanden = await db.kontakt.findFirst({
|
||||
where: { id, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorhanden) {
|
||||
return { fehler: 'Diesen Kontakt gibt es nicht (mehr).' }
|
||||
}
|
||||
|
||||
const nachher = await db.kontakt.update({ where: { id }, data: daten })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Kontakt',
|
||||
entitaetId: nachher.id,
|
||||
bezeichnung: nachher.name,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorhanden, nachher),
|
||||
})
|
||||
} else {
|
||||
const angelegt = await db.kontakt.create({ data: { ...daten, betreuteId } })
|
||||
zielId = angelegt.id
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Kontakt',
|
||||
entitaetId: angelegt.id,
|
||||
bezeichnung: angelegt.name,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/kontakte')
|
||||
revalidatePath(`/kontakte/${zielId}`)
|
||||
revalidatePath('/heute')
|
||||
|
||||
redirect(`/kontakte/${zielId}`)
|
||||
}
|
||||
|
||||
/** Papierkorb: geloeschtAm setzen, nichts hart entfernen. */
|
||||
export async function kontaktInPapierkorb(formular: FormData): Promise<void> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const id = String(formular.get('id') ?? '')
|
||||
if (!betreuteId || !id) redirect('/kontakte')
|
||||
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
|
||||
|
||||
const kontakt = await db.kontakt.findFirst({ where: { id, betreuteId, ...nichtGeloescht } })
|
||||
if (!kontakt) redirect('/kontakte')
|
||||
|
||||
await db.kontakt.update({ where: { id }, data: { geloeschtAm: new Date() } })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Kontakt',
|
||||
entitaetId: id,
|
||||
bezeichnung: kontakt.name,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/kontakte')
|
||||
revalidatePath('/heute')
|
||||
redirect('/kontakte')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Kontaktprotokoll
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Speichert ein Kontaktprotokoll. Das ist die Aktion hinter der
|
||||
* Schnellerfassung - sie muss auch dann durchgehen, wenn ausser Datum,
|
||||
* Art und einem Satz Text nichts ausgefuellt ist.
|
||||
*/
|
||||
export async function protokollSpeichern(
|
||||
_vorher: KontaktZustand,
|
||||
formular: FormData,
|
||||
): Promise<KontaktZustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const id = String(formular.get('id') ?? '').trim()
|
||||
const weiter = String(formular.get('weiter') ?? '').trim()
|
||||
|
||||
if (!betreuteId) {
|
||||
return { fehler: 'Es ist keine betreute Person ausgewählt.' }
|
||||
}
|
||||
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
|
||||
|
||||
const gelesen = KontaktprotokollSchema.safeParse(protokollFormularLesen(formular))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: felderAusZod(gelesen.error.flatten()),
|
||||
werte: werteAusFormular(formular),
|
||||
}
|
||||
}
|
||||
|
||||
const { inhalt, wuensche, folgeaufgaben, kontaktId, vorgangId, ...offen } = gelesen.data
|
||||
|
||||
// Fremde Kontakte und Vorgaenge gehoeren nicht in dieses Protokoll: die
|
||||
// Verknuepfung wird gegen dieselbe betreuteId geprueft.
|
||||
const [kontaktGueltig, vorgangGueltig] = await Promise.all([
|
||||
kontaktId
|
||||
? db.kontakt.findFirst({ where: { id: kontaktId, betreuteId, ...nichtGeloescht } })
|
||||
: null,
|
||||
vorgangId
|
||||
? db.vorgang.findFirst({ where: { id: vorgangId, betreuteId, ...nichtGeloescht } })
|
||||
: null,
|
||||
])
|
||||
|
||||
if (kontaktId && !kontaktGueltig) {
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: { kontaktId: 'Diesen Kontakt gibt es nicht (mehr).' },
|
||||
werte: werteAusFormular(formular),
|
||||
}
|
||||
}
|
||||
if (vorgangId && !vorgangGueltig) {
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: { vorgangId: 'Diesen Vorgang gibt es nicht (mehr).' },
|
||||
werte: werteAusFormular(formular),
|
||||
}
|
||||
}
|
||||
|
||||
const daten = {
|
||||
...offen,
|
||||
kontaktId,
|
||||
vorgangId,
|
||||
...sealen('Kontaktprotokoll', { inhalt, wuensche, folgeaufgaben }),
|
||||
}
|
||||
|
||||
let zielId = id
|
||||
|
||||
if (id) {
|
||||
const vorhanden = await db.kontaktprotokoll.findFirst({
|
||||
where: { id, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorhanden) {
|
||||
return { fehler: 'Dieses Protokoll gibt es nicht (mehr).' }
|
||||
}
|
||||
|
||||
const nachher = await db.kontaktprotokoll.update({ where: { id }, data: daten })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Kontaktprotokoll',
|
||||
entitaetId: nachher.id,
|
||||
bezeichnung: protokollBezeichnung(nachher.datum, nachher.art),
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(vorhanden, nachher),
|
||||
})
|
||||
} else {
|
||||
const angelegt = await db.kontaktprotokoll.create({ data: { ...daten, betreuteId } })
|
||||
zielId = angelegt.id
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Kontaktprotokoll',
|
||||
entitaetId: angelegt.id,
|
||||
bezeichnung: protokollBezeichnung(angelegt.datum, angelegt.art),
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/kontakte/protokoll')
|
||||
revalidatePath('/kontakte')
|
||||
revalidatePath('/heute')
|
||||
if (kontaktId) revalidatePath(`/kontakte/${kontaktId}`)
|
||||
|
||||
// "weiter" kommt aus dem Formular und darf deshalb nur ein Pfad in
|
||||
// dieser Anwendung sein - kein "//fremde-seite.example".
|
||||
redirect(sichererPfad(weiter) ?? `/kontakte/protokoll?hervorgehoben=${zielId}`)
|
||||
}
|
||||
|
||||
export async function protokollInPapierkorb(formular: FormData): Promise<void> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
const id = String(formular.get('id') ?? '')
|
||||
if (!betreuteId || !id) redirect('/kontakte/protokoll')
|
||||
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
|
||||
|
||||
const protokoll = await db.kontaktprotokoll.findFirst({
|
||||
where: { id, betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!protokoll) redirect('/kontakte/protokoll')
|
||||
|
||||
await db.kontaktprotokoll.update({ where: { id }, data: { geloeschtAm: new Date() } })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Kontaktprotokoll',
|
||||
entitaetId: id,
|
||||
bezeichnung: protokollBezeichnung(protokoll.datum, protokoll.art),
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/kontakte/protokoll')
|
||||
revalidatePath('/kontakte')
|
||||
revalidatePath('/heute')
|
||||
if (protokoll.kontaktId) revalidatePath(`/kontakte/${protokoll.kontaktId}`)
|
||||
|
||||
redirect('/kontakte/protokoll')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Kleinkram
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bezeichnung fuer das Audit-Log. Bewusst ohne Inhalt: was besprochen
|
||||
* wurde, steht verschluesselt im Datensatz und hat im Protokolltitel
|
||||
* nichts verloren.
|
||||
*/
|
||||
function protokollBezeichnung(datum: Date, art: string): string {
|
||||
const tag = String(datum.getUTCDate()).padStart(2, '0')
|
||||
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${art} am ${tag}.${monat}.${datum.getUTCFullYear()}`
|
||||
}
|
||||
|
||||
/** Nur anwendungsinterne Pfade zulassen. */
|
||||
function sichererPfad(pfad: string): string | null {
|
||||
if (!pfad.startsWith('/')) return null
|
||||
if (pfad.startsWith('//')) return null
|
||||
return pfad
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
'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 } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import {
|
||||
AufgabeAbhakenSchema,
|
||||
AufgabeSchema,
|
||||
felderAusFehler,
|
||||
formularLesen,
|
||||
VorgangAnlegenSchema,
|
||||
VorgangSchema,
|
||||
VorgangStatusSchema,
|
||||
type Formularzustand,
|
||||
} from '@/lib/validierung/vorgaenge'
|
||||
import { alsTag, vorlageAnwenden } from '@/lib/vorgaenge/vorlagen'
|
||||
|
||||
/**
|
||||
* Server Actions für die Fallakte.
|
||||
*
|
||||
* Drei Dinge passieren in jeder einzelnen Aktion, ohne Ausnahme:
|
||||
* Zugriffsprüfung gegen die betreuteId, Zod-Validierung, Audit-Eintrag.
|
||||
* Die Oberfläche darf nichts davon ersetzen - sie lässt sich umgehen.
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helfer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lädt einen Vorgang und prüft in einem Zug den Zugriff.
|
||||
*
|
||||
* Die betreuteId kommt aus dem Datensatz, nicht aus dem Formular - sonst
|
||||
* könnte ein manipuliertes Formular einen fremden Vorgang unter der
|
||||
* eigenen betreuteId anfassen.
|
||||
*/
|
||||
async function vorgangMitZugang(vorgangId: string) {
|
||||
const vorgang = await db.vorgang.findFirst({
|
||||
where: { id: vorgangId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorgang) return null
|
||||
|
||||
const kontext = await erfordereZugang(vorgang.betreuteId, 'VORGAENGE', 'schreiben')
|
||||
return { vorgang, kontext }
|
||||
}
|
||||
|
||||
function pfadeAuffrischen(vorgangId?: string): void {
|
||||
revalidatePath('/vorgaenge')
|
||||
revalidatePath('/heute')
|
||||
if (vorgangId) revalidatePath(`/vorgaenge/${vorgangId}`)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Anlegen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function vorgangAnlegen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||
if (!betreuteId) return { fehler: 'Es ist keine betreute Person ausgewählt.' }
|
||||
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'VORGAENGE', 'schreiben')
|
||||
|
||||
const gelesen = VorgangAnlegenSchema.safeParse(
|
||||
formularLesen(formular, ['genehmigungspflichtig']),
|
||||
)
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: felderAusFehler(gelesen.error),
|
||||
werte: { titel: String(formular.get('titel') ?? '') },
|
||||
}
|
||||
}
|
||||
|
||||
const eingabe = gelesen.data
|
||||
const jetzt = new Date()
|
||||
|
||||
// Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles
|
||||
// in einer Transaktion: ein Vorgang ohne seine Aufgabenliste waere
|
||||
// schlimmer als gar keiner, weil man ihn fuer vollstaendig hielte.
|
||||
const ergebnis = eingabe.vorlageSchluessel
|
||||
? vorlageAnwenden(eingabe.vorlageSchluessel, jetzt)
|
||||
: null
|
||||
|
||||
const genehmigungspflichtig =
|
||||
ergebnis?.vorlage.genehmigungspflichtig ?? eingabe.genehmigungspflichtig
|
||||
|
||||
const vorgang = await db.$transaction(async (tx) => {
|
||||
const neu = await tx.vorgang.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
titel: eingabe.titel,
|
||||
kategorie: eingabe.kategorie,
|
||||
status: eingabe.status,
|
||||
prioritaet: eingabe.prioritaet,
|
||||
gegenstelle: eingabe.gegenstelle,
|
||||
aktenzeichen: eingabe.aktenzeichen,
|
||||
verantwortlich: eingabe.verantwortlich ?? benutzer.name,
|
||||
naechsterSchritt:
|
||||
eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null,
|
||||
faelligAm: eingabe.faelligAm,
|
||||
letzterKontaktAm: eingabe.letzterKontaktAm,
|
||||
genehmigungspflichtig,
|
||||
vorlageSchluessel: eingabe.vorlageSchluessel,
|
||||
angelegtVonId: benutzer.id,
|
||||
...sealen('Vorgang', {
|
||||
beschreibung: eingabe.beschreibung ?? ergebnis?.vorlage.beschreibung ?? null,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (ergebnis) {
|
||||
if (ergebnis.aufgaben.length > 0) {
|
||||
await tx.aufgabe.createMany({
|
||||
data: ergebnis.aufgaben.map((aufgabe) => ({
|
||||
vorgangId: neu.id,
|
||||
titel: aufgabe.titel,
|
||||
beschreibung: aufgabe.beschreibung,
|
||||
faelligAm: aufgabe.faelligAm,
|
||||
reihenfolge: aufgabe.reihenfolge,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (ergebnis.fristen.length > 0) {
|
||||
await tx.frist.createMany({
|
||||
data: ergebnis.fristen.map((frist) => ({
|
||||
betreuteId,
|
||||
vorgangId: neu.id,
|
||||
titel: frist.titel,
|
||||
datum: frist.datum,
|
||||
// Aus einer Vorlage abgeleitete Fristen sind Wiedervorlagen,
|
||||
// solange keine Vorschrift dahintersteht.
|
||||
typ: frist.rechtsgrundlage ? 'GESETZLICH' : 'WIEDERVORLAGE',
|
||||
rechtsgrundlage: frist.rechtsgrundlage,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Genehmigungspflichtige Vorlagen bringen die Kette gleich mit -
|
||||
// damit sie nicht vergessen wird (§ 1858 BGB).
|
||||
if (ergebnis.vorlage.genehmigungspflichtig && ergebnis.vorlage.rechtsgrundlage) {
|
||||
await tx.genehmigung.create({
|
||||
data: {
|
||||
betreuteId,
|
||||
vorgangId: neu.id,
|
||||
titel: ergebnis.vorlage.titel,
|
||||
rechtsgrundlage: ergebnis.vorlage.rechtsgrundlage,
|
||||
status: 'GEPLANT',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
tx,
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Vorgang',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.titel,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: {
|
||||
titel: { alt: null, neu: neu.titel },
|
||||
kategorie: { alt: null, neu: neu.kategorie },
|
||||
vorlageSchluessel: { alt: null, neu: neu.vorlageSchluessel },
|
||||
},
|
||||
})
|
||||
|
||||
return neu
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
redirect(`/vorgaenge/${vorgang.id}`)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ändern
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function vorgangAendern(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const vorgangId = String(formular.get('vorgangId') ?? '')
|
||||
const geladen = await vorgangMitZugang(vorgangId)
|
||||
if (!geladen) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||
|
||||
const { vorgang, kontext } = geladen
|
||||
|
||||
const gelesen = VorgangSchema.safeParse(formularLesen(formular, ['genehmigungspflichtig']))
|
||||
if (!gelesen.success) {
|
||||
return {
|
||||
fehler: 'Bitte prüfe deine Eingaben.',
|
||||
felder: felderAusFehler(gelesen.error),
|
||||
}
|
||||
}
|
||||
|
||||
const eingabe = gelesen.data
|
||||
const vorherOffen = oeffnen('Vorgang', vorgang)
|
||||
|
||||
const daten: Prisma.VorgangUpdateInput = {
|
||||
titel: eingabe.titel,
|
||||
kategorie: eingabe.kategorie,
|
||||
status: eingabe.status,
|
||||
prioritaet: eingabe.prioritaet,
|
||||
gegenstelle: eingabe.gegenstelle,
|
||||
aktenzeichen: eingabe.aktenzeichen,
|
||||
verantwortlich: eingabe.verantwortlich,
|
||||
naechsterSchritt: eingabe.naechsterSchritt,
|
||||
faelligAm: eingabe.faelligAm,
|
||||
letzterKontaktAm: eingabe.letzterKontaktAm,
|
||||
genehmigungspflichtig: eingabe.genehmigungspflichtig,
|
||||
// Erledigt heisst erledigt: das Datum wird gesetzt bzw. wieder geloescht.
|
||||
erledigtAm:
|
||||
eingabe.status === 'ERLEDIGT' ? (vorgang.erledigtAm ?? new Date()) : null,
|
||||
...sealen('Vorgang', { beschreibung: eingabe.beschreibung }),
|
||||
}
|
||||
|
||||
const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vorgang',
|
||||
entitaetId: vorgang.id,
|
||||
bezeichnung: geaendert.titel,
|
||||
betreuteId: vorgang.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: {
|
||||
...aenderungErmitteln(vorgang, geaendert),
|
||||
...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null)
|
||||
? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
return { erfolg: 'Der Vorgang ist gespeichert.' }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Statuswechsel
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function vorgangStatusWechseln(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const gelesen = VorgangStatusSchema.safeParse({
|
||||
vorgangId: formular.get('vorgangId'),
|
||||
status: formular.get('status'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const geladen = await vorgangMitZugang(gelesen.data.vorgangId)
|
||||
if (!geladen) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||
|
||||
const { vorgang, kontext } = geladen
|
||||
const neu = gelesen.data.status
|
||||
if (neu === vorgang.status) return {}
|
||||
|
||||
const geaendert = await db.vorgang.update({
|
||||
where: { id: vorgang.id },
|
||||
data: {
|
||||
status: neu,
|
||||
erledigtAm: neu === 'ERLEDIGT' ? (vorgang.erledigtAm ?? new Date()) : null,
|
||||
// Wer auf "wartet auf andere" stellt, hat gerade etwas rausgeschickt.
|
||||
// Das Datum ist die Grundlage der Sachstandsanfrage.
|
||||
letzterKontaktAm:
|
||||
neu === 'WARTET_AUF_DRITTE' && !vorgang.letzterKontaktAm
|
||||
? alsTag(new Date())
|
||||
: vorgang.letzterKontaktAm,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vorgang',
|
||||
entitaetId: vorgang.id,
|
||||
bezeichnung: geaendert.titel,
|
||||
betreuteId: vorgang.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: { status: { alt: vorgang.status, neu } },
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
return { erfolg: 'Status geändert.' }
|
||||
}
|
||||
|
||||
/** Merkt den heutigen Tag als letzten Kontakt - nach der Sachstandsanfrage. */
|
||||
export async function kontaktVermerken(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const geladen = await vorgangMitZugang(String(formular.get('vorgangId') ?? ''))
|
||||
if (!geladen) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||
|
||||
const { vorgang, kontext } = geladen
|
||||
const heute = alsTag(new Date())
|
||||
|
||||
await db.vorgang.update({ where: { id: vorgang.id }, data: { letzterKontaktAm: heute } })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Vorgang',
|
||||
entitaetId: vorgang.id,
|
||||
bezeichnung: vorgang.titel,
|
||||
betreuteId: vorgang.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: { letzterKontaktAm: { alt: vorgang.letzterKontaktAm, neu: heute } },
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
return { erfolg: 'Als letzter Kontakt vermerkt.' }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Aufgaben
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function aufgabeAnlegen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const gelesen = AufgabeSchema.safeParse(formularLesen(formular))
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const geladen = await vorgangMitZugang(gelesen.data.vorgangId)
|
||||
if (!geladen) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||
|
||||
const { vorgang, kontext } = geladen
|
||||
|
||||
const letzte = await db.aufgabe.findFirst({
|
||||
where: { vorgangId: vorgang.id },
|
||||
orderBy: { reihenfolge: 'desc' },
|
||||
select: { reihenfolge: true },
|
||||
})
|
||||
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: {
|
||||
vorgangId: vorgang.id,
|
||||
titel: gelesen.data.titel,
|
||||
beschreibung: gelesen.data.beschreibung,
|
||||
faelligAm: gelesen.data.faelligAm,
|
||||
reihenfolge: (letzte?.reihenfolge ?? -1) + 1,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Aufgabe',
|
||||
entitaetId: aufgabe.id,
|
||||
bezeichnung: aufgabe.titel,
|
||||
betreuteId: vorgang.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
return { erfolg: 'Aufgabe hinzugefügt.' }
|
||||
}
|
||||
|
||||
export async function aufgabeAbhaken(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const gelesen = AufgabeAbhakenSchema.safeParse({
|
||||
aufgabeId: formular.get('aufgabeId'),
|
||||
erledigt: formular.get('erledigt') === 'true' || formular.get('erledigt') === 'on',
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusFehler(gelesen.error) }
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.findUnique({
|
||||
where: { id: gelesen.data.aufgabeId },
|
||||
include: { vorgang: true },
|
||||
})
|
||||
if (!aufgabe || aufgabe.vorgang.geloeschtAm) {
|
||||
return { fehler: 'Diese Aufgabe gibt es nicht (mehr).' }
|
||||
}
|
||||
|
||||
const kontext = await erfordereZugang(aufgabe.vorgang.betreuteId, 'VORGAENGE', 'schreiben')
|
||||
|
||||
const erledigt = gelesen.data.erledigt
|
||||
await db.aufgabe.update({
|
||||
where: { id: aufgabe.id },
|
||||
data: { erledigt, erledigtAm: erledigt ? new Date() : null },
|
||||
})
|
||||
|
||||
// Der naechste offene Handgriff steuert das Dashboard - er wird beim
|
||||
// Abhaken mitgezogen, sonst zeigt die Uebersicht Erledigtes an.
|
||||
const naechste = await db.aufgabe.findFirst({
|
||||
where: { vorgangId: aufgabe.vorgangId, erledigt: false },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }],
|
||||
select: { titel: true },
|
||||
})
|
||||
await db.vorgang.update({
|
||||
where: { id: aufgabe.vorgangId },
|
||||
data: { naechsterSchritt: naechste?.titel ?? null },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Aufgabe',
|
||||
entitaetId: aufgabe.id,
|
||||
bezeichnung: aufgabe.titel,
|
||||
betreuteId: aufgabe.vorgang.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
aenderung: { erledigt: { alt: aufgabe.erledigt, neu: erledigt } },
|
||||
})
|
||||
|
||||
pfadeAuffrischen(aufgabe.vorgangId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Papierkorb
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function vorgangInPapierkorb(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
): Promise<Formularzustand> {
|
||||
const geladen = await vorgangMitZugang(String(formular.get('vorgangId') ?? ''))
|
||||
if (!geladen) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||
|
||||
const { vorgang, kontext } = geladen
|
||||
|
||||
// Loeschen heisst hier: geloeschtAm setzen. Der Datensatz bleibt 30 Tage
|
||||
// im Papierkorb - eine Fallakte wirft man nicht versehentlich weg.
|
||||
await db.vorgang.update({ where: { id: vorgang.id }, data: { geloeschtAm: new Date() } })
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Vorgang',
|
||||
entitaetId: vorgang.id,
|
||||
bezeichnung: vorgang.titel,
|
||||
betreuteId: vorgang.betreuteId,
|
||||
userId: kontext.benutzer.id,
|
||||
userEmail: kontext.benutzer.email,
|
||||
userName: kontext.benutzer.name,
|
||||
})
|
||||
|
||||
pfadeAuffrischen(vorgang.id)
|
||||
redirect('/vorgaenge')
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { erfordereBenutzer, erfordereFrischeAnmeldung } from '@/lib/auth/sitzung'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
import {
|
||||
totpPruefen,
|
||||
totpQrCode,
|
||||
totpSecretEntschluesseln,
|
||||
totpSecretErzeugen,
|
||||
totpSecretVerschluesseln,
|
||||
} from '@/lib/crypto/totp'
|
||||
import {
|
||||
wiederherstellungscodeHashen,
|
||||
wiederherstellungscodesErzeugen,
|
||||
} from '@/lib/crypto/hash'
|
||||
|
||||
/**
|
||||
* Einrichtung des zweiten Faktors.
|
||||
*
|
||||
* Ablauf:
|
||||
* 1. vorbereiten() - erzeugt ein Geheimnis und legt es verschluesselt ab,
|
||||
* aber noch OHNE totpAktiviertAm. Bis zur Bestaetigung
|
||||
* wird es beim Anmelden nicht verlangt.
|
||||
* 2. bestaetigen() - prueft einen echten Code aus der App. Erst dann wird
|
||||
* der Faktor scharf geschaltet und es gibt die
|
||||
* Wiederherstellungscodes.
|
||||
*
|
||||
* Wichtig ist Schritt 2: Wer sich beim Abtippen vertut oder die falsche App
|
||||
* benutzt, wuerde sich sonst dauerhaft aussperren.
|
||||
*/
|
||||
|
||||
export type EinrichtungsDaten = {
|
||||
qrCode: string
|
||||
secret: string
|
||||
/** Wird nur angezeigt, wenn die Einrichtung noch nicht bestaetigt ist. */
|
||||
bereitsAktiv: boolean
|
||||
}
|
||||
|
||||
export async function zweitenFaktorVorbereiten(): Promise<EinrichtungsDaten> {
|
||||
const benutzer = await erfordereBenutzer()
|
||||
|
||||
if (benutzer.totpAktiviertAm && benutzer.totpSecretVerschluesselt) {
|
||||
// Schon eingerichtet - kein neues Geheimnis, sonst waere die alte App
|
||||
// still und leise ungueltig.
|
||||
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
|
||||
return { qrCode: '', secret, bereitsAktiv: true }
|
||||
}
|
||||
|
||||
// Ein noch nicht bestaetigtes Geheimnis darf gefahrlos ersetzt werden.
|
||||
const secret = totpSecretErzeugen()
|
||||
await db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { totpSecretVerschluesselt: totpSecretVerschluesseln(secret) },
|
||||
})
|
||||
|
||||
return {
|
||||
qrCode: await totpQrCode(secret, benutzer.email),
|
||||
secret,
|
||||
bereitsAktiv: false,
|
||||
}
|
||||
}
|
||||
|
||||
export type BestaetigungsZustand = {
|
||||
fehler?: string
|
||||
/** Wird genau einmal angezeigt und danach nur noch als Hash gespeichert. */
|
||||
wiederherstellungscodes?: string[]
|
||||
}
|
||||
|
||||
const CodeSchema = z
|
||||
.string()
|
||||
.transform((wert) => wert.replace(/\D/g, ''))
|
||||
.refine((wert) => wert.length === 6, 'Bitte die sechs Ziffern aus der App eingeben.')
|
||||
|
||||
export async function zweitenFaktorBestaetigen(
|
||||
_vorher: BestaetigungsZustand,
|
||||
formular: FormData,
|
||||
): Promise<BestaetigungsZustand> {
|
||||
const benutzer = await erfordereBenutzer()
|
||||
|
||||
if (benutzer.totpAktiviertAm) {
|
||||
return { fehler: 'Der zweite Faktor ist bereits eingerichtet.' }
|
||||
}
|
||||
if (!benutzer.totpSecretVerschluesselt) {
|
||||
return { fehler: 'Es liegt kein Geheimnis vor. Bitte lade die Seite neu.' }
|
||||
}
|
||||
|
||||
const gelesen = CodeSchema.safeParse(String(formular.get('code') ?? ''))
|
||||
if (!gelesen.success) {
|
||||
return { fehler: gelesen.error.issues[0]?.message ?? 'Der Code stimmt nicht.' }
|
||||
}
|
||||
|
||||
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
|
||||
const { gueltig } = totpPruefen(secret, benutzer.email, gelesen.data)
|
||||
|
||||
if (!gueltig) {
|
||||
return {
|
||||
fehler:
|
||||
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts automatisch gestellt wird — ' +
|
||||
'TOTP-Codes hängen an der Uhr. Warte notfalls auf den nächsten Code und versuche es erneut.',
|
||||
}
|
||||
}
|
||||
|
||||
const codes = wiederherstellungscodesErzeugen(8)
|
||||
const hashes = await Promise.all(codes.map(wiederherstellungscodeHashen))
|
||||
|
||||
await db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { totpAktiviertAm: new Date(), wiederherstellungscodes: hashes },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ZWEITER_FAKTOR_EINGERICHTET',
|
||||
entitaet: 'User',
|
||||
entitaetId: benutzer.id,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
return { wiederherstellungscodes: codes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Neue Wiederherstellungscodes erzeugen. Sensible Aktion - erfordert eine
|
||||
* frische Anmeldung, weil ein fremder Zugriff sich damit sonst einen
|
||||
* dauerhaften Zweitschluessel ausstellen koennte.
|
||||
*/
|
||||
export async function wiederherstellungscodesNeu(): Promise<string[]> {
|
||||
const benutzer = await erfordereBenutzer()
|
||||
await erfordereFrischeAnmeldung(benutzer, '/konto/zwei-faktor')
|
||||
|
||||
const codes = wiederherstellungscodesErzeugen(8)
|
||||
const hashes = await Promise.all(codes.map(wiederherstellungscodeHashen))
|
||||
|
||||
await db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { wiederherstellungscodes: hashes },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ZWEITER_FAKTOR_EINGERICHTET',
|
||||
entitaet: 'User',
|
||||
entitaetId: benutzer.id,
|
||||
bezeichnung: 'Neue Wiederherstellungscodes erzeugt',
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
|
||||
return codes
|
||||
}
|
||||
Reference in New Issue
Block a user