From 848aaf1b9fb74b9629fa10379b434c95f6143f2f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 23 Aug 2026 20:29:17 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 45 ++++++++- docs/INSTALLATION.md | 14 +-- docs/ROADMAP.md | 2 +- e2e/absenderanschrift.spec.ts | 97 +++++++++++++++++++ package.json | 2 +- .../migration.sql | 14 +++ prisma/schema.prisma | 7 ++ src/app/(app)/konto/anschrift-formular.tsx | 89 +++++++++++++++++ src/app/(app)/konto/page.tsx | 29 +++++- src/app/(app)/schreiben/neu/page.tsx | 2 +- src/lib/schreiben/entwurf.ts | 14 ++- src/lib/schreiben/platzhalter.ts | 8 ++ src/lib/version.ts | 2 +- src/lib/vertraege/briefkopf.ts | 29 ++++-- src/lib/vertraege/serienlauf.ts | 6 +- src/server/actions/konto.ts | 80 +++++++++++++++ tests/briefkopf.test.ts | 84 ++++++++++++++++ 17 files changed, 493 insertions(+), 31 deletions(-) create mode 100644 e2e/absenderanschrift.spec.ts create mode 100644 prisma/migrations/20260823193832_betreuer_anschrift/migration.sql create mode 100644 src/app/(app)/konto/anschrift-formular.tsx create mode 100644 tests/briefkopf.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef1b0a..b978127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,48 @@ Wiederherstellung*. _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 ### 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.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 diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index b0a829c..1ed41c3 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit # Auf die letzte veröffentlichte Fassung wechseln, # nicht auf den laufenden Entwicklungsstand -git checkout v1.9.0 +git checkout v1.10.0 ``` ### Kontrolle ```bash 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 # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) 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: @@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash 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 @@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Protokoll** siehst du deine eigene Anmeldung. -- Unten links in der Seitenleiste steht `Version 1.9.0 · `. +- Unten links in der Seitenleiste steht `Version 1.10.0 · `. --- @@ -875,7 +875,7 @@ alles so vor wie zuvor. | Aufruf | Wirkung | |---|---| | `./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 --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | @@ -913,7 +913,7 @@ git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten -git checkout v1.9.0 +git checkout v1.10.0 # 4. Neu bauen und starten GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2bcc169..b070f36 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 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)) --- diff --git a/e2e/absenderanschrift.spec.ts b/e2e/absenderanschrift.spec.ts new file mode 100644 index 0000000..28b4d00 --- /dev/null +++ b/e2e/absenderanschrift.spec.ts @@ -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(arbeit: (db: PrismaClient) => Promise): Promise { + 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 { + 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 ') + }) +}) diff --git a/package.json b/package.json index 0f1b1a5..4665a02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.9.0", + "version": "1.10.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/prisma/migrations/20260823193832_betreuer_anschrift/migration.sql b/prisma/migrations/20260823193832_betreuer_anschrift/migration.sql new file mode 100644 index 0000000..c1ed605 --- /dev/null +++ b/prisma/migrations/20260823193832_betreuer_anschrift/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 79cb161..d8dfb5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,13 @@ model User { // (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung). 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 theme String @default("system") // "system" | "hell" | "dunkel" diff --git a/src/app/(app)/konto/anschrift-formular.tsx b/src/app/(app)/konto/anschrift-formular.tsx new file mode 100644 index 0000000..d852a92 --- /dev/null +++ b/src/app/(app)/konto/anschrift-formular.tsx @@ -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 ( + + ) +} + +export function AnschriftFormular({ + strasse, + plz, + ort, +}: { + strasse: string | null + plz: string | null + ort: string | null +}) { + const [zustand, aktion] = useActionState(anschriftSpeichern, {}) + + return ( +
+ {zustand.fehler && ( + + + )} + {zustand.erfolg && ( + + + )} + + + + + +
+ + + + + + + +
+ + + + ) +} diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx index 70bac3d..6267598 100644 --- a/src/app/(app)/konto/page.tsx +++ b/src/app/(app)/konto/page.tsx @@ -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 (
- + @@ -55,6 +59,27 @@ export default async function KontoSeite() { + + + + + + 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. + + + + + + + diff --git a/src/app/(app)/schreiben/neu/page.tsx b/src/app/(app)/schreiben/neu/page.tsx index d42345f..01069b8 100644 --- a/src/app/(app)/schreiben/neu/page.tsx +++ b/src/app/(app)/schreiben/neu/page.tsx @@ -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, diff --git a/src/lib/schreiben/entwurf.ts b/src/lib/schreiben/entwurf.ts index 7d93309..fc496a2 100644 --- a/src/lib/schreiben/entwurf.ts +++ b/src/lib/schreiben/entwurf.ts @@ -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 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, diff --git a/src/lib/schreiben/platzhalter.ts b/src/lib/schreiben/platzhalter.ts index 6f84f82..89b2220 100644 --- a/src/lib/schreiben/platzhalter.ts +++ b/src/lib/schreiben/platzhalter.ts @@ -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' }, diff --git a/src/lib/version.ts b/src/lib/version.ts index f68a246..ef10ea8 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -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: diff --git a/src/lib/vertraege/briefkopf.ts b/src/lib/vertraege/briefkopf.ts index eec5a50..d44c60d 100644 --- a/src/lib/vertraege/briefkopf.ts +++ b/src/lib/vertraege/briefkopf.ts @@ -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, diff --git a/src/lib/vertraege/serienlauf.ts b/src/lib/vertraege/serienlauf.ts index 3d2aa67..3ed3167 100644 --- a/src/lib/vertraege/serienlauf.ts +++ b/src/lib/vertraege/serienlauf.ts @@ -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 { 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. diff --git a/src/server/actions/konto.ts b/src/server/actions/konto.ts index ed1dec5..e41629e 100644 --- a/src/server/actions/konto.ts +++ b/src/server/actions/konto.ts @@ -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> + erfolg?: string +} + +export async function anschriftSpeichern( + _vorher: AnschriftZustand, + formular: FormData, +): Promise { + 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.' } +} diff --git a/tests/briefkopf.test.ts b/tests/briefkopf.test.ts new file mode 100644 index 0000000..a167f1b --- /dev/null +++ b/tests/briefkopf.test.ts @@ -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') + }) +})