Eigene Vorlagen fuer Anschreiben

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
This commit is contained in:
Florian Wilde
2026-08-23 13:41:51 +02:00
parent 0a8878d8d2
commit f8d7752c27
21 changed files with 1443 additions and 13 deletions
+8
View File
@@ -105,6 +105,14 @@ function eintragsarten(): Record<string, Eintragsart> {
),
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',
+187
View File
@@ -0,0 +1,187 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { protokollieren, aenderungErmitteln } from '@/lib/audit'
import { bestellungsabsatzFehlt } from '@/lib/schreiben/platzhalter'
/**
* Server Actions fuer eigene Schreibvorlagen.
*
* Die Warnung beim fehlenden Bestellungsabsatz ist zweistufig: Der erste
* Speicherversuch ohne ihn kommt mit einer Warnung zurueck; erst die
* ausdrueckliche Bestaetigung ("ohneBestellungsabsatz") speichert. So
* laesst sich der Absatz weglassen - aber nur wissentlich. Ohne den
* Nachweis der Bestellung antwortet keine Versicherung, und das faellt
* erst nach vier Wochen auf.
*/
export type VorlagenZustand = {
fehler?: string
/** Die Bestellungsabsatz-Warnung - Speichern erst nach Bestaetigung. */
warnung?: string
erfolg?: string
felder?: Record<string, string>
}
function zugriffsfehler(ausnahme: unknown): VorlagenZustand | null {
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
return null
}
const vorlageSchema = z.object({
betreuteId: z.string().min(1),
vorlageId: z.string().trim().default(''),
name: z.string().trim().min(1, 'Bitte einen Namen vergeben.').max(120),
betreff: z.string().trim().max(200).default(''),
text: z.string().trim().min(10, 'Der Text ist zu kurz für eine Vorlage.').max(50_000),
ohneBestellungsabsatz: z.string().default(''),
})
export async function vorlageSpeichern(
_zustand: VorlagenZustand,
daten: FormData,
): Promise<VorlagenZustand> {
const gelesen = vorlageSchema.safeParse({
betreuteId: daten.get('betreuteId'),
vorlageId: daten.get('vorlageId') ?? '',
name: daten.get('name'),
betreff: daten.get('betreff') ?? '',
text: daten.get('text'),
ohneBestellungsabsatz: daten.get('ohneBestellungsabsatz') ?? '',
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe die Eingaben.',
felder: Object.fromEntries(
Object.entries(felder).map(([name, meldungen]) => [name, meldungen?.[0] ?? '']),
),
}
}
const werte = gelesen.data
const bestaetigt = werte.ohneBestellungsabsatz === 'ja'
try {
const { benutzer } = await erfordereZugang(werte.betreuteId, 'DOKUMENTE', 'schreiben')
// Die zweistufige Warnung: erst warnen, dann - bestaetigt - speichern.
if (bestellungsabsatzFehlt(werte.text) && !bestaetigt) {
return {
warnung:
'In dieser Vorlage fehlt der Bestellungsabsatz — der Nachweis, dass du zur Vertretung ' +
'befugt bist. Ohne ihn antworten Versicherungen und Behörden mit „aus ' +
'datenschutzrechtlichen Gründen keine Auskunft", und das fällt erst nach Wochen auf. ' +
'Füge {{bestellungsabsatz}} ein — oder speichere bewusst ohne.',
}
}
if (werte.vorlageId) {
const vorhanden = await db.schreibvorlage.findFirst({
where: { id: werte.vorlageId, betreuteId: werte.betreuteId, ...nichtGeloescht },
})
if (!vorhanden) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
const nachher = await db.schreibvorlage.update({
where: { id: vorhanden.id },
data: {
name: werte.name,
betreff: werte.betreff,
text: werte.text,
ohneBestellungsabsatz: bestaetigt,
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Schreibvorlage',
entitaetId: nachher.id,
bezeichnung: nachher.name,
betreuteId: werte.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: aenderungErmitteln(
{ name: vorhanden.name, betreff: vorhanden.betreff, text: vorhanden.text },
{ name: nachher.name, betreff: nachher.betreff, text: nachher.text },
),
})
} else {
const neu = await db.schreibvorlage.create({
data: {
betreuteId: werte.betreuteId,
name: werte.name,
betreff: werte.betreff,
text: werte.text,
ohneBestellungsabsatz: bestaetigt,
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Schreibvorlage',
entitaetId: neu.id,
bezeichnung: neu.name,
betreuteId: werte.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
}
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[vorlagen] Speichern fehlgeschlagen:', ausnahme)
return { fehler: 'Die Vorlage konnte nicht gespeichert werden.' }
}
revalidatePath('/einstellungen/vorlagen')
return { erfolg: `„${werte.name}“ ist gespeichert.` }
}
/** Papierkorb: geloeschtAm setzen, 30 Tage rueckholbar wie ueberall. */
export async function vorlageLoeschen(
_zustand: VorlagenZustand,
daten: FormData,
): Promise<VorlagenZustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const vorlageId = String(daten.get('vorlageId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
const vorlage = await db.schreibvorlage.findFirst({
where: { id: vorlageId, betreuteId, ...nichtGeloescht },
select: { id: true, name: true },
})
if (!vorlage) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
await db.schreibvorlage.update({
where: { id: vorlage.id },
data: { geloeschtAm: new Date() },
})
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'Schreibvorlage',
entitaetId: vorlage.id,
bezeichnung: vorlage.name,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[vorlagen] Löschen fehlgeschlagen:', ausnahme)
return { fehler: 'Die Vorlage konnte nicht gelöscht werden.' }
}
revalidatePath('/einstellungen/vorlagen')
return { erfolg: 'In den Papierkorb gelegt — 30 Tage rückholbar.' }
}