Merge #32: Absenderanschrift in jedem Schreiben

Behebt Issue #21: Strasse, PLZ und Ort unter "Mein Konto", von dort in
den Absenderblock jedes Schreibens und als Ort in die Datumszeile. Was
fehlt, bleibt weg.

Closes #21
This commit was merged in pull request #32.
This commit is contained in:
2026-08-23 18:30:23 +00:00
17 changed files with 493 additions and 31 deletions
+44 -1
View File
@@ -26,6 +26,48 @@ Wiederherstellung*.
_Noch nichts._ _Noch nichts._
## [1.10.0] – 2026-08-23
### Hinzugefügt
- **Deine Anschrift steht jetzt in jedem Schreiben**
([#21](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/21)).
Bisher ging jeder Brief ohne Rücksendeadresse hinaus — im
Absenderblock stand nur dein Name, und in der Datumszeile fehlte der
Ort. Bei einer Versicherung oder Behörde wirkt das unseriös und macht
es der Gegenstelle unnötig schwer zu antworten.
Unter **Mein Konto → Meine Anschrift** einmal Straße, PLZ und Ort
hinterlegen. Ab da stehen sie im Absenderblock jedes erzeugten
Schreibens, und die Datumszeile lautet „Bayreuth, den 23.08.2026".
Betroffen ist alles: Rückkaufswert-Anfrage, Serienabfrage, Kündigung,
Sachstandsanfrage und die eigenen Vorlagen.
- **Zwei neue Platzhalter** für eigene Vorlagen: `{{betreuer_anschrift}}`
und `{{betreuer_ort}}`.
### Eine Zusage
**Was fehlt, bleibt weg.** Jede der drei Angaben ist einzeln freiwillig.
Hinterlegst du nur den Ort, bekommst du die Datumszeile mit Ort und einen
Absenderblock, der weiterhin nur aus deinem Namen besteht — keine leeren
Zeilen, kein Komma ohne Ort. Eine halb ausgefüllte Adresse sieht nach
Fehler aus, ein bloßer Name nach Absicht.
### Datenbank
Eine Migration: drei Felder am Benutzer. Die Anschrift hängt an dir und
nicht an der betreuten Person — sie gilt für alle Akten.
### Geprüft
- Sieben Einheitstests für den Absenderblock, darunter jede
Teilbefüllung und die Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet.
- Vier End-to-End-Tests: der Brief ohne hinterlegte Anschrift, das
Speichern unter *Mein Konto*, der Brief danach und die Gegenprobe am
Rückkaufswert-Schreiben.
## [1.9.0] – 2026-08-23 ## [1.9.0] – 2026-08-23
### Hinzugefügt ### Hinzugefügt
@@ -1036,7 +1078,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
--- ---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...HEAD
[1.10.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...v1.10.0
[1.9.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...v1.9.0 [1.9.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...v1.9.0
[1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0 [1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0
[1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0 [1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0
+7 -7
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln, # Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand # nicht auf den laufenden Entwicklungsstand
git checkout v1.9.0 git checkout v1.10.0
``` ```
### Kontrolle ### Kontrolle
```bash ```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.9.0 git describe --tags # v1.10.0
``` ```
--- ---
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash ```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.9.0","commit":"..."} # {"status":"bereit","version":"1.10.0","commit":"..."}
``` ```
Kommt hier nichts, sieh in die Logs: Kommt hier nichts, sieh in die Logs:
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash ```bash
curl -s http://10.0.0.5:3000/api/gesundheit curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.9.0",...} # {"status":"bereit","version":"1.10.0",...}
``` ```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung. - Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.9.0 · <commit>`. - Unten links in der Seitenleiste steht `Version 1.10.0 · <commit>`.
--- ---
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung | | Aufruf | Wirkung |
|---|---| |---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.9.0` | auf eine bestimmte Fassung | | `./scripts/update.sh v1.10.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -913,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main git log --oneline HEAD..origin/main
# 3. Umschalten # 3. Umschalten
git checkout v1.9.0 git checkout v1.10.0
# 4. Neu bauen und starten # 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+1 -1
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben. als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.9.0** ([CHANGELOG](../CHANGELOG.md)) Stand: 23.08.2026 · Aktuelle Fassung: **1.10.0** ([CHANGELOG](../CHANGELOG.md))
--- ---
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Die eigene Anschrift im Schreiben — gegen die echte Anwendung.
*
* Der Weg, um den es geht: einmal unter „Mein Konto" hinterlegen, und
* ab da steht sie im Absenderblock und der Ort in der Datumszeile —
* ohne dass man beim Schreiben daran denken muss.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
/** Zurück auf „nichts hinterlegt", damit der Fall auch prüfbar bleibt. */
async function anschriftLeeren(): Promise<void> {
await mitDb(async (db) => {
await db.user.updateMany({ data: { strasse: null, plz: null, ort: null } })
})
}
test.describe('Absenderanschrift', () => {
test.beforeAll(async () => {
await anschriftLeeren()
})
test.afterAll(async () => {
await anschriftLeeren()
})
test('ohne hinterlegte Anschrift steht nur der Name im Brief', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
// Erste Zeile ist der Absender, danach kommt gleich die Leerzeile.
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('')
// Kein Ort in der Datumszeile, also auch kein Komma davor.
expect(text).not.toContain(', den ')
})
test('speichert die Anschrift unter Mein Konto', async ({ page }) => {
await anmelden(page)
await page.goto('/konto')
await page.locator('input#strasse').fill('Cottenbacher Str. 23')
await page.locator('input#plz').fill('95445')
await page.locator('input#ort').fill('Bayreuth')
await page.getByRole('button', { name: 'Anschrift speichern' }).click()
await expect(page.getByText('Deine Anschrift ist gespeichert.')).toBeVisible()
await page.reload()
await expect(page.locator('input#strasse')).toHaveValue('Cottenbacher Str. 23')
})
test('trägt sie in Absenderblock und Datumszeile des Schreibens', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('Cottenbacher Str. 23')
expect(zeilen[2]).toBe('95445 Bayreuth')
expect(text).toContain('Bayreuth, den ')
})
test('steht auch im Rückkaufswert-Schreiben zum Vertrag', async ({ page }) => {
const vertragId = await mitDb(async (db) => {
const vertrag = await db.vertrag.findFirst({
where: { art: 'LEBENSVERSICHERUNG', geloeschtAm: null },
select: { id: true },
})
return vertrag?.id ?? ''
})
test.skip(vertragId === '', 'Kein Lebensversicherungsvertrag im Bestand.')
await anmelden(page)
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Cottenbacher Str. 23')
expect(text).toContain('Bayreuth, den ')
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.9.0", "version": "1.10.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
@@ -0,0 +1,14 @@
-- betreuer_anschrift
--
-- Erzeugt mit "npm run db:migration". Entfernt wurden die
-- DROP-INDEX-Anweisungen zu diesen Indizes:
-- dokument_absender_trgm
-- vorgang_titel_trgm
--
-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt;
-- ohne sie waere die Volltextsuche still langsam geworden.
-- AlterTable
ALTER TABLE "benutzer" ADD COLUMN "ort" TEXT,
ADD COLUMN "plz" TEXT,
ADD COLUMN "strasse" TEXT;
+7
View File
@@ -81,6 +81,13 @@ model User {
// (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung). // (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung).
sitzungsGeneration Int @default(0) sitzungsGeneration Int @default(0)
/// Die eigene Anschrift - Absenderblock und Ort in der Datumszeile
/// jedes erzeugten Schreibens. Sie gehoert zum Betreuer und nicht zur
/// betreuten Person: Sie gilt fuer alle Akten.
strasse String?
plz String?
ort String?
// Oberflaechen-Einstellungen // Oberflaechen-Einstellungen
theme String @default("system") // "system" | "hell" | "dunkel" theme String @default("system") // "system" | "hell" | "dunkel"
@@ -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 type { Metadata } from 'next'
import Link from 'next/link' 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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 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 { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils' import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular' import { PasswortFormular } from './passwort-formular'
import { AnschriftFormular } from './anschrift-formular'
export const metadata: Metadata = { title: 'Mein Konto' } export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -22,7 +23,10 @@ export default async function KontoSeite() {
return ( return (
<div className="max-w-2xl space-y-6"> <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> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -55,6 +59,27 @@ export default async function KontoSeite() {
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function SchreibenSeite({
try { try {
entwurf = await entwurfBauen({ entwurf = await entwurfBauen({
betreuteId: aktive.id, betreuteId: aktive.id,
benutzerName: benutzer.name, benutzer,
art, art,
vertragId: text('vertragId') || null, vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null, vorgangId: text('vorgangId') || null,
+10 -4
View File
@@ -1,7 +1,7 @@
import 'server-only' import 'server-only'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import { import {
absenderBlock, absenderBlock,
anschriftBlock, anschriftBlock,
@@ -107,7 +107,8 @@ export function betreffAusText(text: string): string {
export type EntwurfEingabe = { export type EntwurfEingabe = {
betreuteId: string betreuteId: string
benutzerName: string /** Wer schreibt - mit Anschrift, wenn hinterlegt. */
benutzer: Absender
art: SchreibenArt art: SchreibenArt
vertragId?: string | null vertragId?: string | null
vorgangId?: string | null vorgangId?: string | null
@@ -127,7 +128,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const kopf = briefkopfBauen({ const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute), betreute: oeffnen('Betreute', betreute),
benutzer: { name: eingabe.benutzerName }, benutzer: eingabe.benutzer,
}) })
const gemeinsam = { const gemeinsam = {
@@ -229,7 +230,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
geburtsdatum: betreute.geburtsdatum, geburtsdatum: betreute.geburtsdatum,
}, },
betreuer: { betreuer: {
name: eingabe.benutzerName, name: eingabe.benutzer.name,
aktenzeichenGericht: betreute.aktenzeichenGericht, aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht, betreuungsgericht: betreute.betreuungsgericht,
}, },
@@ -278,6 +279,11 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
: null, : null,
betreute_anschrift: kopf.betreuteAnschrift, betreute_anschrift: kopf.betreuteAnschrift,
betreuer_name: kopf.betreuerName, 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, betreuungsgericht: kopf.betreuungsgericht,
aktenzeichen_gericht: kopf.aktenzeichenGericht, aktenzeichen_gericht: kopf.aktenzeichenGericht,
bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null, bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null,
+8
View File
@@ -21,6 +21,8 @@ export type PlatzhalterName =
| 'betreute_geburtsdatum' | 'betreute_geburtsdatum'
| 'betreute_anschrift' | 'betreute_anschrift'
| 'betreuer_name' | 'betreuer_name'
| 'betreuer_anschrift'
| 'betreuer_ort'
| 'betreuungsgericht' | 'betreuungsgericht'
| 'aktenzeichen_gericht' | 'aktenzeichen_gericht'
| 'bestellung_datum' | 'bestellung_datum'
@@ -49,6 +51,12 @@ export const PLATZHALTER: readonly PlatzhalterBeschreibung[] = [
{ name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' }, { name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' },
{ name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' }, { name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' },
{ name: 'betreuer_name', beschriftung: 'Dein Name', quelle: 'Dein Zugang' }, { 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: 'betreuungsgericht', beschriftung: 'Betreuungsgericht', quelle: 'Stammdaten' },
{ name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' }, { name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' },
{ name: 'bestellung_datum', beschriftung: 'Datum der Bestellung', 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. */ /** 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: * 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. * Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
* *
* Die Anschrift des Betreuers steht im Datenmodell nicht - der Benutzer * Die Anschrift des Betreuers haengt am Benutzer, nicht an der betreuten
* hat nur Name und E-Mail. Sie bleibt deshalb leer; im erzeugten Text * Person: Sie gehoert zu mir und gilt fuer alle Akten. Ist sie nicht
* fehlen dann schlicht die beiden Zeilen, und wer den Entwurf kopiert, * hinterlegt, bleiben die Zeilen weg - ein Absenderblock mit leeren
* ergaenzt sie im Briefkopf seines Textverarbeitungsprogramms ohnehin. * 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 = { export type BriefkopfQuelle = {
betreute: { betreute: {
vorname: string vorname: string
@@ -22,7 +31,7 @@ export type BriefkopfQuelle = {
aktenzeichenGericht: string | null aktenzeichenGericht: string | null
bestellungAm: Date | null bestellungAm: Date | null
} }
benutzer: { name: string } benutzer: Absender
} }
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf { export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
@@ -37,11 +46,11 @@ export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
return { return {
betreuerName: quelle.benutzer.name, betreuerName: quelle.benutzer.name,
betreuerStrasse: null, betreuerStrasse: quelle.benutzer.strasse ?? null,
betreuerPlz: null, betreuerPlz: quelle.benutzer.plz ?? null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der des // Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der
// Heims - er steht nirgends im Datenmodell und bleibt deshalb leer. // des Heims.
betreuerOrt: null, betreuerOrt: quelle.benutzer.ort ?? null,
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(), betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
betreuteGeburtsdatum: b.geburtsdatum, betreuteGeburtsdatum: b.geburtsdatum,
betreuteAnschrift: anschrift || null, betreuteAnschrift: anschrift || null,
+3 -3
View File
@@ -4,7 +4,7 @@ import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher' import { ablegenMitTyp } from '@/lib/speicher'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import { import {
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE, RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
rueckkaufswertAnschreiben, rueckkaufswertAnschreiben,
@@ -53,7 +53,7 @@ export async function serienlaufDurchfuehren(eingabe: {
betreuteId: string betreuteId: string
vertragIds: string[] vertragIds: string[]
stichtag: Date stichtag: Date
benutzer: { id: string; name: string; email: string } benutzer: { id: string; name: string; email: string } & Absender
}): Promise<SerienErgebnis> { }): Promise<SerienErgebnis> {
const heute = heuteAlsTag() const heute = heuteAlsTag()
const { betreuteId, benutzer } = eingabe const { betreuteId, benutzer } = eingabe
@@ -65,7 +65,7 @@ export async function serienlaufDurchfuehren(eingabe: {
const kopf = briefkopfBauen({ const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute), betreute: oeffnen('Betreute', betreute),
benutzer: { name: benutzer.name }, benutzer,
}) })
// Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere. // Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere.
+80
View File
@@ -1,5 +1,6 @@
'use server' 'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod' import { z } from 'zod'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { erfordereBenutzer } from '@/lib/auth/sitzung' import { erfordereBenutzer } from '@/lib/auth/sitzung'
@@ -102,3 +103,82 @@ export async function passwortAendern(
await aufMindestdauerWarten(begonnen) await aufMindestdauerWarten(begonnen)
return { erfolg: true } 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.' }
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { absenderBlock, datumsZeile } from '@/lib/vertraege/anschreiben'
/**
* Der Absenderblock.
*
* Ein foermliches Schreiben ohne Ruecksendeadresse wirkt unserioes und
* macht es der Gegenstelle schwer zu antworten. Umgekehrt gilt: Lieber
* nur der Name als ein Block mit leeren Zeilen - eine halb ausgefuellte
* Adresse sieht nach Fehler aus, ein blosser Name nach Absicht.
*/
const BETREUTE = {
vorname: 'Erika',
nachname: 'Musterfrau',
anrede: 'Frau',
geburtsdatum: new Date(Date.UTC(1943, 3, 17)),
heimName: 'Seniorenheim Sonnenhof',
heimStrasse: 'Heimweg 3',
heimPlz: '95445',
heimOrt: 'Bayreuth',
betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 412/25',
bestellungAm: new Date(Date.UTC(2026, 1, 14)),
}
const VOLLSTAENDIG = {
name: 'Florian Wilde',
strasse: 'Cottenbacher Str. 23',
plz: '95445',
ort: 'Bayreuth',
}
describe('Absenderblock', () => {
it('nennt Name, Straße und Ort', () => {
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: VOLLSTAENDIG })
expect(absenderBlock(kopf)).toBe('Florian Wilde\nCottenbacher Str. 23\n95445 Bayreuth')
})
it('bleibt beim Namen, wenn keine Anschrift hinterlegt ist', () => {
// Kein Block mit leeren Zeilen - das sieht nach Fehler aus.
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: { name: 'Florian Wilde' } })
expect(absenderBlock(kopf)).toBe('Florian Wilde')
})
it('lässt die Straße weg, wenn nur der Ort bekannt ist', () => {
const kopf = briefkopfBauen({
betreute: BETREUTE,
benutzer: { name: 'Florian Wilde', plz: '95445', ort: 'Bayreuth' },
})
expect(absenderBlock(kopf)).toBe('Florian Wilde\n95445 Bayreuth')
})
it('verträgt einen Ort ohne PLZ', () => {
const kopf = briefkopfBauen({
betreute: BETREUTE,
benutzer: { name: 'Florian Wilde', ort: 'Bayreuth' },
})
expect(absenderBlock(kopf)).toBe('Florian Wilde\nBayreuth')
})
it('nimmt die Anschrift vom Betreuer, nicht vom Heim', () => {
// Die Heimadresse ist die der betreuten Person und gehört in den
// Anschriftenblock, nie in den Absender.
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: VOLLSTAENDIG })
expect(absenderBlock(kopf)).not.toContain('Heimweg')
expect(kopf.betreuteAnschrift).toContain('Heimweg 3')
})
})
describe('Datumszeile', () => {
const heute = new Date(Date.UTC(2026, 7, 23))
it('stellt den Ort des Betreuers voran', () => {
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: VOLLSTAENDIG })
expect(datumsZeile(kopf.betreuerOrt, heute)).toBe('Bayreuth, den 23.08.2026')
})
it('lässt ohne Ort das Komma weg', () => {
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: { name: 'Florian Wilde' } })
expect(datumsZeile(kopf.betreuerOrt, heute)).toBe('23.08.2026')
})
})