Compare commits

..
10 Commits
Author SHA1 Message Date
florian.wilde 91592cbe88 Merge #33: Ein Jahr Kontobewegungen im Beispielfall
Behebt Issue #22: 107 Buchungen ueber zwoelf Monate im Demofall, mit
gerechnetem Kontostand. Dazu behoben: gGmbH fehlte in der Rechtsformliste
beim Zusammenfassen wiederkehrender Zahlungen.

Closes #22
2026-08-23 19:36:49 +00:00
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
Der Demofall brachte Konten und Kontostaende mit, aber keine einzige
Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.

Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster
statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als
Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und
Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer
vierteljaehrlich sowie eine Handvoll Einzelposten.

Was der Fall damit zeigt:
- Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird
  trotzdem als eine monatliche Zahlung gefuehrt.
- Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht
  zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten.
- Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der
  Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst.
  Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die
  Abweichungspruefung sichtbar wird.
- Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen
  gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst
  widerspricht, waere schlimmer als einer ohne Buchungen.

Zwei Funde beim Bauen:

Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in
den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe
Referenz. Genau dafuer ist er da.

"gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum
St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe
zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine
gGmbH ist. Ergaenzt, mit Test.

Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am
Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die
erkannten Gruppen und das Kassenbuch.

Refs #22
2026-08-23 21:35:46 +02:00
florian.wilde b6b579a04d 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
2026-08-23 18:30:23 +00:00
Florian Wilde 848aaf1b9f 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
2026-08-23 20:29:17 +02:00
florian.wilde 5022a97a6f Merge #31: Datenbankbefehle benennen, welcher Rueckfragen stellt
Behebt Issue #27: Uebersicht der sieben Datenbankbefehle in der README,
db:migrate als einziger interaktiver benannt, Ersteinrichtung auf
npm run setup vereinheitlicht.

Closes #27
2026-08-23 17:36:06 +00:00
Florian Wilde 110b11bef8 Datenbankbefehle benennen, welcher Rueckfragen stellt
"npm run db:migrate" ist "prisma migrate dev" und damit interaktiv: Es
fragt nach einem Namen und bietet an, die Datenbank zurueckzusetzen. In
einem Skript, in der CI oder aus einem Agenten heraus bleibt es haengen.
Aus dem Namen ging das nicht hervor, also lief man einmal hinein.

- Neuer Abschnitt in der README mit allen sieben Datenbankbefehlen,
  wofuer sie da sind und welcher Rueckfragen stellt. Es ist genau einer.
- Der uebliche Weg fuer eine Schemaaenderung steht als Dreizeiler
  daneben - ueber den Wrapper aus #24, nicht ueber migrate dev.
- Die Ersteinrichtung in der README rief "npx prisma migrate dev --name
  fundament" auf, obwohl es dafuer "npm run setup" gibt. Das war der
  Ausgangspunkt der Verwirrung; jetzt steht dort der eine Befehl.
- Der Hinweis unter "Betriebswissen", die SQL-Skripte seien von Hand
  anzustossen, galt nur fuer die Ersteinrichtung. Beim Aktualisieren
  erledigt update.sh das mit - jetzt steht das dabei.

"db:migrate" bleibt bestehen. Der interaktive Ablauf hat seinen Platz,
etwa um eine verkorkste Entwicklungsdatenbank zurueckzusetzen; er ist
nur nicht mehr der Standardweg.

Refs #27
2026-08-23 19:35:20 +02:00
florian.wilde 4aed9824d1 Merge #30: Migrationskommentare zeigen auf die richtige Datei
Behebt Issue #25: prisma/sql/suche.sql gibt es nicht, richtig ist
volltextsuche.sql. Dazu ein Verweis auf npm run db:migration aus #24.

Closes #25
2026-08-23 17:31:35 +00:00
Florian Wilde e19300b33d Migrationskommentare zeigen auf die richtige Datei
Fuenf Migrationen verwiesen fuer die Herkunft der Trigramm-Indizes auf
prisma/sql/suche.sql. Diese Datei gibt es nicht; sie heisst
volltextsuche.sql. Der Hinweis steht genau dort, wo ihn jemand liest,
der die naechste Migration erzeugt - ein falscher Pfad schickt ihn ins
Leere.

Dazu ein Verweis auf das Werkzeug aus #24: "npm run db:migration" nimmt
die Zeilen inzwischen selbst heraus, die Kommentare rieten aber
weiterhin nur zum Nachsehen.

Geprueft, bevor die Dateien angefasst wurden: Prisma speichert zwar eine
Pruefsumme je Migration, "migrate deploy" verifiziert sie fuer bereits
angewendete Migrationen aber nicht. Nachgestellt mit genau der Lage, die
auch auf dem Server entsteht - Pruefsumme in der Datenbank weicht ab,
"migrate status" und "migrate deploy" bleiben zufrieden.

Refs #25
2026-08-23 19:30:46 +02:00
florian.wilde 41e33ad85f Merge #29: Migrations-Wrapper gegen das Loeschen der Trigramm-Indizes
Behebt Issue #24: npm run db:migration erzeugt Migrationen ohne die
bekannten DROP-INDEX-Zeilen und prueft nach dem Anwenden, dass die
Indizes stehen.

Closes #24
2026-08-23 17:25:52 +00:00
Florian Wilde 3153e6179d Migrationen erzeugen, ohne die Suche kaputtzumachen
"prisma migrate diff" kennt die Trigramm-Indizes aus
prisma/sql/volltextsuche.sql nicht und schreibt deshalb in jede
Migration ein DROP INDEX dafuer. Fuenfmal mussten die Zeilen von Hand
raus. Auf dem Server waere es folgenlos geblieben - update.sh legt die
Indizes hinterher neu an -, in der Entwicklung dagegen waere die Suche
still langsam geworden.

  npm run db:migration -- <name> [--anwenden]
  npm run db:pruefen

