Absenderanschrift in jedem Schreiben

Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im
Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei
einer Versicherung oder Behoerde wirkt das unserioes und macht es der
Gegenstelle unnoetig schwer zu antworten.

Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen
sie im Absenderblock jedes Schreibens, und die Datumszeile lautet
"Bayreuth, den 23.08.2026".

Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie
gehoert zu mir und gilt fuer alle Akten.

Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und
datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit
einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab.

Aufbau:
- Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war
  sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar,
  DROP-INDEX-Zeilen bereits heraus.
- Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den
  Serienlauf - der hat bisher {name} gebaut und haette die Anschrift
  sonst still verloren.
- Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift,
  betreuer_ort.

Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur
ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der
weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse
sieht nach Fehler aus, ein blosser Name nach Absicht.

Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder
Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet; vier End-to-End-Tests vom leeren
Zustand ueber das Speichern bis zum fertigen Brief.

Refs #21
This commit is contained in:
Florian Wilde
2026-08-23 20:29:17 +02:00
parent 5022a97a6f
commit 848aaf1b9f
17 changed files with 493 additions and 31 deletions
@@ -0,0 +1,89 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { anschriftSpeichern, type AnschriftZustand } from '@/server/actions/konto'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Anschrift speichern'}
</Button>
)
}
export function AnschriftFormular({
strasse,
plz,
ort,
}: {
strasse: string | null
plz: string | null
ort: string | null
}) {
const [zustand, aktion] = useActionState<AnschriftZustand, FormData>(anschriftSpeichern, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input
id="strasse"
name="strasse"
defaultValue={strasse ?? ''}
maxLength={150}
autoComplete="street-address"
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input
id="plz"
name="plz"
defaultValue={plz ?? ''}
maxLength={10}
autoComplete="postal-code"
inputMode="numeric"
/>
</Feld>
<Feld
id="ort"
beschriftung="Ort"
erklaerung="Steht außerdem in der Datumszeile: „Bayreuth, den 23.08.2026“."
fehler={zustand.felder?.ort}
>
<Input
id="ort"
name="ort"
defaultValue={ort ?? ''}
maxLength={100}
autoComplete="address-level2"
/>
</Feld>
</div>
<Absenden />
</form>
)
}
+27 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Fingerprint, KeyRound, ShieldCheck } from 'lucide-react'
import { Fingerprint, KeyRound, MapPin, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -11,6 +11,7 @@ import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular'
import { AnschriftFormular } from './anschrift-formular'
export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic'
@@ -22,7 +23,10 @@ export default async function KontoSeite() {
return (
<div className="max-w-2xl space-y-6">
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten, zweiter Faktor und Passkeys." />
<SeitenKopf
titel="Mein Konto"
beschreibung="Zugangsdaten, eigene Anschrift, zweiter Faktor und Passkeys."
/>
<Card>
<CardHeader className="pb-3">
@@ -55,6 +59,27 @@ export default async function KontoSeite() {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-5" aria-hidden="true" />
Meine Anschrift
</CardTitle>
<CardDescription>
Steht im Absenderblock jedes Schreibens, das hier entsteht, und liefert den Ort für
die Datumszeile. Ohne sie geht ein förmlicher Brief ohne Rücksendeadresse hinaus.
Jede Angabe ist einzeln freiwillig — was fehlt, bleibt im Brief einfach weg.
</CardDescription>
</CardHeader>
<CardContent>
<AnschriftFormular
strasse={benutzer.strasse}
plz={benutzer.plz}
ort={benutzer.ort}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function SchreibenSeite({
try {
entwurf = await entwurfBauen({
betreuteId: aktive.id,
benutzerName: benutzer.name,
benutzer,
art,
vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null,
+10 -4
View File
@@ -1,7 +1,7 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import {
absenderBlock,
anschriftBlock,
@@ -107,7 +107,8 @@ export function betreffAusText(text: string): string {
export type EntwurfEingabe = {
betreuteId: string
benutzerName: string
/** Wer schreibt - mit Anschrift, wenn hinterlegt. */
benutzer: Absender
art: SchreibenArt
vertragId?: string | null
vorgangId?: string | null
@@ -127,7 +128,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute),
benutzer: { name: eingabe.benutzerName },
benutzer: eingabe.benutzer,
})
const gemeinsam = {
@@ -229,7 +230,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
geburtsdatum: betreute.geburtsdatum,
},
betreuer: {
name: eingabe.benutzerName,
name: eingabe.benutzer.name,
aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht,
},
@@ -278,6 +279,11 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
: null,
betreute_anschrift: kopf.betreuteAnschrift,
betreuer_name: kopf.betreuerName,
betreuer_anschrift:
[kopf.betreuerStrasse, [kopf.betreuerPlz, kopf.betreuerOrt].filter(Boolean).join(' ')]
.filter((zeile) => zeile && zeile.trim() !== '')
.join('\n') || null,
betreuer_ort: kopf.betreuerOrt,
betreuungsgericht: kopf.betreuungsgericht,
aktenzeichen_gericht: kopf.aktenzeichenGericht,
bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null,
+8
View File
@@ -21,6 +21,8 @@ export type PlatzhalterName =
| 'betreute_geburtsdatum'
| 'betreute_anschrift'
| 'betreuer_name'
| 'betreuer_anschrift'
| 'betreuer_ort'
| 'betreuungsgericht'
| 'aktenzeichen_gericht'
| 'bestellung_datum'
@@ -49,6 +51,12 @@ export const PLATZHALTER: readonly PlatzhalterBeschreibung[] = [
{ name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' },
{ name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' },
{ name: 'betreuer_name', beschriftung: 'Dein Name', quelle: 'Dein Zugang' },
{
name: 'betreuer_anschrift',
beschriftung: 'Deine Anschrift',
quelle: 'Mein Konto',
},
{ name: 'betreuer_ort', beschriftung: 'Dein Ort', quelle: 'Mein Konto' },
{ name: 'betreuungsgericht', beschriftung: 'Betreuungsgericht', quelle: 'Stammdaten' },
{ name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' },
{ name: 'bestellung_datum', beschriftung: 'Datum der Bestellung', quelle: 'Stammdaten' },
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.9.0'
export const VERSION = '1.10.0'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+19 -10
View File
@@ -3,11 +3,20 @@ import type { Briefkopf } from '@/lib/vertraege/anschreiben'
/**
* Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
*
* Die Anschrift des Betreuers steht im Datenmodell nicht - der Benutzer
* hat nur Name und E-Mail. Sie bleibt deshalb leer; im erzeugten Text
* fehlen dann schlicht die beiden Zeilen, und wer den Entwurf kopiert,
* ergaenzt sie im Briefkopf seines Textverarbeitungsprogramms ohnehin.
* Die Anschrift des Betreuers haengt am Benutzer, nicht an der betreuten
* Person: Sie gehoert zu mir und gilt fuer alle Akten. Ist sie nicht
* hinterlegt, bleiben die Zeilen weg - ein Absenderblock mit leeren
* Zeilen sieht schlimmer aus als einer, der nur aus dem Namen besteht.
*/
/** Wer schreibt - Name und, wenn hinterlegt, die eigene Anschrift. */
export type Absender = {
name: string
strasse?: string | null
plz?: string | null
ort?: string | null
}
export type BriefkopfQuelle = {
betreute: {
vorname: string
@@ -22,7 +31,7 @@ export type BriefkopfQuelle = {
aktenzeichenGericht: string | null
bestellungAm: Date | null
}
benutzer: { name: string }
benutzer: Absender
}
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
@@ -37,11 +46,11 @@ export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
return {
betreuerName: quelle.benutzer.name,
betreuerStrasse: null,
betreuerPlz: null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der des
// Heims - er steht nirgends im Datenmodell und bleibt deshalb leer.
betreuerOrt: null,
betreuerStrasse: quelle.benutzer.strasse ?? null,
betreuerPlz: quelle.benutzer.plz ?? null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der
// des Heims.
betreuerOrt: quelle.benutzer.ort ?? null,
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
betreuteGeburtsdatum: b.geburtsdatum,
betreuteAnschrift: anschrift || null,
+3 -3
View File
@@ -4,7 +4,7 @@ import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import {
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
rueckkaufswertAnschreiben,
@@ -53,7 +53,7 @@ export async function serienlaufDurchfuehren(eingabe: {
betreuteId: string
vertragIds: string[]
stichtag: Date
benutzer: { id: string; name: string; email: string }
benutzer: { id: string; name: string; email: string } & Absender
}): Promise<SerienErgebnis> {
const heute = heuteAlsTag()
const { betreuteId, benutzer } = eingabe
@@ -65,7 +65,7 @@ export async function serienlaufDurchfuehren(eingabe: {
const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute),
benutzer: { name: benutzer.name },
benutzer,
})
// Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere.
+80
View File
@@ -1,5 +1,6 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
@@ -102,3 +103,82 @@ export async function passwortAendern(
await aufMindestdauerWarten(begonnen)
return { erfolg: true }
}
/**
* Die eigene Anschrift.
*
* Sie steht im Absenderblock jedes erzeugten Schreibens und liefert den
* Ort fuer die Datumszeile. Angaben sind einzeln freiwillig: Wer nur den
* Ort hinterlegt, bekommt "Bayreuth, den 23.08.2026" und keinen
* Absenderblock mit leeren Zeilen.
*/
const AnschriftSchema = z.object({
strasse: z.string().trim().max(150, 'Bitte höchstens 150 Zeichen.'),
plz: z.string().trim().max(10, 'Bitte höchstens 10 Zeichen.'),
ort: z.string().trim().max(100, 'Bitte höchstens 100 Zeichen.'),
})
export type AnschriftZustand = {
fehler?: string
felder?: Partial<Record<'strasse' | 'plz' | 'ort', string>>
erfolg?: string
}
export async function anschriftSpeichern(
_vorher: AnschriftZustand,
formular: FormData,
): Promise<AnschriftZustand> {
const benutzer = await erfordereBenutzer()
const gelesen = AnschriftSchema.safeParse({
strasse: formular.get('strasse') ?? '',
plz: formular.get('plz') ?? '',
ort: formular.get('ort') ?? '',
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe die Eingaben.',
felder: {
strasse: felder.strasse?.[0],
plz: felder.plz?.[0],
ort: felder.ort?.[0],
},
}
}
// Leer heisst "nicht hinterlegt", nicht "leere Zeichenkette" - sonst
// stuende im Brief eine Zeile aus Leerzeichen.
const leerZuNull = (wert: string) => (wert === '' ? null : wert)
await db.user.update({
where: { id: benutzer.id },
data: {
strasse: leerZuNull(gelesen.data.strasse),
plz: leerZuNull(gelesen.data.plz),
ort: leerZuNull(gelesen.data.ort),
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: benutzer.id,
bezeichnung: benutzer.name,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
anschrift: {
alt: [benutzer.strasse, benutzer.plz, benutzer.ort].filter(Boolean).join(', ') || null,
neu:
[gelesen.data.strasse, gelesen.data.plz, gelesen.data.ort].filter(Boolean).join(', ') ||
null,
},
},
})
revalidatePath('/konto')
return { erfolg: 'Deine Anschrift ist gespeichert.' }
}