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') + }) +})