Aufbau:
- Die Liste der geschuetzten Indizes steht an einer Stelle. Wer in
  prisma/sql/ einen hinzufuegt, traegt ihn dort ein.
- dropZeilenEntfernen() ist bewusst eng: Nur eine DROP-Anweisung, die
  einen geschuetzten Index nennt, faellt weg. Ein DROP INDEX auf einen
  Prisma-eigenen Index bleibt stehen - der soll ja weg. Ein Kommentar,
  der den Namen bloss erwaehnt, ebenfalls.
- Der Kopf der erzeugten Datei sagt, was entfernt wurde und warum.
- Nach --anwenden wird gegen die Datenbank geprueft, ob die Indizes
  wirklich noch stehen. Das ist der Unterschied zwischen "sollte
  gutgegangen sein" und "nachgesehen".

Beim Probelauf gefunden und behoben: Stimmen Schema und Datenbank
ueberein, besteht der ganze Diff aus den beiden bekannten DROP-Zeilen.
Das Skript legte dafuer eine leere Migration an - und eine leere
Migration in der Historie ist schlimmer als keine, weil sie als
angewendet gilt und niemand mehr weiss, wofuer sie gut war. Jetzt bricht
es vorher ab, ohne einen Ordner zu hinterlassen.

Die Typen liegen als .d.mts daneben: Das Skript bleibt .mjs und laeuft
ohne Bauschritt, die Tests laufen trotzdem nicht gegen "any".

Geprueft: zwoelf Einheitstests, dazu von Hand der Lauf mit echter
Schemaaenderung und der Lauf ohne.

