Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece995bb6a | ||
|
|
107406e3c0 | ||
|
|
9ea78a4d29 | ||
|
|
1ed19eb76e | ||
|
|
6a0db51e3b | ||
|
|
851724774a | ||
|
|
3772e360e1 | ||
|
|
d75eb185d5 | ||
|
|
91a9aca0d8 |
@@ -0,0 +1,32 @@
|
||||
# Zeilenenden
|
||||
#
|
||||
# Im Repository steht ueberall LF, unabhaengig davon, auf welchem System
|
||||
# gearbeitet wird. Der Grund ist nicht Geschmack, sondern eine Falle:
|
||||
#
|
||||
# Solange Dateien gemischt vorliegen, schreibt jedes zeilenweise
|
||||
# arbeitende Werkzeug - sed, ein Formatierer, ein Skript - die Datei mit
|
||||
# LF zurueck. Der Diff zeigt dann nicht die drei geaenderten Zeilen,
|
||||
# sondern die ganze Datei. Genau das ist passiert: 578 Diff-Zeilen statt
|
||||
# 8, kurz vor einem Push bemerkt.
|
||||
#
|
||||
# Nebenwirkung derselben Mischung: "git diff --check" meldet bei
|
||||
# CRLF-Dateien jede hinzugefuegte Zeile als "trailing whitespace", weil
|
||||
# es das CR so wertet. Damit ist die Pruefung fuer diese Dateien wertlos.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Shell-Skripte laufen auf dem Server. Ein CRLF im Shebang macht sie
|
||||
# unbrauchbar ("bad interpreter"), und der Fehler ist von aussen nicht
|
||||
# anzusehen. Deshalb hier ausdruecklich, nicht nur ueber die Regel oben.
|
||||
*.sh text eol=lf
|
||||
|
||||
# Binaerdateien nie anfassen.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.zip binary
|
||||
+47
-1
@@ -26,6 +26,51 @@ Wiederherstellung*.
|
||||
|
||||
_Noch nichts._
|
||||
|
||||
## [1.10.2] – 2026-08-24
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Das Update brach ohne Meldung ab.** Beim Sprung von 1.5.0 auf 1.10.1
|
||||
endete `scripts/update.sh` mitten in der Ausgabe — kein Fehler, keine
|
||||
Zeile, einfach zurück zur Eingabeaufforderung. Ursache war eine
|
||||
abgeschnittene Leitung:
|
||||
|
||||
```
|
||||
git log --oneline "HEAD..$ZIEL" | head -15 | sed ...
|
||||
```
|
||||
|
||||
`head` schließt die Leitung nach 15 Zeilen, `git log` schreibt weiter
|
||||
und bekommt SIGPIPE. Weil das Skript mit `set -o pipefail` und
|
||||
`set -e` läuft, gilt die ganze Kette als gescheitert und das Skript
|
||||
beendet sich — stumm.
|
||||
|
||||
Das ist zeitabhängig und deshalb monatelang nicht aufgefallen: Solange
|
||||
`git log` fertig war, bevor `head` zumachte, ging alles gut. Genau bei
|
||||
1.10.0 lagen zum ersten Mal **mehr als 15 Commits** dazwischen.
|
||||
|
||||
- **Dieselbe Falle in der Selbstprüfung der Sicherung.** `sicherung.sh`
|
||||
prüfte mit `openssl … | head -c 5 | grep -q "PGDMP"`, ob sich die
|
||||
frische Sicherung wieder entschlüsseln lässt. Bei kleiner Datenbank
|
||||
ging das gut, weil alles im Puffer stand. Mit wachsender Datenbank
|
||||
hätte es gekippt — und dann hätte das Skript eine **völlig intakte
|
||||
Sicherung als kaputt gemeldet** und das Update verweigert.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Ein stummer Abbruch ist nicht mehr möglich.** `update.sh` meldet
|
||||
jetzt bei jedem unerwarteten Ende die Zeilennummer und den Exitcode,
|
||||
und erklärt bei 141 ausdrücklich, dass es sich um SIGPIPE handelt und
|
||||
um einen Fehler im Skript, nicht um einen Bedienfehler.
|
||||
- Stehen mehr als 15 Commits an, steht jetzt „… und N weitere" darunter,
|
||||
statt sie wortlos wegzulassen.
|
||||
|
||||
### Geprüft
|
||||
|
||||
- Ein Test verbietet das Muster künftig in allen Betriebsskripten:
|
||||
Wer wieder `| head` in eine Leitung schreibt, bekommt die Zeilennummer
|
||||
genannt. Gegenprobe gemacht — der Test schlägt an.
|
||||
- Der Ablauf von 1.5.0 auf 1.10.1 einmal nachgestellt: läuft durch.
|
||||
|
||||
## [1.10.1] – 2026-08-23
|
||||
|
||||
### Geändert
|
||||
@@ -1126,7 +1171,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.10.1...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.2...HEAD
|
||||
[1.10.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.1...v1.10.2
|
||||
[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
|
||||
|
||||
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
|
||||
|
||||
# Auf die letzte veröffentlichte Fassung wechseln,
|
||||
# nicht auf den laufenden Entwicklungsstand
|
||||
git checkout v1.10.1
|
||||
git checkout v1.10.2
|
||||
```
|
||||
|
||||
### Kontrolle
|
||||
|
||||
```bash
|
||||
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
||||
git describe --tags # v1.10.1
|
||||
git describe --tags # v1.10.2
|
||||
```
|
||||
|
||||
---
|
||||
@@ -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.10.1","commit":"..."}
|
||||
# {"status":"bereit","version":"1.10.2","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.10.1",...}
|
||||
# {"status":"bereit","version":"1.10.2",...}
|
||||
```
|
||||
|
||||
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.10.1 · <commit>`.
|
||||
- Unten links in der Seitenleiste steht `Version 1.10.2 · <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.10.1` | auf eine bestimmte Fassung |
|
||||
| `./scripts/update.sh v1.10.2` | 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.10.1
|
||||
git checkout v1.10.2
|
||||
|
||||
# 4. Neu bauen und starten
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
|
||||
|
||||
+1
-1
@@ -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.10.1** ([CHANGELOG](../CHANGELOG.md))
|
||||
Stand: 23.08.2026 · Aktuelle Fassung: **1.10.2** ([CHANGELOG](../CHANGELOG.md))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { anmelden } from './hilfen'
|
||||
|
||||
/**
|
||||
* Verweise über Aktengrenzen hinweg — gegen die echte Anwendung.
|
||||
*
|
||||
* Die Auswahllisten im Browser zeigen nur die eigene Akte. Darauf darf
|
||||
* sich der Server nicht verlassen: Wer das Formular verändert, schickt
|
||||
* jede beliebige Id.
|
||||
*
|
||||
* Genau das wird hier nachgestellt — nicht als nachgebauter Aufruf,
|
||||
* sondern indem dem echten Auswahlfeld ein Eintrag mit einer fremden Id
|
||||
* untergeschoben und das Formular ganz normal abgeschickt wird. Nur so
|
||||
* beweist der Test etwas: Er liefe rot, wenn die Prüfung fehlte.
|
||||
*
|
||||
* Solange nur eine betreute Person angelegt ist, wäre der Fehler
|
||||
* folgenlos. Er bliebe es nicht — ein fremder Name stünde im Briefkopf.
|
||||
*/
|
||||
|
||||
const KENNUNG = 'Prüfhausen Fremdakte'
|
||||
|
||||
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
|
||||
const db = new PrismaClient()
|
||||
try {
|
||||
return await arbeit(db)
|
||||
} finally {
|
||||
await db.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
async function aufraeumen(): Promise<void> {
|
||||
await mitDb(async (db) => {
|
||||
await db.betreute.deleteMany({ where: { nachname: { contains: 'Fremdakte' } } })
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Verweise auf eine fremde Akte', () => {
|
||||
test.beforeAll(async () => {
|
||||
await aufraeumen()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await aufraeumen()
|
||||
})
|
||||
|
||||
test('weist einen Kontakt aus einer anderen Akte ab', async ({ page }) => {
|
||||
// Zweite Akte mit einem Kontakt darin — der angemeldete Benutzer hat
|
||||
// auf sie keinen Zugriff.
|
||||
const fremderKontakt = await mitDb(async (db) => {
|
||||
const fremde = await db.betreute.create({
|
||||
data: {
|
||||
vorname: 'Otto',
|
||||
nachname: `${KENNUNG} Fremdakte`,
|
||||
geburtsdatum: new Date(Date.UTC(1940, 0, 1)),
|
||||
},
|
||||
})
|
||||
const kontakt = await db.kontakt.create({
|
||||
data: {
|
||||
betreuteId: fremde.id,
|
||||
name: `${KENNUNG} Fremder Sachbearbeiter`,
|
||||
rolle: 'SACHBEARBEITER_BEZIRK',
|
||||
},
|
||||
})
|
||||
return kontakt.id
|
||||
})
|
||||
|
||||
await anmelden(page)
|
||||
// Irgendeinen Vorgang der eigenen Akte öffnen.
|
||||
const vorgangId = await mitDb(async (db) => {
|
||||
const vorgang = await db.vorgang.findFirst({
|
||||
where: { geloeschtAm: null, betreute: { vorname: 'Erika' } },
|
||||
select: { id: true },
|
||||
})
|
||||
return vorgang?.id ?? ''
|
||||
})
|
||||
test.skip(vorgangId === '', 'Kein Vorgang in der eigenen Akte.')
|
||||
|
||||
await page.goto(`/vorgaenge/${vorgangId}`)
|
||||
const auswahl = page.locator('select[name="ansprechpartnerId"]')
|
||||
await auswahl.waitFor()
|
||||
|
||||
// Dem echten Auswahlfeld die fremde Id unterschieben und ganz normal
|
||||
// abschicken — so, wie es ein verändertes Formular täte.
|
||||
await auswahl.evaluate((feld, id) => {
|
||||
const eintrag = document.createElement('option')
|
||||
eintrag.value = id
|
||||
eintrag.textContent = 'untergeschoben'
|
||||
;(feld as HTMLSelectElement).append(eintrag)
|
||||
;(feld as HTMLSelectElement).value = id
|
||||
}, fremderKontakt)
|
||||
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
|
||||
// Der Server lehnt ab, mit einer Meldung, die den Grund nennt.
|
||||
// Grosszuegige Frist: Im Entwicklungsmodus wird die Server Action beim
|
||||
// ersten Aufruf uebersetzt, und das dauert laenger als die Vorgabe.
|
||||
await expect(page.getByText(/gehört nicht zu dieser betreuten Person/).first()).toBeVisible({
|
||||
timeout: 45_000,
|
||||
})
|
||||
|
||||
// Und vor allem: Die Verknüpfung ist nicht entstanden.
|
||||
const verknuepft = await mitDb((db) =>
|
||||
db.vorgang.count({ where: { ansprechpartnerId: fremderKontakt } }),
|
||||
)
|
||||
expect(verknuepft).toBe(0)
|
||||
})
|
||||
|
||||
test('die Auswahlliste zeigt den fremden Kontakt gar nicht erst', async ({ page }) => {
|
||||
await anmelden(page)
|
||||
await page.goto('/vorgaenge')
|
||||
|
||||
const inhalt = await page.locator('body').innerText()
|
||||
expect(inhalt).not.toContain('Fremder Sachbearbeiter')
|
||||
})
|
||||
})
|
||||
@@ -117,3 +117,46 @@ export function beispielPdf(text = 'Bescheid'): Buffer {
|
||||
|
||||
return Buffer.from(pdf, 'latin1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Doppelt gerenderte Listen — was beim Suchen schiefgeht.
|
||||
*
|
||||
* Die Dokumentenliste (und moeglicherweise kuenftig weitere) rendert
|
||||
* denselben Inhalt ZWEIMAL: als Karten fuers Handy, als Tabelle ab
|
||||
* Tablet. Eine der beiden Fassungen ist per Breakpoint versteckt, steht
|
||||
* aber im DOM.
|
||||
*
|
||||
* Die Folge im Test:
|
||||
*
|
||||
* await expect(page.getByText('Bescheid').first()).toBeVisible()
|
||||
* // -> "element is not visible", obwohl die Seite richtig aussieht
|
||||
*
|
||||
* `.first()` nimmt, was zuerst im Baum steht - und das ist die Karte,
|
||||
* also am Schreibtisch die unsichtbare Fassung. Es liegt NICHT an der
|
||||
* Anwendung.
|
||||
*
|
||||
* Nachgemessen an /dokumente mit acht Dokumenten: Ein Titel liefert 16
|
||||
* Treffer, davon 8 sichtbar - und `.first()` ist unsichtbar.
|
||||
*
|
||||
* Drei Wege, die tragen:
|
||||
*
|
||||
* 1. Ueber den Seitentext pruefen, wenn es nur um "steht das da?" geht:
|
||||
*
|
||||
* const inhalt = await page.locator('body').innerText()
|
||||
* expect(inhalt).toContain('Bescheid')
|
||||
*
|
||||
* innerText liefert nur sichtbaren Text - die versteckte Fassung
|
||||
* faellt von selbst weg. (Fuer Inhalt in einem zugeklappten
|
||||
* <details> braucht es dagegen textContent.)
|
||||
*
|
||||
* 2. Auf die sichtbare Fassung einschraenken, wenn geklickt wird:
|
||||
*
|
||||
* page.getByRole('link', { name: 'Bescheid' }).locator('visible=true')
|
||||
*
|
||||
* 3. Die Zusammenfassungszeile pruefen ("12 Dokumente"), wenn es um
|
||||
* Anzahlen geht - die steht nur einmal.
|
||||
*
|
||||
* Was nicht traegt: eine feste Anzahl erwarten. `getByText(...)` findet
|
||||
* jeden Eintrag zweimal, und wer das mit toHaveCount(1) prueft, schreibt
|
||||
* einen Test, der bei jeder Layoutaenderung umfaellt.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "betreuungs-cockpit",
|
||||
"version": "1.10.1",
|
||||
"version": "1.10.2",
|
||||
"private": true,
|
||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||
"engines": {
|
||||
|
||||
+13
-3
@@ -75,9 +75,19 @@ done
|
||||
# --- Selbstpruefung ----------------------------------------------------------
|
||||
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
|
||||
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
|
||||
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
||||
| head -c 5 | grep -q "PGDMP"; then
|
||||
# Kein "| head -c 5 | grep -q": Beide schliessen die Leitung, sobald sie
|
||||
# genug haben. openssl schreibt dann in ein geschlossenes Rohr, bekommt
|
||||
# SIGPIPE - und mit "set -o pipefail" gilt die ganze Kette als
|
||||
# gescheitert. Bei einer kleinen Datenbank faellt das nicht auf, weil
|
||||
# alles schon im Puffer steht; mit wachsender Datenbank kippt es, und
|
||||
# dann meldet die Selbstpruefung eine INTAKTE Sicherung als kaputt.
|
||||
#
|
||||
# "dd" liest eine feste Menge und beendet sich regulaer.
|
||||
KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
||||
| dd bs=1 count=5 2>/dev/null || true)"
|
||||
|
||||
if [ "$KOPF" = "PGDMP" ]; then
|
||||
echo "$(date -Iseconds) Selbstpruefung bestanden."
|
||||
else
|
||||
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
||||
|
||||
+45
-5
@@ -52,6 +52,32 @@ gut() { printf ' %s%s%s\n' "$GRUEN" "$1" "$AUS"; }
|
||||
warnung() { printf ' %s%s%s\n' "$GELB" "$1" "$AUS"; }
|
||||
abbruch() { printf '\n%sAbbruch: %s%s\n\n' "$ROT" "$1" "$AUS" >&2; exit 1; }
|
||||
|
||||
# --- Unerwarteter Abbruch ----------------------------------------------------
|
||||
#
|
||||
# "set -e" beendet das Skript bei jedem Befehl, der nicht 0 zurueckgibt -
|
||||
# stumm. Auf dem Bildschirm bricht die Ausgabe dann einfach ab, und man
|
||||
# sucht an der falschen Stelle.
|
||||
#
|
||||
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert: Eine Leitung
|
||||
# der Form "git log ... | head -15" hat den Erzeuger mit SIGPIPE beendet,
|
||||
# "pipefail" hat das an die Kette weitergereicht, und das Skript war weg.
|
||||
#
|
||||
# Diese Falle sagt jetzt wenigstens, WO es passiert ist. Exitcode 141
|
||||
# heisst SIGPIPE - dann wurde irgendwo wieder eine Leitung zu frueh
|
||||
# geschlossen.
|
||||
unerwartet() {
|
||||
code=$1
|
||||
zeile=$2
|
||||
printf '%sAbbruch in Zeile %s (Exitcode %s).%s\n' "$ROT" "$zeile" "$code" "$AUS" >&2
|
||||
if [ "$code" = "141" ]; then
|
||||
printf '%sExitcode 141 ist SIGPIPE: eine Leitung wurde zu frueh geschlossen,%s\n' "$ROT" "$AUS" >&2
|
||||
printf '%setwa durch "| head". Das ist ein Fehler im Skript, nicht bei dir.%s\n' "$ROT" "$AUS" >&2
|
||||
fi
|
||||
printf '%sKam der Abbruch vor Schritt 5, wurde nichts veraendert.%s\n' "$ROT" "$AUS" >&2
|
||||
}
|
||||
trap 'unerwartet $? $LINENO' ERR
|
||||
|
||||
|
||||
# --- Aufrufparameter ---------------------------------------------------------
|
||||
|
||||
ZIEL=""
|
||||
@@ -113,7 +139,11 @@ if ! git fetch --tags --quiet origin; then
|
||||
fi
|
||||
|
||||
JETZT="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
||||
NEUESTE="$(git tag --list 'v*' --sort=-v:refname | head -1)"
|
||||
# Kein "| head -1": Siehe den Hinweis zu SIGPIPE weiter unten. Die erste
|
||||
# Zeile holt die Shell selbst.
|
||||
ALLE_TAGS="$(git tag --list 'v*' --sort=-v:refname)"
|
||||
NEUESTE="${ALLE_TAGS%%$'
|
||||
'*}"
|
||||
|
||||
[ -n "$NEUESTE" ] || abbruch "Es gibt keine veroeffentlichte Fassung (kein Tag)."
|
||||
|
||||
@@ -123,7 +153,8 @@ fi
|
||||
|
||||
git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \
|
||||
|| abbruch "Die Fassung '$ZIEL' gibt es nicht. Vorhanden:
|
||||
$(git tag --list 'v*' --sort=-v:refname | head -5 | sed 's/^/ /')"
|
||||
$(printf '%s
|
||||
' "$ALLE_TAGS" | sed -n '1,5p' | sed 's/^/ /')"
|
||||
|
||||
printf ' Jetzt: %s\n' "$JETZT"
|
||||
printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS"
|
||||
@@ -152,14 +183,23 @@ if [ -n "$CHANGELOG" ]; then
|
||||
if (nummer == jetzt) exit
|
||||
drucken = (nummer != "Unveröffentlicht")
|
||||
}
|
||||
drucken { print " " $0 }
|
||||
' | head -60
|
||||
drucken && zeilen < 60 { print " " $0; zeilen++ }
|
||||
'
|
||||
else
|
||||
warnung "Kein CHANGELOG.md in $ZIEL gefunden."
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
git log --oneline --no-decorate "HEAD..$ZIEL" | head -15 | sed 's/^/ /'
|
||||
# "-15" statt "| head -15": Bei mehr Commits als der Grenze schliesst head
|
||||
# die Leitung, git bekommt SIGPIPE, und mit "set -o pipefail" gilt die ganze
|
||||
# Kette als gescheitert - "set -e" beendet das Skript dann OHNE Meldung.
|
||||
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert.
|
||||
ANZAHL="$(git rev-list --count "HEAD..$ZIEL")"
|
||||
git log --oneline --no-decorate -15 "HEAD..$ZIEL" | sed 's/^/ /'
|
||||
if [ "$ANZAHL" -gt 15 ]; then
|
||||
printf ' ... und %s weitere
|
||||
' "$((ANZAHL - 15))"
|
||||
fi
|
||||
|
||||
if [ "$NUR_PRUEFEN" = "1" ]; then
|
||||
printf '\n'
|
||||
|
||||
@@ -17,6 +17,21 @@ import {
|
||||
*
|
||||
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
|
||||
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
|
||||
*
|
||||
* ACHTUNG, jeder Inhalt steht ZWEIMAL im DOM: einmal als Karte, einmal
|
||||
* als Tabellenzeile. Eine der beiden Fassungen ist per Breakpoint
|
||||
* versteckt, steht aber trotzdem im Baum.
|
||||
*
|
||||
* Fuer Tests heisst das: getByText(...).first() trifft womoeglich die
|
||||
* unsichtbare Kopie, und die Pruefung scheitert mit "element is not
|
||||
* visible" an einer Stelle, die augenscheinlich in Ordnung ist. Der Weg,
|
||||
* der traegt, steht in e2e/hilfen.ts unter "Doppelt gerenderte Listen".
|
||||
*
|
||||
* Warum nicht eine Fassung fuer beide? Eine Tabelle, die sich zu Karten
|
||||
* umbricht, verlangt display:block auf Zeilen und Zellen. Damit ist es
|
||||
* keine Tabelle mehr - Vorlesesoftware verliert den Zusammenhang von
|
||||
* Spaltenkopf und Wert, und genau der macht eine Dokumentenliste mit
|
||||
* acht Spalten erst benutzbar. Die Verdopplung ist der Preis dafuer.
|
||||
*/
|
||||
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'server-only'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
|
||||
/**
|
||||
* Gehoert ein verknuepfter Datensatz zu dieser Akte?
|
||||
*
|
||||
* Wenn ein Formular einen Fremdschluessel mitschickt - Konto, Vertrag,
|
||||
* Immobilie, Dokument, Vorgang, Kontakt -, dann kommt der aus einer
|
||||
* Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen:
|
||||
* Eine veraenderte Anfrage kann jede beliebige Id enthalten.
|
||||
*
|
||||
* Ohne die Pruefung entstuende eine Verknuepfung ueber Aktengrenzen
|
||||
* hinweg. Was daran gefaehrlich ist, sieht man erst an den Folgen: Der
|
||||
* Rueckkaufswert der einen Akte haengt am Vertrag der anderen, ein
|
||||
* fremder Name steht im Briefkopf, ein fremder Beschluss in der
|
||||
* Genehmigungskette.
|
||||
*
|
||||
* Solange nur eine betreute Person angelegt ist, ist all das folgenlos.
|
||||
* Die Pruefung gehoert trotzdem hin - sie kostet eine Abfrage, und die
|
||||
* zweite betreute Person kommt frueher, als man denkt.
|
||||
*
|
||||
* Bewusst KEINE Ausnahme geworfen: Formularaktionen geben Fehler zurueck,
|
||||
* damit die Eingaben des Benutzers nicht verlorengehen.
|
||||
*/
|
||||
|
||||
/** Die Tabellen, auf die ein Formular verweisen darf. */
|
||||
export type Verweisziel =
|
||||
| 'konto'
|
||||
| 'vertrag'
|
||||
| 'immobilie'
|
||||
| 'dokument'
|
||||
| 'vorgang'
|
||||
| 'kontakt'
|
||||
| 'vermoegensposten'
|
||||
| 'buchung'
|
||||
|
||||
type Sucher = (id: string, betreuteId: string) => Promise<{ id: string } | null>
|
||||
|
||||
/**
|
||||
* Je Ziel eine Abfrage, ausgeschrieben statt dynamisch zusammengesetzt.
|
||||
*
|
||||
* Das ist ein paar Zeilen laenger als ein Zugriff ueber den Tabellennamen -
|
||||
* dafuer ist die Liste der erlaubten Ziele eine bewusste Aufzaehlung und
|
||||
* keine Nebenwirkung, und der Typpruefer sieht jede Abfrage.
|
||||
*/
|
||||
const SUCHER: Record<Verweisziel, Sucher> = {
|
||||
konto: (id, betreuteId) =>
|
||||
db.konto.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
vertrag: (id, betreuteId) =>
|
||||
db.vertrag.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
immobilie: (id, betreuteId) =>
|
||||
db.immobilie.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
dokument: (id, betreuteId) =>
|
||||
db.dokument.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
vorgang: (id, betreuteId) =>
|
||||
db.vorgang.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
kontakt: (id, betreuteId) =>
|
||||
db.kontakt.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
vermoegensposten: (id, betreuteId) =>
|
||||
db.vermoegensposten.findFirst({
|
||||
where: { id, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
}),
|
||||
buchung: (id, betreuteId) =>
|
||||
db.buchung.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
|
||||
}
|
||||
|
||||
const BESCHRIFTUNG: Record<Verweisziel, string> = {
|
||||
konto: 'Dieses Konto',
|
||||
vertrag: 'Dieser Vertrag',
|
||||
immobilie: 'Diese Immobilie',
|
||||
dokument: 'Dieses Dokument',
|
||||
vorgang: 'Dieser Vorgang',
|
||||
kontakt: 'Dieser Kontakt',
|
||||
vermoegensposten: 'Dieser Vermögensposten',
|
||||
buchung: 'Diese Buchung',
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft einen einzelnen Verweis.
|
||||
*
|
||||
* Ein leerer Verweis ist in Ordnung - "keine Verknuepfung" ist ein
|
||||
* gueltiger Zustand und keine Verletzung.
|
||||
*
|
||||
* @returns null, wenn alles stimmt; sonst die fertige Fehlermeldung.
|
||||
*/
|
||||
export async function verweisPruefen(
|
||||
ziel: Verweisziel,
|
||||
id: string | null | undefined,
|
||||
betreuteId: string,
|
||||
): Promise<string | null> {
|
||||
if (!id) return null
|
||||
|
||||
const treffer = await SUCHER[ziel](id, betreuteId)
|
||||
|
||||
return treffer ? null : `${BESCHRIFTUNG[ziel]} gehört nicht zu dieser betreuten Person.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft mehrere Verweise auf einmal und meldet den ersten Verstoss.
|
||||
*
|
||||
* Die Abfragen laufen nebeneinander: Bei vier Verweisen ist das eine
|
||||
* Wartezeit statt vier.
|
||||
*
|
||||
* @example
|
||||
* const fehler = await verweisePruefen(betreuteId, {
|
||||
* konto: werte.kontoId,
|
||||
* vertrag: werte.vertragId,
|
||||
* })
|
||||
* if (fehler) return { fehler }
|
||||
*/
|
||||
export async function verweisePruefen(
|
||||
betreuteId: string,
|
||||
verweise: Partial<Record<Verweisziel, string | null | undefined>>,
|
||||
): Promise<string | null> {
|
||||
const eintraege = Object.entries(verweise) as [Verweisziel, string | null | undefined][]
|
||||
const ergebnisse = await Promise.all(
|
||||
eintraege.map(([ziel, id]) => verweisPruefen(ziel, id, betreuteId)),
|
||||
)
|
||||
return ergebnisse.find((meldung) => meldung !== null) ?? null
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
/** Muss zur "version" in der package.json passen. */
|
||||
export const VERSION = '1.10.1'
|
||||
export const VERSION = '1.10.2'
|
||||
|
||||
/**
|
||||
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { verweisePruefen } from '@/lib/auth/verweise'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||
import { sealen } from '@/lib/crypto/feld'
|
||||
@@ -325,29 +326,15 @@ export async function buchungSpeichern(
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
if (werte.kontoId) {
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
|
||||
// Wie beim Konto: Der Vertrag muss zu dieser Akte gehoeren.
|
||||
if (werte.vertragId) {
|
||||
const vertrag = await db.vertrag.findFirst({
|
||||
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
if (werte.belegId) {
|
||||
const beleg = await db.dokument.findFirst({
|
||||
where: { id: werte.belegId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!beleg) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
// Alle vier Verweise auf einmal - der Vorgang war bisher der einzige,
|
||||
// der ungeprueft durchging.
|
||||
const verweisfehler = await verweisePruefen(betreuteId, {
|
||||
konto: werte.kontoId,
|
||||
vertrag: werte.vertragId,
|
||||
dokument: werte.belegId,
|
||||
vorgang: werte.vorgangId,
|
||||
})
|
||||
if (verweisfehler) return { fehler: verweisfehler }
|
||||
|
||||
const felder = {
|
||||
kontoId: werte.kontoId,
|
||||
@@ -635,34 +622,14 @@ export async function vermoegenspostenSpeichern(
|
||||
}
|
||||
const werte = gepruft.data
|
||||
|
||||
if (werte.kontoId) {
|
||||
const konto = await db.konto.findFirst({
|
||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
|
||||
// Dieselbe Pruefung fuer die beiden anderen Verweise. Sie fehlte:
|
||||
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert, und
|
||||
// die darf nicht in eine fremde Akte zeigen.
|
||||
if (werte.vertragId) {
|
||||
const vertrag = await db.vertrag.findFirst({
|
||||
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
|
||||
if (werte.immobilieId) {
|
||||
const immobilie = await db.immobilie.findFirst({
|
||||
where: { id: werte.immobilieId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!immobilie) {
|
||||
return { fehler: 'Diese Immobilie gehört nicht zu dieser betreuten Person.' }
|
||||
}
|
||||
}
|
||||
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert - sie
|
||||
// darf nicht in eine fremde Akte zeigen.
|
||||
const verweisfehler = await verweisePruefen(betreuteId, {
|
||||
konto: werte.kontoId,
|
||||
vertrag: werte.vertragId,
|
||||
immobilie: werte.immobilieId,
|
||||
})
|
||||
if (verweisfehler) return { fehler: verweisfehler }
|
||||
|
||||
const felder = {
|
||||
bezeichnung: werte.bezeichnung,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Prisma } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang } from '@/lib/auth/sitzung'
|
||||
import { aenderungErmitteln, protokollieren, type Aenderung } from '@/lib/audit'
|
||||
import { verweisePruefen } from '@/lib/auth/verweise'
|
||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||
import {
|
||||
AufgabeAbhakenSchema,
|
||||
@@ -69,34 +70,6 @@ async function kontaktnameZu(kontaktId: string | null): Promise<string | null> {
|
||||
return [kontakt.name, kontakt.organisation].filter(Boolean).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehoert der Ansprechpartner zu dieser Akte?
|
||||
*
|
||||
* Ohne die Pruefung koennte ein veraendertes Formular einen Kontakt aus
|
||||
* einer fremden Akte an den Vorgang haengen - und sein Name stuende
|
||||
* anschliessend im Briefkopf.
|
||||
*/
|
||||
async function ansprechpartnerPruefen(
|
||||
betreuteId: string,
|
||||
ansprechpartnerId: string | null,
|
||||
): Promise<{ ok: true } | { ok: false; zustand: Formularzustand }> {
|
||||
if (!ansprechpartnerId) return { ok: true }
|
||||
|
||||
const kontakt = await db.kontakt.findFirst({
|
||||
where: { id: ansprechpartnerId, betreuteId, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (kontakt) return { ok: true }
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
zustand: {
|
||||
fehler: 'Diesen Kontakt gibt es nicht (mehr).',
|
||||
felder: { ansprechpartnerId: 'Bitte einen Kontakt aus dem Verzeichnis wählen.' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorgangAnlegen(
|
||||
_vorher: Formularzustand,
|
||||
formular: FormData,
|
||||
@@ -119,8 +92,13 @@ export async function vorgangAnlegen(
|
||||
|
||||
const eingabe = gelesen.data
|
||||
|
||||
const geprueft = await ansprechpartnerPruefen(betreuteId, eingabe.ansprechpartnerId)
|
||||
if (!geprueft.ok) return geprueft.zustand
|
||||
// Sein Name stuende sonst im Briefkopf - siehe lib/auth/verweise.ts.
|
||||
const verweisfehler = await verweisePruefen(betreuteId, {
|
||||
kontakt: eingabe.ansprechpartnerId,
|
||||
})
|
||||
if (verweisfehler) {
|
||||
return { fehler: verweisfehler, felder: { ansprechpartnerId: verweisfehler } }
|
||||
}
|
||||
|
||||
const jetzt = new Date()
|
||||
|
||||
@@ -250,8 +228,12 @@ export async function vorgangAendern(
|
||||
|
||||
const eingabe = gelesen.data
|
||||
|
||||
const geprueft = await ansprechpartnerPruefen(vorgang.betreuteId, eingabe.ansprechpartnerId)
|
||||
if (!geprueft.ok) return geprueft.zustand
|
||||
const verweisfehler = await verweisePruefen(vorgang.betreuteId, {
|
||||
kontakt: eingabe.ansprechpartnerId,
|
||||
})
|
||||
if (verweisfehler) {
|
||||
return { fehler: verweisfehler, felder: { ansprechpartnerId: verweisfehler } }
|
||||
}
|
||||
|
||||
const vorherOffen = oeffnen('Vorgang', vorgang)
|
||||
|
||||
|
||||
@@ -85,3 +85,50 @@ describe.skipIf(!mitGit)('Betriebsskripte', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Abschneidende Leitungen in Skripten mit "pipefail".
|
||||
*
|
||||
* Anlass ist ein Abbruch im Betrieb: Beim Sprung von v1.5.0 auf v1.10.1
|
||||
* endete update.sh mitten in der Ausgabe, ohne jede Meldung. Ursache war
|
||||
*
|
||||
* git log --oneline "HEAD..$ZIEL" | head -15 | sed ...
|
||||
*
|
||||
* "head" schliesst die Leitung nach 15 Zeilen, "git log" schreibt weiter
|
||||
* und bekommt SIGPIPE (Exitcode 141). Mit "set -o pipefail" gilt die
|
||||
* ganze Kette als gescheitert, "set -e" beendet das Skript - stumm.
|
||||
*
|
||||
* Es ist zeitabhaengig: Solange der Erzeuger fertig wird, bevor "head"
|
||||
* zumacht, faellt nichts auf. Deshalb lief es monatelang gut und riss
|
||||
* genau dann, als zum ersten Mal mehr als 15 Commits dazwischenlagen.
|
||||
*
|
||||
* Dieselbe Falle steckte in der Selbstpruefung der Sicherung, wo sie
|
||||
* eine intakte Sicherung als kaputt gemeldet haette, sobald die
|
||||
* Datenbank gross genug ist.
|
||||
*
|
||||
* Der Test verbietet das Muster deshalb ganz. Wer eine Ausgabe kuerzen
|
||||
* will, nimmt "git log -15", "sed -n '1,15p'" oder einen Zaehler in awk -
|
||||
* alles drei liest bis zum Ende und schliesst nichts zu frueh.
|
||||
*/
|
||||
describe('Skripte mit pipefail', () => {
|
||||
const skripte = ['scripts/update.sh', 'scripts/sicherung.sh', 'scripts/wiederherstellung.sh']
|
||||
|
||||
it.each(skripte)('%s schneidet keine Leitung mit head ab', (pfad) => {
|
||||
const inhalt = readFileSync(pfad, 'utf8')
|
||||
if (!/set -[a-z]*o pipefail|set -o pipefail/.test(inhalt)) return
|
||||
|
||||
const treffer = inhalt
|
||||
.split('\n')
|
||||
.map((zeile, nummer) => ({ zeile, nummer: nummer + 1 }))
|
||||
// Kommentarzeilen erklaeren das Muster gerade - sie sind erlaubt.
|
||||
.filter(({ zeile }) => !zeile.trim().startsWith('#'))
|
||||
.filter(({ zeile }) => !zeile.includes('printf'))
|
||||
.filter(({ zeile }) => /\|\s*head\b/.test(zeile))
|
||||
|
||||
expect(
|
||||
treffer.map((t) => `Zeile ${t.nummer}: ${t.zeile.trim()}`),
|
||||
'Abschneidende Leitung: der Erzeuger bekommt SIGPIPE, pipefail reicht ' +
|
||||
'es weiter, set -e beendet das Skript stumm.',
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user