Refs #24
2026-08-23 19:25:09 +02:00
30 changed files with 1505 additions and 42 deletions
+93 -1
View File
@@ -26,6 +26,96 @@ Wiederherstellung*.
_Noch nichts._
## [1.10.1] – 2026-08-23
### Geändert
- **Der Beispielfall hat jetzt ein Jahr Kontobewegungen**
([#22](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/22)).
Bisher brachte er Konten und Kontostände mit, aber keine einzige
Buchung — Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer. Wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.
Neu sind 107 Buchungen über zwölf Monate: Rente, Betriebsrente und
Pflegekasse als Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag,
Telefon und Kontoführung als monatliche Ausgaben, dazu Rundfunkbeitrag
und Grundsteuer vierteljährlich sowie eine Handvoll Einzelposten.
Das betrifft nur neue Installationen mit `SEED_DEMO=true` — an einem
bestehenden Bestand ändert sich nichts.
### Behoben
- **„gGmbH" wurde nicht als Rechtsform erkannt.** Beim Zusammenfassen
wiederkehrender Zahlungen galten „Seniorenzentrum St. Katharina" und
„Seniorenzentrum St. Katharina gGmbH" als zwei verschiedene Empfänger —
und damit zerfiel die Gruppe genau dort, wo sie am meisten zählt: Heime,
Sozialdienste und Träger sind fast durchweg gGmbH.
### Was der Beispielfall jetzt zeigt
- Das Heimentgelt steht in **drei Schreibweisen** auf dem Auszug und wird
trotzdem als eine monatliche Zahlung von 2.489 € geführt.
- Genau **eine** Buchung hat weder Quittung noch Begründung — damit die
Belegpflicht etwas zu zeigen hat, aber die Liste nicht rot leuchtet.
- Die Sterbegeld-Lastschriften sind bewusst **nicht** zugeordnet: So lässt
sich der Handgriff „zwölf Zahlungen als belegt zuordnen" ausprobieren.
Der Beitrag steigt im Mai von 18,40 € auf 19,80 €, damit auch die
Abweichungsprüfung sichtbar wird.
- Der Kontostand zum 31.07.2026 wird aus dem Anfangsstand und den
Buchungen **gerechnet**, nicht behauptet. Ein Beispielfall, der sich
selbst widerspricht, wäre schlimmer als einer ohne Buchungen.
### Geprüft
- Ein Einheitstest für die gGmbH-Erkennung.
- Vier End-to-End-Tests am Beispielfall: Umfang und Zeitraum der
Buchungen, die Übereinstimmung von Kontostand und Buchungssumme, die
erkannten Zahlungsgruppen und das Kassenbuch.
## [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 +1126,9 @@ 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.1...HEAD
[1.10.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...v1.10.1
[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
+35 -4
View File
@@ -97,11 +97,12 @@ DATABASE_URL="postgresql://cockpit:entwicklung@localhost:5433/betreuungs_cockpit
**3. Datenbank einrichten:**
```bash
npx prisma migrate dev --name fundament
npm run db:sql # Volltextsuche und Audit-Schutz
npm run db:seed
npm run setup # Migrationen, Volltextsuche, Audit-Schutz, Beispieldaten
```
Das ist die Kurzform für `db:deploy`, `db:sql` und `db:seed` — die drei
Schritte laufen ohne Rückfrage durch.
**4. Starten:**
```bash
@@ -513,6 +514,35 @@ npm run protokoll # Bauprotokoll aus der Git-Historie
npm run version -- patch # Version anheben, Changelog vorbereiten
```
### Die Datenbankbefehle
Welcher Befehl wofür — und welcher am Terminal Rückfragen stellt. Nur
`db:migrate` ist interaktiv; alle anderen laufen in Skripten, in der CI
und im Container durch.
| Befehl | Wofür | Rückfragen? |
|---|---|---|
| `npm run setup` | Ersteinrichtung: Migrationen, SQL-Skripte, Beispieldaten | nein |
| `npm run db:migration -- <name>` | Schemaänderung während der Entwicklung — erzeugt die Migration und nimmt die `DROP INDEX`-Zeilen zur Volltextsuche heraus | nein |
| `npm run db:deploy` | Vorhandene Migrationen anwenden (Server, CI, Container) | nein |
| `npm run db:migrate` | `prisma migrate dev` — fragt nach dem Namen und bietet an, die Datenbank zurückzusetzen | **ja** |
| `npm run db:sql` | Volltextsuche und Audit-Schutz; läuft nicht mit den Migrationen mit | nein |
| `npm run db:pruefen` | Nachsehen, ob die Trigramm-Indizes noch stehen | nein |
| `npm run db:seed` | Beispieldaten einspielen | nein |
Der übliche Weg für eine Schemaänderung:
```bash
# Schema in prisma/schema.prisma ändern, dann
npm run db:migration -- ansprechpartner # erzeugen und lesen
npm run db:deploy # anwenden
npm run db:pruefen # Indizes nachsehen
```
`db:migrate` braucht man dafür nicht mehr. Es bleibt für die Fälle, in
denen man wirklich den interaktiven Prisma-Ablauf will — etwa um eine
verkorkste Entwicklungsdatenbank zurückzusetzen.
Konventionen — verbindlich für alle Beiträge:
[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md).
Schnittstellen zwischen den Modulen:
@@ -545,7 +575,8 @@ Produktion.
über den Abschnitt `environment:`.
- Beim ersten Start eines Containers laufen die Migrationen automatisch
mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind
**einmalig von Hand** anzustoßen, siehe oben.
**einmalig von Hand** anzustoßen, siehe oben. Beim Aktualisieren
erledigt das `scripts/update.sh` mit — beide Skripte sind idempotent.
### Technische Grundlage
+7 -7
View File
@@ -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.1
```
### Kontrolle
```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.9.0
git describe --tags # v1.10.1
```
---
@@ -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.1","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.1",...}
```
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 · <commit>`.
- Unten links in der Seitenleiste steht `Version 1.10.1 · <commit>`.
---
@@ -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.1` | 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.1
# 4. Neu bauen und starten
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
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.1** ([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 ')
})
})
+138
View File
@@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Demo-Fall mit Kontobewegungen — gegen die echte Anwendung.
*
* Was hier geprüft wird, ist kein Verhalten der Anwendung, sondern die
* Brauchbarkeit der Beispieldaten: Wer die Anwendung zum ersten Mal
* ansieht, soll auf den Finanzseiten etwas vorfinden und nicht erst
* eine CSV importieren müssen.
*
* Die Prüfungen sind bewusst grob gehalten — sie sollen anschlagen, wenn
* der Demo-Fall leer läuft, und nicht bei jeder geänderten Zahl.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
test.describe('Demo-Fall', () => {
test('bringt ein Jahr Kontobewegungen mit', async () => {
const zahlen = await mitDb(async (db) => {
const betreute = await db.betreute.findFirst({
where: { vorname: 'Erika', nachname: 'Musterfrau' },
select: { id: true },
})
if (!betreute) return null
const buchungen = await db.buchung.findMany({
where: { betreuteId: betreute.id, geloeschtAm: null },
select: { datum: true },
orderBy: { datum: 'asc' },
})
const ohneNachweis = await db.buchung.count({
where: { betreuteId: betreute.id, belegId: null, belegFehltBegruendung: null },
})
return { buchungen, ohneNachweis }
})
test.skip(zahlen === null, 'Kein Demo-Fall in dieser Datenbank.')
if (!zahlen) return
expect(zahlen.buchungen.length).toBeGreaterThan(50)
// Über mindestens zwölf Monate verteilt, nicht alle an einem Tag.
const erste = zahlen.buchungen[0]!.datum
const letzte = zahlen.buchungen[zahlen.buchungen.length - 1]!.datum
const monate =
(letzte.getUTCFullYear() - erste.getUTCFullYear()) * 12 +
(letzte.getUTCMonth() - erste.getUTCMonth())
expect(monate).toBeGreaterThanOrEqual(11)
// Genau ein Fall ohne Beleg und ohne Begründung — sonst hätte die
// Belegpflicht nichts zu zeigen, oder zu viel.
expect(zahlen.ohneNachweis).toBe(1)
})
test('der Kontostand stimmt mit der Summe der Buchungen überein', async () => {
const ergebnis = await mitDb(async (db) => {
const konto = await db.konto.findFirst({
where: { art: 'GIRO', betreute: { vorname: 'Erika', nachname: 'Musterfrau' } },
select: { id: true },
})
if (!konto) return null
const staende = await db.saldostand.findMany({
where: { kontoId: konto.id },
orderBy: { stichtag: 'asc' },
})
if (staende.length < 2) return null
const [anfang, ende] = [staende[0]!, staende[staende.length - 1]!]
const bewegungen = await db.buchung.findMany({
where: {
kontoId: konto.id,
geloeschtAm: null,
datum: { gt: anfang.stichtag, lte: ende.stichtag },
},
select: { betragCent: true, richtung: true },
})
const summe = bewegungen.reduce(
(wert, b) => (b.richtung === 'EINNAHME' ? wert + b.betragCent : wert - b.betragCent),
0n,
)
return { erwartet: anfang.betragCent + summe, tatsaechlich: ende.betragCent }
})
test.skip(ergebnis === null, 'Kein Demo-Fall mit zwei Kontoständen.')
if (!ergebnis) return
// Ein Demo-Fall, der sich selbst widerspricht, ist schlimmer als
// einer ohne Buchungen: Man rechnet nach und verliert das Vertrauen.
expect(ergebnis.tatsaechlich).toBe(ergebnis.erwartet)
})
test('die Erkennung wiederkehrender Zahlungen findet mehrere Gruppen', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const inhalt = await page.locator('body').innerText()
// Monatliches und Vierteljährliches, beides erkannt.
expect(inhalt).toContain('Seniorenzentrum')
expect(inhalt).toContain('TeleCare')
expect(inhalt).toContain('Beitragsservice')
expect(inhalt).toContain('monatlich')
expect(inhalt).toContain('vierteljährlich')
// Der eigentliche Vorführeffekt: Das Heim steht in drei Schreibweisen
// auf dem Auszug und wird trotzdem als eine Zahlung geführt.
expect(inhalt).toContain('3 Schreibweisen zusammengefasst')
// Die Sterbekasse steht im zweiten Abschnitt: Zu ihr gibt es einen
// Vertrag, die Zahlungen sind ihm aber noch nicht zugeordnet. Damit
// lässt sich der Handgriff aus der Dauerzahlungs-Zuordnung am
// Demo-Fall ausprobieren, statt ihn nur zu beschreiben.
expect(inhalt).toContain('Schon einem Vertrag zugeordnet')
expect(inhalt).toContain('Nordheim Sterbekasse')
expect(inhalt).toContain('als belegt zuordnen')
})
test('das Kassenbuch zeigt die Buchungen und die fehlende Quittung', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/buchungen')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Heimentgelt')
// Die eine Buchung ohne Quittung und ohne Begründung.
expect(inhalt).toContain('Modehaus Lindner')
})
})
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.9.0",
"version": "1.10.1",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
@@ -17,6 +17,8 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:migration": "node scripts/migration-erzeugen.mjs",
"db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"",
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
@@ -2,10 +2,11 @@
--
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
-- jeder erzeugten Migration entfernt.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
@@ -2,10 +2,11 @@
--
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable
CREATE TABLE "zahlungsgruppe_geklaert" (
@@ -2,10 +2,11 @@
--
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm"
-- loeschen. Sie stammen aus prisma/sql/suche.sql und sind Prisma
-- loeschen. Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
-- nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- AlterTable
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
@@ -1,8 +1,9 @@
-- Eigene Schreibvorlagen.
--
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable
CREATE TABLE "schreibvorlage" (
@@ -1,8 +1,9 @@
-- Ansprechpartner am Vorgang, Anrede am Kontakt.
--
-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "KontaktAnrede" AS ENUM ('OHNE', 'FRAU', 'HERR');
@@ -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).
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"
+307 -2
View File
@@ -22,7 +22,7 @@
*
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/
import { PrismaClient } from '@prisma/client'
import { PrismaClient, type BuchungKategorie, type Prisma } from '@prisma/client'
import { hash } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto'
@@ -263,9 +263,11 @@ async function demofall(userId: string) {
art: 'GIRO',
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
saldostaende: {
// Nur der Anfangsstand. Der spaetere wird weiter unten aus den
// Buchungen gerechnet - ein Demofall, der sich selbst
// widerspricht, waere schlimmer als einer ohne Buchungen.
create: [
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
{ stichtag: datum('2026-07-31'), betragCent: cent(2967.11), quelle: 'Kontoauszug 07/2026' },
],
},
},
@@ -523,6 +525,309 @@ async function demofall(userId: string) {
})
}
// --- Kontobewegungen ----------------------------------------------------
// Zwoelf Monate auf dem Girokonto. Erzeugt aus einem Muster und nicht
// einzeln abgetippt: So bleibt der Fall aenderbar, und die Betraege
// widersprechen sich nicht.
//
// Die Gegenstellen sind absichtlich uneinheitlich geschrieben. Genau
// damit muss die Zusammenfassung wiederkehrender Zahlungen umgehen - auf
// einem Kontoauszug steht dieselbe Stelle auch mal in Grossbuchstaben.
type Buchungsvorlage = {
tag: number
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstellen: string[]
zweck: string
}
const MONATLICH: Buchungsvorlage[] = [
{
tag: 1,
betrag: 1284.17,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: [
'Deutsche Rentenversicherung Bund',
'DRV BUND',
'Deutsche Rentenversich. Bund',
],
zweck: 'Altersrente',
},
{
tag: 1,
betrag: 96.4,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: ['Zusatzversorgungskasse Bayern', 'ZVK Bayern'],
zweck: 'Betriebsrente',
},
{
tag: 3,
betrag: 1262,
richtung: 'EINNAHME',
kategorie: 'PFLEGEKASSE',
gegenstellen: ['Pflegekasse bei der AOK Bayern', 'AOK Bayern - Pflegekasse'],
zweck: 'Leistung vollstationaere Pflege, Pflegegrad 3',
},
{
tag: 5,
betrag: 2489,
richtung: 'AUSGABE',
kategorie: 'HEIMKOSTEN',
gegenstellen: [
'Seniorenzentrum St. Katharina',
'SENIORENZENTRUM ST KATHARINA',
'Seniorenzentrum St. Katharina gGmbH',
],
zweck: 'Heimentgelt',
},
{
tag: 5,
betrag: 135,
richtung: 'AUSGABE',
kategorie: 'BARBETRAG',
// Eigene Gegenstelle, nicht das Heim: Sonst faellt der Barbetrag
// mit dem Heimentgelt in eine Gruppe, und aus zwei sauberen
// monatlichen Zahlungen wird eine "schwankende".
gegenstellen: ['Barauszahlung Verwahrgeldkonto'],
zweck: 'Barbetrag zur persoenlichen Verfuegung',
},
{
tag: 15,
betrag: 18.4,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstellen: ['Nordheim Sterbekasse VVaG', 'NORDHEIM STERBEKASSE', 'Nordheim Sterbekasse'],
zweck: 'Beitrag Sterbegeldversicherung SG-220417',
},
{
tag: 20,
betrag: 24.9,
richtung: 'AUSGABE',
kategorie: 'TELEFON_INTERNET',
gegenstellen: ['TeleCare Kommunikation GmbH', 'TELECARE KOMMUNIKATION'],
zweck: 'Telefon und Fernsehen im Zimmer',
},
{
tag: 28,
betrag: 4.9,
richtung: 'AUSGABE',
kategorie: 'BANKGEBUEHREN',
gegenstellen: ['Sparkasse Bayreuth'],
zweck: 'Kontofuehrung',
},
]
/** Zwoelf Monate bis einschliesslich Juli 2026. */
const MONATE = [
'2025-08',
'2025-09',
'2025-10',
'2025-11',
'2025-12',
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
]
const buchungen: Prisma.BuchungCreateManyInput[] = []
MONATE.forEach((monat, lauf) => {
MONATLICH.forEach((vorlage, stelle) => {
// Die Sterbegeld-Lastschrift steigt im Mai 2026. Daran wird die
// Pruefung beim Zuordnen einer Dauerzahlung sichtbar: Der Betrag
// weicht vom hinterlegten Beitrag ab.
const betrag =
vorlage.kategorie === 'VERSICHERUNG' && monat >= '2026-05' ? 19.8 : vorlage.betrag
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(`${monat}-${String(vorlage.tag).padStart(2, '0')}`),
betragCent: cent(betrag),
richtung: vorlage.richtung,
kategorie: vorlage.kategorie,
gegenstelle: vorlage.gegenstellen[lauf % vorlage.gegenstellen.length],
verwendungszweckVerschluesselt: verschluesseln(
vorlage.zweck,
'verwendungszweckVerschluesselt',
),
// Wiederkehrende Zahlungen brauchen keinen Einzelbeleg - der
// Bescheid bzw. der Vertrag dahinter ist der Nachweis.
belegFehltBegruendung:
vorlage.richtung === 'EINNAHME'
? 'Laufender Bezug, Nachweis ist der Bescheid.'
: 'Wiederkehrende Zahlung aus laufendem Vertrag.',
// Die Stelle in der Vorlagenliste gehoert in die Referenz: Zwei
// Renten am Monatsersten haetten sonst dieselbe, und der
// Unique-Index gegen Doppelbuchungen schlaegt zu Recht zu.
importReferenz: `demo-${monat}-${stelle}-${vorlage.kategorie}`,
})
})
})
// Vierteljaehrliches und Einmaliges - damit nicht alles im Monatsraster
// liegt und die Belegpflicht etwas zu zeigen hat.
const EINZELN: {
datum: string
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstelle: string
zweck: string
begruendung?: string
}[] = [
{
datum: '2025-08-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 3. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2025-11-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 4. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 1. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-05-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 2. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 1. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-05-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 2. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-01-19',
betrag: 240,
richtung: 'AUSGABE',
kategorie: 'IMMOBILIE_BETRIEBSKOSTEN',
gegenstelle: 'Stadtwerke Bayreuth',
zweck: 'Abschlag Strom und Wasser, Haus Bindlach',
begruendung: 'Jahresabrechnung steht noch aus.',
},
{
datum: '2026-03-11',
betrag: 10,
richtung: 'AUSGABE',
kategorie: 'ZUZAHLUNG',
gegenstelle: 'Apotheke am Markt',
zweck: 'Zuzahlung Rezept',
begruendung: 'Quittung liegt in der Akte.',
},
// Der Fall fuer die Belegpflicht: keine Quittung und keine Begruendung.
// Genau so sieht eine Buchung aus, die noch Arbeit macht.
{
datum: '2026-04-08',
betrag: 89.95,
richtung: 'AUSGABE',
kategorie: 'BEKLEIDUNG',
gegenstelle: 'Modehaus Lindner',
zweck: 'Winterjacke und zwei Nachthemden',
},
{
datum: '2026-06-17',
betrag: 23,
richtung: 'AUSGABE',
kategorie: 'SONSTIGE_AUSGABE',
gegenstelle: 'Friseur im Haus',
zweck: 'Friseur',
begruendung: 'Bar aus dem Barbetrag bezahlt, keine Quittung ausgestellt.',
},
{
datum: '2026-07-02',
betrag: 62.4,
richtung: 'EINNAHME',
kategorie: 'ZINSEN',
gegenstelle: 'Sparkasse Bayreuth',
zweck: 'Zinsgutschrift',
begruendung: 'Der Kontoauszug ist der Nachweis.',
},
]
for (const eintrag of EINZELN) {
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(eintrag.datum),
betragCent: cent(eintrag.betrag),
richtung: eintrag.richtung,
kategorie: eintrag.kategorie,
gegenstelle: eintrag.gegenstelle,
verwendungszweckVerschluesselt: verschluesseln(
eintrag.zweck,
'verwendungszweckVerschluesselt',
),
belegFehltBegruendung: eintrag.begruendung ?? null,
importReferenz: `demo-${eintrag.datum}-${eintrag.kategorie}`,
})
}
await db.buchung.createMany({ data: buchungen })
// Der Kontostand zum 31.07.2026 wird gerechnet, nicht behauptet: Anfangs-
// stand plus alles, was seither ueber das Konto gelaufen ist. Sonst
// widerspraeche der Demofall sich selbst, sobald jemand nachrechnet.
const bewegungCent = buchungen
.filter((eintrag) => {
const tag = eintrag.datum as Date
return tag > datum('2026-02-14') && tag <= datum('2026-07-31')
})
.reduce((summe, eintrag) => {
const betrag = eintrag.betragCent as bigint
return eintrag.richtung === 'EINNAHME' ? summe + betrag : summe - betrag
}, 0n)
await db.saldostand.create({
data: {
kontoId: giro.id,
stichtag: datum('2026-07-31'),
betragCent: cent(4218.36) + bewegungCent,
quelle: 'Kontoauszug 07/2026',
},
})
console.log('Demofall "Erika Musterfrau" angelegt.')
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Typen fuer das Werkzeugskript.
*
* Das Skript selbst bleibt .mjs - es laeuft ohne Bauschritt, auch auf
* einem Rechner, auf dem nur Node steht. Die Typen daneben sorgen dafuer,
* dass die Tests trotzdem geprueft werden und nicht stillschweigend
* gegen "any" laufen.
*/
export type GeschuetzterIndex = {
name: string
quelle: string
}
export declare const GESCHUETZTE_INDIZES: GeschuetzterIndex[]
export declare function dropZeilenEntfernen(
sql: string,
geschuetzt?: GeschuetzterIndex[],
): { sql: string; entfernt: string[] }
export declare function hatAnweisungen(sql: string): boolean
export declare function kopfBauen(
name: string,
entfernt: string[],
geschuetzt?: GeschuetzterIndex[],
): string
export declare function ordnernameBauen(name: string, jetzt?: Date): string
export declare function indizesPruefen(): void
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* Eine Migration erzeugen, ohne die Volltextsuche kaputtzumachen.
*
* npm run db:migration -- ansprechpartner
* npm run db:migration -- ansprechpartner --anwenden
*
* Das Problem, das dieses Skript loest: "prisma migrate diff" kennt nur
* das, was im Prisma-Schema steht. Die Trigramm-Indizes der Suche stehen
* aber in prisma/sql/volltextsuche.sql und sind Prisma unbekannt - also
* haelt es sie fuer ueberfluessig und schreibt in JEDE erzeugte Migration:
*
* DROP INDEX "dokument_absender_trgm";
* DROP INDEX "vorgang_titel_trgm";
*
* Fuenfmal ist das passiert, fuenfmal mussten die Zeilen von Hand raus.
* Auf dem Server waere es nicht aufgefallen, weil update.sh die Indizes
* hinterher ohnehin neu anlegt - in der Entwicklung dagegen waere die
* Suche still langsam geworden, ohne dass etwas fehlschlaegt.
*
* Was das Skript macht:
* 1. Migration mit "migrate diff" erzeugen.
* 2. DROP-Zeilen zu geschuetzten Indizes entfernen und im Kopf
* vermerken, welche das waren.
* 3. Auf Wunsch (--anwenden) "migrate deploy" laufen lassen und danach
* pruefen, dass die geschuetzten Indizes wirklich noch stehen.
*
* Nicht automatisch angewendet wird die Migration ohne --anwenden: Erst
* liest man, was da erzeugt wurde.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), '..')
/**
* Indizes, die es nur in prisma/sql/ gibt und die Prisma deshalb nicht
* kennt. Wer dort einen hinzufuegt, traegt ihn hier ein - sonst faellt er
* der naechsten Migration zum Opfer.
*/
export const GESCHUETZTE_INDIZES = [
{ name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
{ name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
]
/**
* Entfernt DROP-INDEX-Anweisungen zu geschuetzten Indizes.
*
* Bewusst eng gefasst: Nur eine Anweisung, die genau einen geschuetzten
* Index nennt, faellt weg. Ein DROP INDEX auf einen Prisma-eigenen Index
* bleibt stehen - der soll ja weg. Der Kommentar "-- DropIndex", den
* Prisma darueber setzt, wird mitgenommen, damit keine verwaisten
* Ueberschriften zurueckbleiben.
*
* @param {string} sql
* @param {{name: string}[]} geschuetzt
* @returns {{sql: string, entfernt: string[]}}
*/
export function dropZeilenEntfernen(sql, geschuetzt = GESCHUETZTE_INDIZES) {
const namen = geschuetzt.map((eintrag) => eintrag.name)
const zeilen = sql.split('\n')
const behalten = []
const entfernt = []
for (let i = 0; i < zeilen.length; i++) {
const zeile = zeilen[i]
const treffer = namen.find(
(name) => /^\s*DROP\s+INDEX\b/i.test(zeile) && zeile.includes(name),
)
if (!treffer) {
behalten.push(zeile)
continue
}
entfernt.push(treffer)
// Die "-- DropIndex"-Ueberschrift darueber mitnehmen, samt der
// Leerzeile davor - sonst bleiben Luecken im Text stehen.
while (behalten.length > 0 && /^\s*--\s*DropIndex\s*$/i.test(behalten[behalten.length - 1])) {
behalten.pop()
while (behalten.length > 0 && behalten[behalten.length - 1].trim() === '') behalten.pop()
}
// Leerzeilen, die der entfernten Anweisung folgen, ebenfalls.
while (i + 1 < zeilen.length && zeilen[i + 1].trim() === '') i++
}
return { sql: behalten.join('\n').replace(/\n{3,}/g, '\n\n').trimStart(), entfernt }
}
/**
* Steht in dem SQL ueberhaupt noch eine Anweisung?
*
* Wichtig fuer den Normalfall: Wenn Schema und Datenbank uebereinstimmen,
* besteht der ganze Diff aus den beiden bekannten DROP-INDEX-Zeilen. Nach
* dem Entfernen bleibt nichts uebrig - und eine leere Migration anzulegen
* waere schlimmer als gar keine, weil sie als angewendet in der Historie
* steht und niemand mehr weiss, wofuer sie gut war.
*
* @param {string} sql
* @returns {boolean}
*/
export function hatAnweisungen(sql) {
return sql
.split('\n')
.some((zeile) => zeile.trim() !== '' && !zeile.trim().startsWith('--'))
}
/** Der Kopfkommentar der erzeugten Datei. */
export function kopfBauen(name, entfernt, geschuetzt = GESCHUETZTE_INDIZES) {
const zeilen = [`-- ${name}`, '--']
if (entfernt.length === 0) {
zeilen.push('-- Erzeugt mit "npm run db:migration". Nichts zu entfernen.')
} else {
const quellen = [
...new Set(
entfernt.map(
(eintrag) => geschuetzt.find((g) => g.name === eintrag)?.quelle ?? 'prisma/sql/',
),
),
]
zeilen.push(
'-- Erzeugt mit "npm run db:migration". Entfernt wurden die',
'-- DROP-INDEX-Anweisungen zu diesen Indizes:',
...entfernt.map((eintrag) => `-- ${eintrag}`),
'--',
`-- Sie stammen aus ${quellen.join(', ')} und sind Prisma unbekannt;`,
'-- ohne sie waere die Volltextsuche still langsam geworden.',
)
}
zeilen.push('')
return zeilen.join('\n')
}
/** Zeitstempel im Prisma-Format, aus der lokalen Uhr. */
export function ordnernameBauen(name, jetzt = new Date()) {
const zwei = (zahl) => String(zahl).padStart(2, '0')
const stempel = [
jetzt.getFullYear(),
zwei(jetzt.getMonth() + 1),
zwei(jetzt.getDate()),
zwei(jetzt.getHours()),
zwei(jetzt.getMinutes()),
zwei(jetzt.getSeconds()),
].join('')
return `${stempel}_${name}`
}
// --- Ab hier: Ausfuehrung ----------------------------------------------------
/**
* Prisma direkt ueber sein Eintrittsskript aufrufen, nicht ueber npx.
*
* Mit "shell: true" wuerde Node warnen (Argumente werden dann nur
* aneinandergehaengt statt maskiert), und ohne Shell findet Windows das
* npx-Skript nicht. Der Weg ueber node loest beides und ist derselbe,
* den scripts/update.sh schon nimmt.
*/
function prisma(argumente, optionen = {}) {
const eintritt = join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js')
return execFileSync(process.execPath, [eintritt, ...argumente], {
cwd: WURZEL,
encoding: 'utf8',
...optionen,
})
}
function abbruch(meldung) {
console.error(`\n Abbruch: ${meldung}\n`)
process.exit(1)
}
function hauptlauf() {
const argumente = process.argv.slice(2)
const anwenden = argumente.includes('--anwenden')
const name = argumente.find((eintrag) => !eintrag.startsWith('--'))
if (!name) {
abbruch(
'Bitte einen Namen angeben: npm run db:migration -- <name> [--anwenden]\n' +
' Beispiel: npm run db:migration -- ansprechpartner',
)
}
if (!/^[a-z0-9_]+$/.test(name)) {
abbruch(`"${name}" taugt nicht als Ordnername. Erlaubt: a-z, 0-9 und _`)
}
console.log(`\n Migration "${name}" erzeugen\n`)
let roh
try {
roh = prisma([
'migrate',
'diff',
'--from-schema-datasource',
'prisma/schema.prisma',
'--to-schema-datamodel',
'prisma/schema.prisma',
'--script',
])
} catch (fehler) {
abbruch(`"prisma migrate diff" ist fehlgeschlagen:\n${fehler.message}`)
}
if (roh.trim() === '' || /^--\s*This is an empty migration/im.test(roh)) {
abbruch('Schema und Datenbank stimmen bereits ueberein - es gibt nichts zu migrieren.')
}
const { sql, entfernt } = dropZeilenEntfernen(roh)
if (!hatAnweisungen(sql)) {
const grund =
entfernt.length > 0
? `Uebrig blieben nur die bekannten DROP-INDEX-Zeilen (${entfernt.join(', ')}).`
: 'Der Vergleich hat keine Anweisungen ergeben.'
abbruch(
`Es gibt nichts zu migrieren. ${grund}\n` +
' Es wurde keine Migration angelegt.',
)
}
const ordner = join(WURZEL, 'prisma', 'migrations', ordnernameBauen(name))
mkdirSync(ordner, { recursive: true })
const datei = join(ordner, 'migration.sql')
writeFileSync(datei, `${kopfBauen(name, entfernt)}\n${sql.trimEnd()}\n`, 'utf8')
console.log(` Geschrieben: ${datei.replace(WURZEL, '.')}`)
if (entfernt.length > 0) {
console.log('\n Entfernte DROP-INDEX-Anweisungen:')
for (const eintrag of entfernt) console.log(` - ${eintrag}`)
} else {
console.log(' Keine DROP-INDEX-Anweisung zu schuetzenden Indizes gefunden.')
}
if (!anwenden) {
console.log('\n Jetzt: die Datei lesen, dann anwenden mit')
console.log(' npm run db:deploy && npm run db:pruefen\n')
return
}
console.log('\n Anwenden ...')
try {
prisma(['migrate', 'deploy'], { stdio: 'inherit' })
} catch {
abbruch('"prisma migrate deploy" ist fehlgeschlagen. Die Datei bleibt liegen.')
}
indizesPruefen()
}
/**
* Stehen die geschuetzten Indizes noch?
*
* Der Sinn des ganzen Skripts haengt an dieser Pruefung: Sie ist der
* Unterschied zwischen "sollte gutgegangen sein" und "nachgesehen".
*/
export function indizesPruefen() {
const bedingung = GESCHUETZTE_INDIZES.map((eintrag) => `'${eintrag.name}'`).join(', ')
const sql = `DO $$
BEGIN
IF (SELECT count(*) FROM pg_indexes WHERE indexname IN (${bedingung})) <> ${GESCHUETZTE_INDIZES.length} THEN
RAISE EXCEPTION 'Es fehlen geschuetzte Indizes';
END IF;
END $$;`
// In den Temp-Ordner, nicht ins Repository: Die Datei ist Werkzeug,
// kein Bestandteil des Projekts.
const datei = join(tmpdir(), `indizes-pruefung-${process.pid}.sql`)
writeFileSync(datei, sql, 'utf8')
try {
prisma(['db', 'execute', '--schema', 'prisma/schema.prisma', '--file', datei], {
stdio: 'pipe',
})
console.log('\n Geschuetzte Indizes stehen:')
for (const eintrag of GESCHUETZTE_INDIZES) console.log(` - ${eintrag.name}`)
console.log('')
} catch {
abbruch(
'Nach der Migration fehlen geschuetzte Indizes.\n' +
' Wiederherstellen mit: npm run db:sql',
)
} finally {
rmSync(datei, { force: true })
}
}
if (process.argv[1] && process.argv[1].endsWith('migration-erzeugen.mjs')) {
hauptlauf()
}
@@ -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,
+4
View File
@@ -122,6 +122,10 @@ export type Zahlungsgruppe = {
const RECHTSFORMEN = [
'gmbh & co kg',
'gmbh und co kg',
// Die haeufigste Rechtsform in diesem Fachgebiet ueberhaupt: Heime,
// Sozialdienste und Traeger sind fast durchweg gGmbH. Fehlte sie,
// zerfiele die Zusammenfassung genau dort, wo sie gebraucht wird.
'ggmbh',
'gmbh',
'mbh',
'ohg',
+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.1'
/**
* 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.' }
}
+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')
})
})
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import {
dropZeilenEntfernen,
hatAnweisungen,
kopfBauen,
ordnernameBauen,
} from '../scripts/migration-erzeugen.mjs'
/**
* Der Migrations-Wrapper.
*
* Die eine Sache, die hier schiefgehen darf und nicht schiefgehen soll:
* zu viel entfernen. Ein DROP INDEX, das Prisma zu Recht schreibt, muss
* stehen bleiben - sonst repariert das Skript die Suche und zerlegt
* dafuer das Schema.
*/
const GESCHUETZT = [
{ name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
{ name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
]
describe('DROP-Zeilen entfernen', () => {
it('nimmt die geschützte Anweisung samt Überschrift heraus', () => {
const roh = [
'-- DropIndex',
'DROP INDEX "dokument_absender_trgm";',
'',
'-- AlterTable',
'ALTER TABLE "kontakt" ADD COLUMN "anrede" TEXT;',
].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual(['dokument_absender_trgm'])
expect(sql).not.toContain('DROP INDEX')
expect(sql).not.toContain('DropIndex')
expect(sql).toContain('ALTER TABLE "kontakt"')
})
it('lässt ein DROP INDEX auf einen Prisma-eigenen Index stehen', () => {
// Der Kern der Sache: Das Skript darf nicht alles wegräumen, was nach
// DROP INDEX aussieht - manche sollen wirklich weg.
const roh = ['-- DropIndex', 'DROP INDEX "vertrag_betreuteId_status_idx";'].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual([])
expect(sql).toContain('DROP INDEX "vertrag_betreuteId_status_idx";')
expect(sql).toContain('-- DropIndex')
})
it('entfernt mehrere geschützte Anweisungen und meldet beide', () => {
const roh = [
'-- DropIndex',
'DROP INDEX "dokument_absender_trgm";',
'',
'-- DropIndex',
'DROP INDEX "vorgang_titel_trgm";',
'',
'-- CreateTable',
'CREATE TABLE "schreibvorlage" ();',
].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual(['dokument_absender_trgm', 'vorgang_titel_trgm'])
expect(sql.startsWith('-- CreateTable')).toBe(true)
})
it('verträgt IF EXISTS und abweichende Schreibweise', () => {
const roh = 'drop index if exists "vorgang_titel_trgm";'
expect(dropZeilenEntfernen(roh, GESCHUETZT).entfernt).toEqual(['vorgang_titel_trgm'])
})
it('lässt eine Migration ohne Treffer unverändert', () => {
const roh = ['-- AlterTable', 'ALTER TABLE "vorgang" ADD COLUMN "x" TEXT;'].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual([])
expect(sql).toBe(roh)
})
it('greift nicht bei einem bloßen Vorkommen des Namens im Text', () => {
// Ein Kommentar, der den Index nennt, ist keine Anweisung.
const roh = '-- dokument_absender_trgm bleibt bestehen'
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual([])
expect(sql).toBe(roh)
})
})
describe('Leere Migration erkennen', () => {
it('sieht eine echte Anweisung', () => {
expect(hatAnweisungen('-- AlterTable\nALTER TABLE "x" ADD COLUMN "y" TEXT;')).toBe(true)
})
it('hält reinen Kommentartext für leer', () => {
// Der Normalfall: Schema und Datenbank stimmen überein, der ganze
// Diff bestand aus den zwei bekannten DROP-Zeilen. Dann darf keine
// Migration entstehen — eine leere in der Historie ist schlimmer als
// keine.
expect(hatAnweisungen('-- nur ein Kommentar\n\n-- noch einer')).toBe(false)
})
it('hält Leerraum für leer', () => {
expect(hatAnweisungen(' \n\n ')).toBe(false)
})
})
describe('Kopfkommentar', () => {
it('benennt die entfernten Indizes und ihre Quelle', () => {
const kopf = kopfBauen('ansprechpartner', ['vorgang_titel_trgm'], GESCHUETZT)
expect(kopf).toContain('vorgang_titel_trgm')
expect(kopf).toContain('prisma/sql/volltextsuche.sql')
})
it('sagt auch, wenn nichts zu entfernen war', () => {
expect(kopfBauen('leer', [], GESCHUETZT)).toContain('Nichts zu entfernen')
})
})
describe('Ordnername', () => {
it('baut den Zeitstempel im Prisma-Format', () => {
expect(ordnernameBauen('anrede', new Date(2026, 7, 23, 9, 5, 3))).toBe(
'20260823090503_anrede',
)
})
})
+11
View File
@@ -75,6 +75,17 @@ describe('Schreibweisen vergleichbar machen', () => {
expect(normalisieren('Debeka AG')).toBe(normalisieren('Debeka'))
})
it('kennt die gGmbH', () => {
// Heime, Sozialdienste und Träger sind fast durchweg gGmbH — ohne
// sie zerfiele die Zusammenfassung genau dort, wo sie zählt.
expect(normalisieren('Seniorenzentrum St. Katharina gGmbH')).toBe(
normalisieren('Seniorenzentrum St. Katharina'),
)
expect(normalisieren('SENIORENZENTRUM ST KATHARINA GGMBH')).toBe(
normalisieren('Seniorenzentrum St. Katharina'),
)
})
it('entfernt auch mehrteilige Rechtsformen', () => {
expect(normalisieren('Stadtwerke Bamberg GmbH & Co KG')).toBe(
normalisieren('Stadtwerke Bamberg'),