Sowohl in der Patientenakte als auch auf der oeffentlichen Notfall-Card- Seite ist jetzt ersichtlich, wann die Notfalldaten (Blutgruppe, Allergien, Medikamente, Notfallkontakt) oder eine neue Patientenverfuegung zuletzt geaendert wurden. Neues Feld Patient.notfallkarte_daten_aktualisiert_am, wird nur bei echter inhaltlicher Aenderung gesetzt, nicht bei jedem Speichern des Bearbeiten-Formulars. Changelog-Eintrag 1.18.5. Ausserdem: neuer CLAUDE.md-Abschnitt zum kuenftigen PR-Workflow ueber Gitea (dieser Commit ist der erste PR unter dem neuen Schema). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
234 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Naming: pflegepilot_v3's product is now branded "MeinPflegeFuchs" (renamed 2026-07-24)
The pflegepilot_v3 product was renamed from "Pflegepilot" to "MeinPflegeFuchs" (domain: meinpflegefuchs.de, ordered via Strato) — there turned out to be several unrelated existing apps/services already using "Pflegepilot" or very close variants, discovered when checking candidate replacement names for trademark/confusion risk (a lesson worth repeating: the German Pflege-app naming space is extremely crowded — a "Pflege" + descriptive word pattern is very likely to already be taken by a real competing service; "MeinPflegeFuchs" was chosen specifically because it's a mascot-style name rather than a literal descriptor).
- Every user-visible string, generated-document footer, email subject, page title/metadata, and the two legal texts under
rechtstexte/were updated to "MeinPflegeFuchs" — see thechangelog_seed.pyentry (version 1.1.0) for the user-facing announcement of the rename. - The
pflegepilot_v3directory name, package/module names, DB names, and NAS deployment path (/Dockers/pflege) were deliberately left unchanged — renaming those would touch git history, every absolute path referenced throughout this file and past deployment scripts, and the NAS's manually-maintained (non-git) copy, for zero user-facing benefit. Don't be confused that the folder still says "pflegepilot" while the product itself is called MeinPflegeFuchs — that's intentional, not a leftover. - Only scope was
pflegepilot_v3—pflegepilot_alpha_v2(the old, separately-running Flask app) still legitimately calls itself Pflegepilot and was not touched. - Historical references to "Pflegepilot" — e.g. the changelog's 1.0.0 entry, or anything below in this file describing what was true at the time it was written — are accurate records of the past and were deliberately left as-is rather than rewritten; only forward-looking/current-state documentation and all actual product code got renamed. When adding new documentation to this file, use "MeinPflegeFuchs", not "Pflegepilot".
einstellungen.firmenname(sidebar branding / PDF-footer company name) is a separate, operator-configured field for the legal entity name (currently "Daniel Barac Onlinedienste" in production) — unrelated to the product brand name and intentionally not touched by this rename.
Git-Workflow: Pull Requests über Gitea statt Direkt-Push auf master (ab 2026-08-10)
Ab sofort läuft alle Arbeit an diesem Repo über den Remote origin (ssh://git@192.168.1.222:2222/admin/pflegefuchs.git, self-hosted Gitea auf dem NAS, Web-UI unter http://192.168.1.222:3666) — Betreiber-Wunsch, um bei mehreren gleichzeitigen Änderungen die Übersicht zu behalten, statt jede Änderung nur in diesem CLAUDE.md-Dokument und einzelnen git log-Commits nachzuvollziehen.
- Kein Direkt-Push auf
mastermehr durch Claude. Jede abgeschlossene, in sich sinnvolle Änderung (ein Feature, ein Bugfix, ein Tester-Feedback-Punkt ausFeedbackfromtester.md) bekommt einen eigenen Branch, wird dorthin committet/gepusht und als Pull Request gegenmastergeöffnet — ein PR pro Feature/Feedback-Punkt, nicht ein Sammel-PR pro Konversation/Session. - Branch-Namensschema:
feature/<kurzbeschreibung>für neue Funktionen,fix/<kurzbeschreibung>für Bugfixes, jeweils kurze englische oder deutsche Kebab-Case-Beschreibung (z. B.feature/notfallkarte-zeitstempel,fix/pdf-upload-passwortgeschuetzt). - PR wird nicht automatisch gemerged — der Betreiber merged jeden PR manuell nach eigener Durchsicht über die Gitea-Weboberfläche. Das ist die eigentliche Absicht dieses Workflows (Review-Gate + Übersicht), nicht nur eine Formalität — Claude darf einen von sich selbst geöffneten PR nicht selbst mergen, auch nicht auf Nachfrage "mach doch gleich fertig", ohne das explizit noch einmal zu bestätigen.
- Werkzeug:
tea(offizielle Gitea-CLI, bereits unteradmin-Login auf diesem Rechner eingerichtet,tea login listzeigt den Eintrag) — PRs werden pertea pr creategeöffnet, nicht per Klick im Web-UI.gh(GitHub CLI) ist ebenfalls installiert, aber nicht für dieses Repo relevant, da hier kein GitHub im Spiel ist. - PR-Titel/-Beschreibung: Titel entspricht dem, was sonst die Commit-Message-Subject-Zeile gewesen wäre (deutsch, gleicher Stil wie die bisherigen Commit-Messages/Changelog-Einträge); Beschreibung fasst die Änderung stichpunktartig zusammen, mit Verweis auf den zugehörigen Tester-Feedback-Punkt (T-Nummer) falls zutreffend und auf den neuen
changelog_seed.py-Versionseintrag, falls einer dazugehört. - Gilt für alle Änderungen an diesem Repo, nicht nur
pflegepilot_v3— auch Änderungen anpflegepilot_alpha_v2oder an diesemCLAUDE.mdselbst laufen künftig über einen PR, keine Ausnahme für "nur Dokumentation" oder "nur ein Einzeiler". - Dieser Workflow-Abschnitt selbst wurde als erster PR unter dem neuen Schema eingeführt (zusammen mit dem bereits fertigen Notfallkarte-Zeitstempel-Feature, siehe unten) — als lebendes Beispiel, nicht nur als Ankündigung.
Notfall-Card-Bereich im Bearbeiten-Formular hervorgehoben (pflegepilot_v3 only, 2026-08-08)
Betreiber-Feedback: die Notfall-Card-Felder (Blutgruppe, Allergien, Medikamente, Notfallkontakt) im "Patient bearbeiten"-Formular waren nur durch eine kleine graue Zwischenüberschrift von den übrigen Abschnitten (Persönliche Daten, Adresse, Krankenversicherung, Pflegesituation) abgesetzt — optisch identisch zu jedem anderen Abschnittstitel im selben durchlaufenden weißen Formular, dadurch leicht zu übersehen.
- Der komplette Notfall-Card-Block ist jetzt eine eigene, farblich abgesetzte Box (
getAccent("red"), dieselbe Akzentfarbe wie die "Notfall-Card"-Sektion in der Patientenakte selbst — bewusste Konsistenz, nicht neu erfunden) mit eigenem Rand, Verlaufshintergrund und einem erklärenden Untertitel ("Optional – nur sichtbar für Personen mit dem Notfall-Link/QR-Code … unabhängig vom Rest dieses Formulars"). - Kein neues Farbschema eingeführt, nur der bereits bestehende
AccentClasses-Mechanismus auslib/theme.tswiederverwendet. - Verifiziert per echtem Browser-Screenshot gegen
https://meinpflegefuchs.de/patienten/7/bearbeiten.
Fortschrittsanzeige beim PDF-Upload/OCR (pflegepilot_v3 only, 2026-08-08)
Betreiber-Feedback: weder der normale Datei-Upload noch der Kamera-Scan zeigten irgendein sichtbares Zeichen, dass im Hintergrund gearbeitet wird (Upload + Texterkennung), solange die Anfrage lief — ein Laie könnte deshalb versehentlich mehrfach dieselbe Datei hochladen, weil nicht ersichtlich ist, dass überhaupt etwas passiert.
hochladen.isPending(React Query, bislang ungenutzt für UI-Feedback) steuert jetzt: Datei-Input wird deaktiviert (disabled, verhindert die Doppel-Upload-Sorge strukturell, nicht nur kosmetisch), ein Hinweistext ("Wird hochgeladen und nach durchsuchbarem Text durchsucht – bitte kurz warten, nicht erneut hochladen…") sowie ein unbestimmter Fortschrittsbalken erscheinen. Gleiches Muster für den Kamera-Scan (DokumentScan.tsx,hochladenLaeuft-Prop) ergänzt — dort fehlte bislang auch der "+ weitere Seite"-Button eindisabledwährend des Uploads.- Bewusst kein echter Prozentwert: ein tatsächlicher Upload-Fortschritt (Bytes gesendet) wäre technisch möglich (
XMLHttpRequeststattfetch), aber irreführend — bei einer PDF ist der Byte-Upload in Sekundenbruchteilen fertig, während die serverseitige OCR-Verarbeitung (die eigentliche Wartezeit) noch mehrere Sekunden bis über eine Minute dauern kann (bei einem 15-seitigen Testdokument ohne Textebene ca. 25s). Ein Balken, der bei 100% "fertig" aussieht, während im Hintergrund noch OCR läuft, hätte das ursprüngliche Problem eher verschlimmert als gelöst. Stattdessen ein unbestimmter ("indeterminate") Balken mit durchlaufendem Segment (@keyframes fortschritt-durchlaufinglobals.css) — ehrlich darüber, dass keine Prozentangabe messbar ist, aber klar sichtbar, dass gearbeitet wird. - Farbe grau+grün statt grau-in-grau (Nachbesserung noch in derselben Sitzung nach Betreiber-Feedback: auf dem gelben "Dokumente"-Kartenhintergrund war ein reines Grau-in-Grau kaum erkennbar): grauer Balken-Grund (
bg-slate-300), grünes durchlaufendes Segment (bg-emerald-500). - Verifiziert per echtem Browser-Test gegen
https://meinpflegefuchs.de(Claude-in-Chrome-Skill, echter eingeloggter Nutzer-Account): ein 15-seitiges synthetisches Test-PDF ohne Textebene (erzwingt den OCR-Fallback auf jeder Seite) hochgeladen — deaktivierte Datei-Eingabe und der grün-graue Balken blieben sichtbar über die gesamte, spürbar lange Verarbeitungszeit (~25s), Eingabe wurde danach automatisch wieder freigegeben. - Zwischenfall während der Verifikation: beim manuellen Aufräumen der Test-Uploads über die UI wurde durch einen Koordinaten-Klick auf eine zwischenzeitlich verschobene Listenposition versehentlich eine echte, bereits vorhandene Nutzerdatei (
Gemini_Generated_Image_....png) gelöscht statt einer eigenen Testdatei — nicht wiederherstellbar, da harte Löschung ohne Papierkorb. Dem Nutzer sofort transparent gemeldet. Lehre für künftige UI-Tests dieser Art: bei listenbasierten Lösch-Aktionen in einer sich dynamisch verändernden Liste (Umsortierung durch parallele Uploads) ausschließlich überfind/Element-Referenzen klicken, niemals über zwischengespeicherte Pixel-Koordinaten aus einem älteren Screenshot — und bei Unsicherheit über den tatsächlichen Zustand direkt die Datenbank abfragen statt der Bildschirmanzeige zu vertrauen. - Changelog-Eintrag
1.18.3.
Sidebar sticky statt im Dokumentenfluss (pflegepilot_v3 only, 2026-08-08)
Betreiber-Feedback: auf langen Seiten musste man bis ganz nach unten scrollen, um "Abmelden" oder den eigenen Kontonamen in der Sidebar zu sehen. Ursache: das <aside> in (app)/layout.tsx war auf Desktop-Breite (lg:) nur lg:static — ein normales Element im Dokumentenfluss, kein position:fixed/sticky. Da es als Flex-Geschwister neben der Hauptinhaltsspalte steht (<div className="flex min-h-screen">), streckt das Standard-Flexbox-Verhalten (align-items:stretch) die Sidebar auf die Höhe des Hauptinhalts — bei einer langen Seite (z. B. eine umfangreiche Patientenakte) wurde die Sidebar dadurch künstlich sehr hoch, und da nav darin flex-1 ist, rutschten der Konto-/Abmelden-Block und die Rechtstext-Links ganz ans Ende dieser künstlich gestreckten Höhe — nur durch Scrollen der gesamten Seite erreichbar, nicht nur der Sidebar selbst.
- Fix:
lg:static→lg:sticky lg:top-0 lg:h-screen— die Sidebar bekommt jetzt auf Desktop-Breite eine feste, an den Viewport gebundene Höhe und bleibt beim Scrollen des Hauptinhalts an ihrer Position stehen, unabhängig davon, wie lang die jeweilige Seite ist. Der bereits bestehendeoverflow-y-autoauf demnav-Element bleibt die Absicherung für den (aktuell nicht erreichten) Fall, dass allein die Navigationspunkte je die Viewport-Höhe überschreiten. - Mobile/Tablet-Verhalten (
fixed inset-y-0unterhalb vonlg:) war bereits vorher schon viewport-fixiert und ist von dieser Änderung nicht betroffen. - Verifiziert per echtem Browser-Test (Claude-in-Chrome-Skill) direkt gegen
https://meinpflegefuchs.de/admin(eingeloggte Session des Betreibers) — Screenshot vor und nach starkem Scrollen im Hauptinhalt bestätigt: Sidebar-Kopf, Navigation und der "Daniel"/"Abmelden"-Block bleiben pixelgenau an derselben Position, während der Hauptinhalt darunter durchscrollt. Lokale Browser-Verifikation war hierfür nicht möglich — die lokale.envhatNEXT_PUBLIC_API_URLbekanntermaßen auf die echte NAS-LAN-IP stattlocalhostgesetzt (siehe Memory-Eintrag zu dieser Falle), wodurch der lokal gebaute Frontend-Container beim Testen im Browser API-Calls an die NAS statt an sich selbst schickt und auf "Lädt…" hängen bleibt — kein neuer Bug, sondern dieselbe bereits dokumentierte Umgebungs-Falle, nur diesmal zum ersten Mal in einer echten Browser-Session statt nur per curl bemerkt. - Changelog-Eintrag
1.18.1.
T30-Nachtrag: Volltextsuche auch für hochgeladene PDFs + Admin-Nachverarbeitung (pflegepilot_v3 only, 2026-08-08)
Betreiber-Feedback zum ursprünglichen T30 (siehe unten): die durchsuchbaren PDFs/Volltextsuche deckten bislang nur per Kamera gescannte Dokumente ab — ganz normal hochgeladene PDFs (z. B. ein per E-Mail erhaltener oder am PC erstellter Bescheid) blieben unauffindbar, obwohl das bereits bestehende Suchfeld in der Patientenakte ("Dokumente durchsuchen…") laut Beschreibung genau das können sollte. Zwei Erweiterungen, per AskUserQuestion vorab geklärt (rückwirkende Nachverarbeitung ja/nein — Betreiber wählte "auch rückwirkend").
- Kein neues Suchfeld nötig: die bereits bestehende clientseitige Such-Eingabe in der Patientenakte (
patienten/[id]/page.tsx, seit T30) filtert bereits aufbezeichnungundocr_text— sie fand bei hochgeladenen PDFs nur deshalb nichts, weilocr_textdort bislang immernullblieb. Mit dieser Erweiterung funktioniert dasselbe Suchfeld jetzt auch für PDFs, ohne dass am Frontend etwas Neues gebaut werden musste — nur der Platzhaltertext wurde ergänzt ("…und hochgeladener PDFs"). app/dokument_scan.py: pdf_text_extrahieren(): neue, zweistufige Funktion je PDF-Seite. Liest zuerst per PyMuPDF (fitz) eine ggf. bereits vorhandene Textebene aus (page.get_text()) — verlustfrei und um Größenordnungen schneller als OCR, der häufigste Fall bei digital erzeugten PDFs. Nur wenn eine Seite praktisch keinen Text liefert (< 20 Zeichen,MIN_TEXTLAENGE_JE_SEITE, typischerweise eine eingescannte Seite ohne eigene Textebene), wird nur diese Seite zusätzlich perpage.get_pixmap()gerendert und per Tesseract-OCR verarbeitet — ein gemischtes PDF (z. B. eine Textseite plus eine eingescannte Beilage) bekommt so je Seite die passende Methode. Gleiche Kulanz-Haltung wie beim bestehenden Kamera-Scan-OCR: ein Fehler auf einzelnen Seiten überspringt nur diese Seite, ein komplett unlesbares PDF liefertNonestatt den Upload/die Nachverarbeitung scheitern zu lassen.- Neue Abhängigkeit
PyMuPDF(requirements.txt, importiert alsimport pymupdf as fitz— der ältereimport fitz-Name ist in aktuellen PyMuPDF-Versionen deprecated). Bewusste Abweichung von der ursprünglichen T30-Entscheidung "pypdf statt PyMuPDF/fitz" (siehe dortiger Kommentar) — die galt für reines Seiten-Zusammenfügen beim Kamera-Scan, wofür pypdf ausreicht; hier wird dagegen tatsächlich eine PDF-Seite als Bild gerendert (OCR-Fallback), das kann pypdf nicht. PyMuPDF ist dafür die schlankere Wahl gegenüber z. B.pdf2image+poppler-utils (kein zusätzliches System-Paket im Dockerfile nötig, reine Wheel-Installation). routers/dokumente.py: hochladen()(der normale Einzeldatei-Upload-Endpunkt, nicht der Kamera-Scan-Endpunkt) ruftpdf_text_extrahieren()jetzt automatisch auf, wenn die Dateiendung.pdfist, und speichert das Ergebnis inDokument.ocr_text— dieselbe Spalte wie beim Kamera-Scan, kein neues Schema-Feld nötig. Gilt nur für neue Uploads ab diesem Deploy.- Rückwirkende Nachverarbeitung: neuer Endpunkt
POST /api/admin/dokumente/pdf-ocr-nachtrag(admin-only, bewusst ohne Begründungspflicht/Audit-Log-Eintrag — reine additive Anreicherung bereits zugänglicher Daten, kein Datenexport/-eingriff mit Beweiswert, gleiche Einordnung wie die Test-Mail-Funktion) verarbeitet alle bereits bestehendenDokument-Zeilen mitdateiname LIKE '%.pdf'undocr_text IS NULLüber alle Konten hinweg in einem Rutsch (Dokumentenmenge dieser App bleibt insgesamt klein) und liefert eine Zusammenfassung (gefunden/aktualisiert/ohne_erkennbaren_text/fehlgeschlagen/datei_fehlt) zurück. Frontend: neue Karte "PDF-Volltext nachtragen" im Admin-Dashboard (Akzentfarbelime, bis dahin ungenutzt) mit einem einzelnen Button, kein Formular nötig. - End-to-end gegen die echte lokale Docker-Postgres-Instanz verifiziert: drei Testfälle direkt gegen
pdf_text_extrahieren()(ein synthetisches Text-PDF → korrekt perget_text()erkannt ohne OCR, ein synthetisches Bild-only-PDF → korrekt per OCR-Fallback erkannt, ein kaputtes PDF → sauberNonestatt Crash), danach der komplette HTTP-Weg: echter Upload eines Text-PDFs überPOST /api/patienten/{pid}/dokumenteliefertocr_textsofort in der Response,GET .../dokumente?suche=Aktenzeichen(ein Wort, das nur im OCR-Text steht, nicht in der Bezeichnung) findet das Dokument korrekt, ein Kontroll-Upload einer.txt-Datei bestätigtocr_text=null(keine Regression für Nicht-PDF-Typen). Der Nachtrags-Endpunkt wurde mit einem künstlich aufNULLzurückgesetztenocr_textgetestet (korrekt 1 gefunden/1 aktualisiert) sowie auf Idempotenz geprüft (zweiter Aufruf: 0 gefunden).docker compose build webfehlerfrei (Next.js-Routenmanifest zeigt/adminweiterhin als erwartete Route). Testdaten (2 Test-Dokumente, 1 Test-Admin-Konto) danach wieder vollständig entfernt. - Changelog-Eintrag
1.18.0ergänzt, vorab gegen den lokalenGET /api/changelog-Stand abgeglichen (neuester Eintrag war1.17.0), keine Kollision.
Bug nach dem Deploy gefunden und behoben: bestimmte PDFs ließen sich gar nicht mehr hochladen (2026-08-08)
Betreiber-Meldung noch am selben Tag: unter "Dokumente → Datei hochladen" ließ sich mit einem Test-Account keine PDF hochladen (Bilder funktionierten weiterhin). Root Cause: pdf_text_extrahieren() wrappte nur fitz.open() selbst gegen Fehler ab, nicht aber seite.get_text() — bei PDFs, die zwar ohne Fehler geöffnet werden können, deren Seiteninhalt aber ohne Passwort nicht lesbar ist (needs_pass=1, z. B. ein mit Owner-Passwort geschütztes PDF, wie es manche Kassen-/Behörden-PDF-Systeme erzeugen), warf get_text() beim ersten Seitenzugriff einen ValueError: document closed or encrypted. Da dieser Aufruf in routers/dokumente.py: hochladen() nach dem Schreiben der Datei auf die Platte, aber vor dem Anlegen der Dokument-DB-Zeile passierte, brach der komplette Request mit einem 500er ab, bevor die DB-Zeile erstellt wurde — die Datei lag zwar auf der Platte, tauchte aber nirgends in der App auf.
- Reproduziert: ein mit
PDF_ENCRYPT_AES_256und einem echten User-Passwort erzeugtes Test-PDF (nicht entschlüsselt geöffnet) löste exakt den beschriebenenValueErroraus — bestätigt vor dem Fix als echter Crash, nach dem Fix als sauberesNone. - Fix, zweifach abgesichert (gleiches Muster wie der bereits bestehende OCR-Fallback in derselben Funktion):
seite.get_text()bekam ein eigenestry/except, zusätzlich fängt ein äußeresexceptum die gesamte Seiten-Schleife auch andere, nicht seitenspezifische Fehler ab (liefert dann den bis dahin erkannten Text statt komplett zu scheitern). Als zweite Sicherheitsebene wurde der Aufruf inrouters/dokumente.py: hochladen()selbst ebenfalls in eintry/exceptgepackt — der Upload darf unter keinen Umständen an der Texterkennung scheitern, auch nicht durch einen künftigen, heute noch unbekannten Fehlerfall inpdf_text_extrahieren(). - Keine verwaisten Dateien gefunden: ein Abgleich aller Dateien auf der Platte gegen die
Dokument-Tabelle (NAS) ergab keine Datei ohne zugehörige DB-Zeile — der genaue vom Betreiber getestete Fall bleibt daher nicht zweifelsfrei rekonstruiert (das reproduzierte Passwort-Szenario ist der plausibelste Kandidat für dieselbe Fehlerklasse, aber nicht mit Sicherheit exakt derselbe Auslöser). - Verifiziert per echtem HTTP-Upload des Passwort-PDFs gegen die lokale Docker-Instanz (201 statt 500,
ocr_text=null) sowie eines normalen Text-PDFs als Regressionstest (weiterhin korrekt erkannter Text). Changelog-Eintrag1.18.2.
Notfall-Card: "Zuletzt aktualisiert am"-Zeitstempel (pflegepilot_v3 only, 2026-08-08)
Betreiber-Feedback: weder intern (Patientenakte) noch extern (öffentliche Notfall-Card-Seite) war erkennbar, wann die angezeigten Notfalldaten zuletzt eingegeben/geändert wurden.
- Neue Spalte
Patient.notfallkarte_daten_aktualisiert_am(Migratione2f5c8a1b4d7) — bewusst ein eigenes Feld stattPatient.geaendert_amwiederzuverwenden, das bei jeder Patientenänderung (Adresse, Telefonnummer, …) mitläuft und damit fälschlich häufiger "aktualisiert" wirken würde, als es für die Notfalldaten selbst zutrifft. - Zwei Stellen setzen den Zeitstempel, beide nur bei einer echten inhaltlichen Änderung:
PUT /api/patienten/{pid}(routers/patienten.py): vergleicht bei jedem Speichern die 5 Notfall-Felder (blutgruppe,allergien,notfallkarte_medikamente,notfallkontakt_name,notfallkontakt_telefon) explizit gegen die bisherigen Werte (NOTFALLKARTE_FELDER-Set) — bewusst nicht "war das Feld im Request enthalten", da das Bearbeiten-Formular bei jedem Speichern alle Felder mitschickt, auch unveränderte. Nur bei mindestens einer echten Wertänderung wird der Zeitstempel neu gesetzt.POST /api/patienten/{pid}/patientenverfuegung(routers/briefe.py): eine neu eingereichte Patientenverfügung zählt ebenfalls als "Notfalldaten aktualisiert" (sie wird auf der Notfall-Card mit angezeigt/verlinkt) — nur beim echten Neuanlage-Pfad, nicht wenn_juengster_duplikat()einen bereits bestehenden Antrag zurückgibt.
- Anzeige an beiden Stellen: intern im "Notfall-Card"-Collapsible der Patientenakte (nur wenn die Karte aktiv ist, direkt unter dem Titel) sowie extern auf der öffentlichen
/notfallkarte/[token]-Seite (unter Vorname/Nachname/Geburtsdatum) — beide mit identischemformatZeitpunkt()-Format ("TT.MM.JJJJ, HH:MM Uhr"), bewusst dieselbe kleine Hilfsfunktion an beiden Stellen dupliziert statt in ein gemeinsames Modul ausgelagert (unterschiedliche Nachbarschaft: Server- vs. Client-Component). - Verifiziert end-to-end gegen die lokale Docker-Instanz (Feldänderung setzt den Zeitstempel, ein reines Ändern eines NICHT-Notfall-Felds bei unveränderten Notfall-Werten setzt ihn nachweislich NICHT neu, öffentlicher Endpunkt liefert denselben Wert, neue Patientenverfügung bumpt ihn erneut) sowie live auf der NAS gegen den echten Test-Account (Allergien-Feld testweise geändert und zurückgesetzt, Zeitstempel beide Male korrekt aktualisiert, intern sichtbar bestätigt per Screenshot). Der öffentliche Seiten-Teil wurde bewusst nicht am echten, bereits aktiven Token des Testpatienten verifiziert, um dessen laufenden QR-Code/Link nicht durch eine nötige Neu-Generierung ungültig zu machen — Logik ist identisch zur bereits lokal bestätigten.
- Changelog-Eintrag
1.18.5.
Apple-/Google-Wallet-Passes für die Notfall-Card: bewusst weiterhin zurückgestellt (Stand 2026-08-08)
Anschlussfrage des Betreibers nach T37 ("ist die Notfallkarte schon wallet-ready?"), zusammen mit den technischen Rahmenbedingungen besprochen, aber (noch) nicht umgesetzt:
- Technischer Unterschied zwischen den beiden Plattformen, falls das je angegangen wird: Ein Apple-
.pkpasslässt sich komplett offline/serverseitig erzeugen und direkt von der eigenen API ausliefern — bei Verzicht aufwebServiceURL-Live-Updates müsste keinerlei Daten (auch nicht der Token) an Apple übertragen werden, nur ein einmalig zu beschaffendes Signier-Zertifikat (Apple-Developer-Program, 99 $/Jahr) ist nötig. Google Wallet ist dagegen architekturell ein Cloud-Dienst — ein Pass-Objekt muss zwingend über Googles API oder ein selbst signiertes JWT bei Google erzeugt werden, das lässt sich nicht umgehen. Der einzig sinnvolle Kompromiss dafür: der Wallet-Pass enthält nur den bereits bestehenden QR-Code/Token + einen neutralen Titel ("Notfallkarte"), nie die eigentlichen Gesundheitsdaten (Blutgruppe, Allergien, Medikamente, Notfallkontakt bleiben ausschließlich serverseitig, wie es die App bei T37 ohnehin schon so gebaut hat) — Google sähe damit zwar den Token, aber nie die echten Daten. - Betreiber-Entscheidung: Thema bewusst auf einen späteren Zeitpunkt verschoben, um aktuell (noch keine zahlenden Nutzer) die laufenden Kosten (v. a. der Apple-Developer-Account) zu sparen. Kein technisches Hindernis, reine Kostenentscheidung — bei Bedarf jederzeit nachholbar, das bestehende Token-Design (kein Ablauf, kein Einmalgebrauch, siehe T37 unten) ist bereits absichtlich wallet-kompatibel angelegt.
T37: Notfall-Card (öffentlicher, unauthentifizierter QR-Code-Notfallzugriff) (pflegepilot_v3 only, 2026-08-06)
Letzter offener Punkt aus dem Tester-Feedback-Backlog (Feedbackfromtester.md) — laut Backlog "der aufwendigste und sicherheitskritischste Einzelpunkt der gesamten Liste", explizit als "Plan nötig" markiert. Wunsch: ein QR-Code/Link, über den z. B. ein Notarzt im Ernstfall ausgewählte Daten einer gepflegten Person ohne Login einsehen kann. Vier Weichenstellungen vorab per AskUserQuestion geklärt, danach Plan-Mode-Durchlauf (~/.claude/plans/lucky-sparking-rain.md) vor der Umsetzung — gleiches Vorgehen wie bei T35/T36.
- Geklärte Entscheidungen: (1) Stufenplan — erst eine öffentliche Web-Ansicht, echte Apple-/Google-Wallet-Passes explizit als spätere Ausbaustufe zurückgestellt (kostenpflichtiger Apple-Developer-Account + Zertifikats-Setup, Google-Cloud-Projekt). (2) Datenumfang: Patientenverfügung plus feste Notfall-Basisdaten (Blutgruppe, Allergien, aktuelle Medikamente, Notfallkontakt) — kein frei konfigurierbarer Feld-Picker über alle Patientendaten. (3) Widerruf: ein jederzeit neu generierbarer Token pro Patient, kein separater Pause-Schalter. (4) Zugriffs-Log: IP-Adresse und Zeitstempel bei jedem Abruf, für den Kontoinhaber einsehbar.
- Erste unauthentifizierte Datenexposition dieser App: bislang führten alle bestehenden Magic-Links (T25) immer zu einer eingeloggten Session, nie zu einer öffentlichen Anzeige — hier wird zum ersten Mal Art.-9-DSGVO-Gesundheitsdaten ohne Login zugänglich gemacht, mit entsprechend strengen Vorkehrungen (siehe unten).
- Datenmodell: neue
Patient-Felderblutgruppe/allergien/notfallkarte_medikamente/notfallkontakt_name/notfallkontakt_telefon(nur über das Bearbeiten-Formular pflegbar, bewusst nicht Teil vonPatientCreate/dem Interview-Wizard) sowienotfallkarte_token_hash(NULL = inaktiv, nur SHA-256-Hash gespeichert, gleiches Prinzip wieNutzer.reset_token_hash) undnotfallkarte_aktiviert_am. Neue Kind-TabelleNotfallkartenZugriff(IP + Zeitstempel je Abruf). Migrationd9f2b6e4a8c1. app/notfallkarte.py: Token-Erzeugung/-Hash (bewusst kein Ablauf, kein Einmalgebrauch — anders als der T25-Magic-Link-Token, da dieser Token dauerhaft auf einer gedruckten Karte/im Wallet funktionieren soll) sowiehole_aktuelle_patientenverfuegung()(existierte vorher nirgends als Abfrage).app/routers/notfallkarte.py: authentifizierter Teil (aktivieren/neu-generieren/deaktivieren/zugriffe, hinterrequire_active_access+get_owned_patient, Klartext-Token wird ausschließlich im Moment der (Neu-)Aktivierung einmalig zurückgegeben) plus ein komplett neuer öffentlicherrouter_public(gleiches Zwei-Router-Muster wieantraege.router_public):GET /api/notfallkarte/{token}(striktes eigenes Response-SchemaNotfallkarteOeffentlichOut, damit nie versehentlich ein zusätzliches Patientenfeld mit ausgeliefert wird) undGET /api/notfallkarte/{token}/patientenverfuegung.pdf(eigener token-gesicherter Zweitpfad statt des normalenget_owned_patient-geschützten PDF-Downloads, der für einen anonymen Aufrufer unerreichbar wäre). Beide öffentlichen Routen mit demselben Rate-Limiting-Muster wie Login/Registrierung; unbekannter/deaktivierter Token liefert immer dasselbe generische 404.- Frontend: Bearbeiten-Formular bekam die Notfall-Card-Datenfelder (Blutgruppe als festes
<select>mit den 8 Standard-Notationen, kein Freitext — Ernstfall-Seite, keine Tippfehler-Daten). Patientenakte bekam einen neuen "Notfall-Card"-Collapsible (Aktivieren mitconfirm()-Warnhinweis vor dem ersten Freischalten, QR-Code via neuerqrcode-npm-Abhängigkeit, Neu-generieren/Deaktivieren, Zugriffs-Log-Liste). Neue, komplett eigenständige Top-Level-Routefrontend/src/app/notfallkarte/[token]/page.tsx— bewusst außerhalb von(app)und(marketing), damit weder die Login-Sperre noch die Marketing-Chrome (Anmelde-/Registrierungs-Links) eine im Ernstfall genutzte Seite stören; async Server Component mit demselben serverseitigenAPI_INTERNAL_URL-Fetch-Muster wie die Landingpage, eigenesnoindex,nofollowin den Metadaten.robots.tsdisallowed/notfallkarteexplizit, da die URL den geheimen Token enthält. - Rechtstexte: neue Datenschutzerklärung-Ziffer 8 "NOTFALLKARTE" (Folgeziffern 9–19 hochgezählt, alle internen Querverweise mit angepasst), Nutzungsbedingungen bekamen einen Feature-Bullet in Ziffer 2.1 sowie einen neuen Haftungsausschluss 11.6 (Umkreissuche-Disclaimer zu 11.7 verschoben). Wie bei der Umkreissuche-Ziffer: das sind nur die Betreiber-Quelltexte im Repo, der Betreiber muss die live angezeigte Datenschutzerklärung (
rechtstext_datenschutz-Einstellung) selbst im Admin-UI nachziehen — noch nicht geschehen. - End-to-end gegen die lokale Docker-Postgres-Instanz verifiziert (Aktivieren → Hash statt Klartext in der DB, öffentlicher Zugriff ohne jedes Cookie liefert nur die 5 Notfallfelder + Patientenverfügungs-Link, kein anderes Patientenfeld; Zugriffs-Log; Neu-Generieren invalidiert den alten Token sofort; Deaktivieren sperrt zuverlässig; echte Patientenverfügung wurde erzeugt und über den PDF-Zweitpfad korrekt ausgeliefert), danach auf der NAS deployed und dort ebenfalls per Migration-Log + Health-Checks bestätigt.
Zwei Bugs nach dem ersten Deploy gefunden und behoben (2026-08-06)
Beide erst durch tatsächliche Nutzung der Funktion durch den Betreiber entdeckt, nicht durch Code-Review vorhersehbar:
- Zugriffs-Log zeigte die interne Docker-Netzwerk-IP des
web-Containers statt der echten Besucher-IP (192.168.144.x, perdocker network inspectals exakt die IP vonpflege-web-1auf dempflege_default-Netz bestätigt). Ursache: die öffentliche Seite ist bewusst eine Server Component, die die Daten serverseitig überAPI_INTERNAL_URLlädt (Performance/Robustheit, gleiches Muster wie die Landingpage) — genau dieser Server-zu-Server-Aufruf lief aber am Nginx Proxy Manager vorbei und ist zugleich der Aufruf, der beim Backend den Zugriff protokolliert. Das Backend sah dadurch bei jedem Zugriff, unabhängig vom tatsächlichen Besucher, immer nur denweb-Container als Aufrufer. Fix:notfallkarte/[token]/page.tsxliest jetzt perheaders()(ausnext/headers) denx-forwarded-for-Header des eingehenden Seitenaufrufs aus (erste Hop-Logik, gleich wieclient_ip()im Backend) und reicht ihn explizit alsX-Forwarded-For-Header an den internen API-Aufruf weiter —client_ip()im Backend liest diesen Header ohnehin bereits, keine Backend-Änderung nötig. Lehre für künftige Features: sobald eine Server Component serverseitig einen Backend-Endpunkt aufruft, der die Besucher-IP protokolliert/rate-limitet, muss die IP explizit durchgereicht werden — ein naiverfetch()vonwebzuapiläuft immer am Reverse-Proxy vorbei und liefert sonst nur die interne Container-Adresse. - "Patientenverfügung öffnen"-Link zeigte auf
localhost:8100, von einem echten Gerät aus natürlich nicht erreichbar. Ursache:process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"— auf der NAS istNEXT_PUBLIC_API_URLbewusst ein leerer String (damit der Browser/api/*relativ zur selben Domain aufruft, siehe Reverse-Proxy-Abschnitt weiter unten), aber ein leerer String ist in JavaScript falsy, wodurch||fälschlich auf den Fallback zurückfiel statt den absichtlich leeren Wert stehen zu lassen. Fix: die Stelle wiederverwendet jetzt die bereits bestehende, korrekt mit??(nullish coalescing statt logischem Oder) gebauteAPI_URL-Konstante auslib/api.ts, statt die Fallback-Logik ein zweites Mal (diesmal fehlerhaft) zu duplizieren. Lehre: beiNEXT_PUBLIC_*-Variablen, die absichtlich leer sein können (same-origin-Routing-Konvention dieses Projekts), immer??verwenden, nie||— und wo immer möglich die bestehendeAPI_URL-Konstante importieren statt die Fallback-Zeile erneut hinzuschreiben. - Beide Fixes lokal verifiziert (inkl. eines eigenen Testartefakts dabei: die lokale Dev-
.envhatNEXT_PUBLIC_API_URL/WEB_ORIGINauf die reale NAS-LAN-IP192.168.1.222gesetzt statt auflocalhost— ein Test gegen192.168.1.222:8100trifft von der lokalen Dev-Maschine aus also die echte NAS-API, nicht den lokalen Container; für lokale Verifikation immerlocalhost:8100/localhost:3100verwenden, nicht die in Aktivierungs-Antworten zurückgegebene URL blind übernehmen) und danach auf der NAS deployed.
- Bewusst nicht angetastet: die bereits vom Betreiber selbst aktivierte, echte Notfall-Card von "Grete Musterfrau" (Demo-Konto auf der NAS, Patient-ID 1) wurde nicht neu generiert, um den bereits gespeicherten Link/QR-Code nicht ungültig zu machen — der Fix wirkt auch auf den bestehenden Token, ohne dass etwas neu aktiviert werden muss. Bei ihr liegen vier Patientenverfügung-Anträge (IDs 32–35) aus eigenen Testdurchläufen des Betreibers, bewusst nicht aufgeräumt (nicht ungefragt Daten löschen).
T34-Anschluss/T35/T36: Pflegegrad vermutet/bestätigt, ressourcenorientiertes Interview, 4-Wochen-Pflegetagebuch (pflegepilot_v3 only, 2026-08-05)
Drei inhaltlich verzahnte, als "hoch, Plan nötig" eingestufte Punkte aus dem Tester-Feedback-Backlog, vorab per AskUserQuestion mit dem Betreiber geklärt und in einem Plan (~/.claude/plans/lucky-sparking-rain.md) festgehalten, bevor mit der Umsetzung begonnen wurde — gleiches Vorgehen wie beim ursprünglichen T25-Side-Plan.
- Pflegegrad "vermutet" vs. "bestätigt" (Antwort auf T34, das zur Diskussion stellte, ob die Pflegegrad-Schätzung überhaupt noch angezeigt werden sollte): die Schätzung wird nicht entfernt, aber nirgendwo mehr als Fakt dargestellt. Neue Felder
Patient.pflegegrad_bestaetigt/pflegegrad_bestaetigt_am(Migrationc1e5a9d3f7b2). Die dafür nötige "Rückmeldung vom Medizinischen Dienst" existierte bereits aus T25 (Antrag.zugeteilter_pflegegrad/bewilligter_betrag, "Status bearbeiten"-Form) — sie war nur nicht mitPatient.pflegegradverknüpft.PUT /api/antraege/{aid}(routers/antraege.py: aktualisieren()) synct jetzt beistatus="genehmigt"mit gesetztemzugeteilter_pflegegradfür die drei Pflegegrad-Antragstypen (erstantrag/hoeherst/widerspruch) automatisch nachPatient.pflegegradund setztpflegegrad_bestaetigt=True. Eine Guard-Bedingung (nur schreiben, wenn sich der Wert tatsächlich ändert oder das Konto noch nicht bestätigt ist) verhindert, dass ein späteres PUT auf denselben bereits genehmigten Antrag (z. B. nur Aktenzeichen nachtragen)pflegegrad_bestaetigt_amerneut auf "heute" zurücksetzt — per echtem Regressionstest gegen die lokale Docker-Postgres-Instanz bestätigt. Eine manuelle Korrektur über die bestehenden Chip-Buttons (PATCH /{pid}/pflegegrad) setztpflegegrad_bestaetigtbewusst wieder aufFalse— nur der Weg über die Antrag-Rückmeldung gilt als bestätigt.AntragUpdate.zugeteilter_pflegegradbekam dabei zusätzlich einege=0, le=5-Validierung (fehlte vorher, war aber unkritisch, solange der Wert nirgends automatisch weiterverarbeitet wurde) — ohne sie hätte ein außerhalb des Bereichs liegender Wert beim Schreiben in die per CheckConstraint begrenztePatient.pflegegrad-Spalte einen 500er statt einer sauberen 400-Validierung ausgelöst, per Test bestätigt (422 statt 500). Anzeige überall ergänzt, wopflegegradauftaucht (Dashboard-Badge, Patientenliste, Patientenakte, Wizard-Step5-Label "Vermuteter Pflegegrad" statt "Tatsächlicher / vermuteter Pflegegrad"). Bewusst nicht angefasst:build_musterbrief_text()/Widerspruch-Text referenzieren die Wizard-Schätzung nirgends (Widerspruchsist_grad/soll_gradkommen aus dem echten Bescheid) — es gab dort nichts fälschlich als Fakt Dargestelltes zu korrigieren. - T36 (ressourcenorientierter Umbau): die 5 Pflegegrad-Situationsfragen (
static_data/pflegegrad_regeln.py: FRAGEN, Interview-Wizard Schritt 4) sind jetzt Checklisten "was kann die Person noch selbstständig" (3 capability-first Teilschritte je Frage) statt einer Einzelauswahl aus 4 zunehmend defizitorientierten Optionen. Punktwert je Frage =3 − Anzahl angekreuzter Teilschritte— reproduziert exakt den bisherigen 0-3-Wertebereich und die Summenbandbreite 0-15,schaetzen()blieb komplett unverändert. Per Regressionstest mit den historisch dokumentierten Schwellenwerten (0-1/2-4/5-7/8-10/11-13/14-15 → PG-Bereiche 0-1/1-2/2-3/3-4/4/5) gegen die echte API bestätigt: identische Ergebnisse wie vor dem Umbau. Betrifft nur Schritt 4 — die 8LEISTUNGS_FRAGENaus Schritt 6 sind Logistik-Fragen (Betreuungsform, welche Zusatzleistungen), keine Fähigkeits-Fragen, blieben unangetastet. Frontend: neue KomponenteTeilschritteAuswahlinpatienten/neu/page.tsx, nutzt weiterhin denselbenSET_ANTWORT-Reducer-Contract wie zuvor. - T35 (4-Wochen-Pflegetagebuch): Betreiber wählte "weiches Angebot, kein Blocker" (keine Wizard-Gating-Logik) und "separates zweites PDF pro Antrag" (statt Freitext im Hauptbrief). Neues Feld
Patient.tagebuch_start_am(optional, über das bereits bestehende generischePatientUpdate/PUT /api/patienten/{pid}gesetzt, kein neuer Endpunkt) — Button "4-Wochen-Tagebuch starten" im bestehenden Pflegetagebuch-Collapsible der Patientenakte, danach Fortschrittsanzeige "Tag X von 28" + Anzahl unterschiedlicher Tage mit mindestens einem Eintrag im Fenster. Neues FeldAntrag.tagebuch_pdf_pfad(String, analog zupdf_pfad— bewusst ein einzelnes Feld statt einer Anhänge-Tabelle, da es genau einen bekannten Zweit-PDF-Typ gibt). Musterbrief-Generator bekam eine "Pflegetagebuch als Anhang beifügen"-Checkbox (nur beityp ∈ {erstantrag, hoeherst}sichtbar) —MusterbriefRequest.tagebuch_anhang, serverseitig zusätzlich auf denselben Typ-Filter geprüft (nicht nur im Frontend versteckt), damit ein manipulierter Request bei z. B.grundsicherungnicht fälschlich ein Tagebuch-PDF anhängt — per Test mit direktem API-Aufruf bestätigt (tagebuch_pdf_pfadbleibtnull). Zeitfenster:patient.tagebuch_start_amfalls gesetzt, sonst Fallbackheute − 28 Tage(nichtantrag.antragsdatum— das Feld wird bei Musterbrief-Erstellung anders als beim Widerspruch nicht gesetzt). Neues Templatepdf_templates/tagebuch_anhang.html(tabellarisch, nur nicht-leere Kategorien je Eintrag) — Inhalt perpypdf.extract_text()gegen einen echten Testlauf verifiziert (Patientenname, Zeitraum, beide Testeinträge korrekt enthalten). Download über denselben autorisierungsgeprüften Endpunkt (GET .../antraege/{aid}/pdf?datei=tagebuchstatt eines neuen Endpunkts).DELETE /api/antraege/{aid}(T29) wurde um denselben Datei-Löschblock fürtagebuch_pdf_pfadergänzt wie fürpdf_pfad— sonst wären verwaiste PDFs auf der Platte liegen geblieben (gleiche Bug-Klasse wie der ursprüngliche T29-Fund); per Test bestätigt, dass nach dem Löschen eines Antrags mit beiden PDFs keine der beiden Dateien mehr im Uploads-Verzeichnis liegt. - Vollständig end-to-end gegen die echte lokale Docker-Postgres-Instanz verifiziert (nicht nur synthetisch): Migration lief sauber durch, alle drei Teile per echten HTTP-Aufrufen gegen die laufende API getestet (Sync-Logik inkl. Guard-Regression, Schätzungs-Regressionstabelle, Tagebuch-Fenster-Filterung, Zweit-PDF-Erzeugung/-Inhalt/-Download/-Löschung, Typ-Guard-Umgehungsversuch),
next buildsowietsc --noEmitfehlerfrei. Testdaten (Testpatient, Testkonto) danach wieder entfernt; ein lokalesverifyadmin-Konto blieb bewusst bestehen (Admin-Selbstlöschung ist blockiert, unkritisch für eine lokale Dev-Instanz). Changelog-Eintrag1.17.0ergänzt, Versions-Kollisionsrisiko vorab perGET /api/changelog-Abgleich ausgeschlossen (siehe Lehre im Changelog-Abschnitt unten).
Repository layout
This is not a single project but a working directory with one active app plus reference/planning material:
pflegepilot_alpha_v2/pflegepilot/— the current production codebase (Flask + SQLite web app). Still the live app; keep maintaining it (bugfixes, security, small features) independently of the rewrite below.pflegepilot_v3/— the in-progress full rewrite (FastAPI + PostgreSQL + Next.js), built as a complete parallel rebuild alongside the still-runningpflegepilot_alpha_v2app, not a migration of it — no code is shared and no data is migrated (fresh DB, demo data, an admin account created interactively together with the operator). Backend:pflegepilot_v3/backend/app/(routers, SQLAlchemy models, Alembic migrations undermigrations/). Frontend:pflegepilot_v3/frontend/src/app/(Next.js App Router, route groups(marketing)public/SSR and(app)client-side behind a login overlay). Owndocker-compose.ymlwith independent ports (db 5532, api 8100, web 3100) so it never conflicts with the old app's Docker setup. The full 8-phase architecture plan (schema translation, cross-cutting patterns, phase-by-phase scope) lives atC:\Users\Daniel\.claude\plans\cheerful-shimmying-pnueli.md— read it before continuing this work. Phase 0 (project skeleton, auth end-to-end), Phase 1 (patient CRUD, tenant isolation, dashboard), Phase 2 (6-step non-AI interview wizard, rule-based Pflegegrad estimation ported topflegegrad_regeln.py, Krankenkassen autocomplete), Phase 3 (Leistungskatalog, Leistungsrechner, Anträge, Widerspruch- and Musterbrief-generation), Phase 4 (admin dashboard: account lock/unlock/soft-delete-with-archival/subscription-grant, audit log, DSGVO export), Phase 5 (global subscription gate, § 14 UStG invoicing, changelog system), and Phase 6 (Pflegetagebuch, Dokumente with the same authorization-checked-download pattern as briefe, Aufgaben CRUD, Kalender aggregation) are done as of 2026-07-19 — full feature parity with the old app is reached. Of Phase 7 (responsive pass, hardening, deployment), the frontend production-build switch is done as of 2026-07-29 (see "Frontend-Produktions-Build" below) — a responsive/mobile pass across the UI is what remains. Note the architecture shift in Phase 3: unlike the old app's client-sidehtml2pdf.js, PDFs here are rendered server-side with WeasyPrint (app/pdf.py+ Jinja2 templates underapp/pdf_templates/) and served only through an authorization-checked download route rather than an openly mountedstatic/folder — see the plan file for the full rationale. The admin router in Phase 4 (app/routers/admin.py) demonstrates the Phase 0 architectural decision concretely: it's declared asAPIRouter(dependencies=[Depends(require_admin)])so every route is protected by construction, not by a maintained list. Real bug found and fixed in Phase 5: Gunicorn's multiple worker processes each run the FastAPIlifespanstartup handler independently, so the changelog dev-seed sync (app/changelog.py: sync_changelog()) executed concurrently on first boot and inserted a duplicate row — no unique constraint existed onchangelog_eintraege.versionto prevent it. Fixed with a DB-levelUNIQUEconstraint plusINSERT ... ON CONFLICT DO NOTHING(Postgres dialect insert) instead of a Python-side "check existing, then insert missing" pattern, which is not safe under concurrent execution. Worth remembering for any future startup-time sync/seed logic in this app: assume it runs from more than one process at once.pflegepilot_alpha/— superseded v1. Source files have been deleted; only a stale__pycache__remains. Do not develop here.pflegepilot_erp.html— a standalone static HTML mockup/prototype, not wired to the Flask app. Not part of the running system.Anleitung.md,Leistungen.md,Leistungen2.md,Pflegegrad.md,Musterbrief.md— German-language domain reference notes (care-benefits catalog research, sample letters, project rationale) used as source material forkatalog.pyand the letter generators. Not code.refactoring.md— the current architecture-change brief from the product owner (see "Planned architecture pivot" below). Read this before any structural work.
A git repository is initialized at the root (Pflegepilot/.git). .gitignore excludes the SQLite DB, patient-generated files (static/briefe, static/dokumente, static/fotos), __pycache__, and graphify-out/ — never commit real patient data.
Commands
Run from pflegepilot_alpha_v2/pflegepilot/:
pip install flask
python start.py # initializes/migrates the SQLite DB, then serves on http://localhost:5050
start.py runs with debug=False; running app.py directly instead uses debug=True on the same port. There is no test suite, linter, or build step in this project.
Default login after first start: admin / admin (created automatically by init_db() when the nutzer table is empty).
Docker deployment
The app also runs containerized, as-is (Flask + SQLite, no rewrite) — Dockerfile, docker-compose.yml, .dockerignore, requirements.txt, and .env all live in pflegepilot_alpha_v2/pflegepilot/, alongside the app itself. This is an additional deployment path, not a replacement for local dev — python start.py still works unchanged.
cd pflegepilot_alpha_v2/pflegepilot
docker compose build
docker compose up -d # serves on http://localhost:${APP_PORT:-5050}
docker compose logs -f app
docker compose down # add -v to also wipe the db/static volumes
- Single app service, no separate DB container: SQLite is an embedded, file-based engine, not a networked service — there is nothing to containerize separately without swapping to Postgres, which would be a rewrite and is explicitly out of scope for this task (that's the separate, not-yet-planned
open.mditem 4). "Isolation between app and data" is instead achieved with two named Docker volumes —pflegepilot_db(mounted at/app/db) andpflegepilot_static(mounted at/app/static) — so patient data outlives anydocker compose build/upcycle of the app container and can be backed up or moved independently of the image. - Runs on Gunicorn, not the Flask dev server: the container
CMDfirst runsinit_db()once as a plainpython -cstep (idempotent — see the schema-evolution pattern above, safe to re-run on every container start), then execsgunicornbound to0.0.0.0:5050inside the container. Worker count isGUNICORN_WORKERS(env var, default 2). - Secrets and config live in
.env(real file, not.env.example— solo project, no template needed), gitignored, and referenced bydocker-compose.ymlviaenv_file. Currently holds:SECRET_KEY(random 64-hex-char value, generated once withpython -c "import secrets; print(secrets.token_hex(32))"—app.secret_keyinapp.pyreadsos.environ.get("SECRET_KEY", "pflegepilot-alpha-2025"), so localpython start.pystill works with zero setup via the hardcoded fallback, while Docker always overrides it with a real secret),APP_PORT(host-side port mapping, default 5050),GUNICORN_WORKERS. Never commit.envor paste its contents anywhere. - No domain/URL baked in anywhere, intentionally: the container only knows about its internal port (
5050, fixed) and the host port it's mapped to (APP_PORT, configurable). Whatever hostname eventually points at that port — a DynDNS domain forwarded to the testbench, or anything else later — needs zero code or.envchanges here; the app doesn't care what hostname it's reached through. The one thing to revisit when a real domain + reverse proxy with TLS is added in front of this later: session cookies aren't currently markedSecure(fine for a plain-HTTP testbench today), so addSESSION_COOKIE_SECURE=Trueand proxy-header trust (ProxyFix) at that point, not before. - Bug found and fixed while adding this: the
nutzertable isn't inschema.sql(it was added later, purely via theinit_db()migration list) — it used to be created near the end of that list, after severalALTER TABLE nutzer ADD COLUMN ...entries earlier in the same list. On any database that already had anutzertable this was invisible (the ALTERs just worked normally), but on a genuinely fresh database — e.g. the first boot against a brand-new Docker volume — the ALTERs ran before the table existed, failed, and were silently swallowed by the migration loop'sexcept: pass, leavingnutzerpermanently missingabo_bis/abo_typ/ist_demoand crashing the demo-account bootstrap insert. Fixed by movingCREATE TABLE IF NOT EXISTS nutzerto the very top of the migration list with the full current column set included directly, so table creation always precedes anything that depends on it, regardless of whether the DB is fresh or pre-existing. Verified by tearing down the Docker volumes (docker compose down -v) and rebuilding from nothing — the app now boots clean.
Architecture
Single-file Flask backend, vanilla-JS SPA frontend, zero build tooling.
app.py— the entire backend: all/api/*routes, SQLite access, auth, file upload handling. There's no blueprint/module split — everything lives in this one file. There is no server-side email sending — see "Email: mailto hand-off, not SMTP" below.templates/index.html— the entire frontend. One Jinja template that is actually a client-rendered SPA: vanilla JS, string-templated HTML, no framework, no bundler. Key conventions in the<script>block:go(name, extra)is the client-side router; it maps a page name to apg*()render function (pgDash,pgPatienten,pgKatalog,pgBackend, …).S(id, html)sets innerHTML by id;R(html, title, actions)sets the main content/title/toolbar in one call.api(path, opts)is the sharedfetchwrapper for JSON API calls (rawfetchis used directly for multipart file uploads).- PDF documents (Musterbrief, Widerspruch, Pflegetagebuch, Patientenakte) are rendered client-side via
html2pdf.js, then the resulting blob is base64-uploaded to a server endpoint (e.g.POST /api/patienten/<pid>/briefe,POST /api/patienten/<pid>/dokumente) for permanent storage understatic/.
katalog.py— static data module:KATALOG, a list of ~82 care-benefit entries (Pflegekasse/Krankenkasse services under SGB XI), each tagged withzielgruppe(target group:alterspflege/beduerftige/behinderte) and apflegegrad_min/pflegegrad_maxrange, plus legal basis (rechtsgrundlage), amounts, and optionalabhaengigkeiten(cross-references to related/conflicting benefits). Filtering helpers (get_by_zielgruppe,get_by_pflegegrad,search, …) are used both by/api/katalogand to compute a patient'sempfohlene_leistungen.krankenkassen.py— static directory of ~86 health/care insurers (GKV/PKV in Germany, SV in Austria, KVG in Switzerland) with postal addresses, used to auto-fill recipient addresses in generated letters. Has alandfield (DE/AT/CH) with helpersget_by_land,search(q, land=None).db/schema.sql— base SQLite schema (patienten,antraege,leistungen,aufgaben,pflegetagebuch,einstellungen,dokumente) plus demo seed rows.- Schema evolution pattern:
init_db()inapp.pyrunsschema.sqlonce, then re-applies a hardcoded list ofALTER TABLE/CREATE TABLE IF NOT EXISTSstatements on every startup, each wrapped in its own try/except so an already-applied migration is silently skipped. When adding a column or table, add a new entry to that list rather than editingschema.sql(existing DBs won't be recreated) — and keep entries idempotent/backward-compatible since this list re-runs on every process start. - Auth: session-cookie based.
_get_current_user()/_check_admin()readsession["nutzer_id"];login_required/admin_requireddecorators guard individual routes, whileglobal_auth_check()(a Flaskbefore_requesthook) blanket-blocks any/api/*path not in the small public allowlist (/api/auth/login,/api/auth/me,/api/einstellungen/public). Two roles exist:adminandnutzer. Passwords are hashed withwerkzeug.security(PBKDF2). - Patient isolation:
patienten.nutzer_id(FK tonutzer.id) records who owns each patient — set automatically to the creating user onPOST /api/patienten. Two helpers inapp.pyenforce it everywhere:_get_owned_patient(pid)(single-patient routes — returns the row if the current user is the owner or an admin, elseNone→ caller returns 404) and_scope_sql(column="id")(list/aggregate routes — returns a SQL fragment + params to append to aWHEREclause; empty for admins,AND {column} IN (SELECT id FROM patienten WHERE nutzer_id=?)otherwise).adminrole is exempt and always sees every patient. Every route touchingpatientenor a child table (antraege,leistungen,aufgaben,pflegetagebuch,dokumente) must go through one of these two helpers — when adding a new patient-scoped route, follow the existing pattern rather than querying the table directly.aufgaben.patient_idis nullable in the schema, but the API now requires apatient_idfrom non-admin callers on creation (admins may still create patient-less tasks) so the isolation model stays consistent between write and read paths. - File storage: everything is local under
static/—static/fotos/<patient_id>.<ext>(profile photos),static/briefe/<patient_id>/…(generated letter PDFs),static/dokumente/<patient_id>/…(uploaded documents),static/logo/(company branding). No object storage/CDN involved. - Settings: a generic key-value
einstellungentable (accessed viaget_setting()) holds company branding/address and the threerechtstext_*legal texts (see "Admin focus, self-registration & legal pages" below) — surfaced through/api/admin/einstellungen. The earlier "KI – Claude API" token field/card was removed (unused, never wired to any actual AI feature) — don't resurrect it without a real integration behind it.
Planned architecture pivot (see refactoring.md)
The product owner specified a pivot away from the original central multi-patient service (any logged-in user could see all patients) toward three changes:
- Per-account patient isolation — ✅ implemented. Data model: 1
nutzer= 1 account,patienten.nutzer_idFK,adminrole exempt (sees everything). See "Patient isolation" above for the enforcement mechanism. Verified end-to-end (two non-admin accounts cannot see or directly access each other's patients; admin sees all). - No-AI interview flow — ✅ implemented.
pgNeu()intemplates/index.htmlis now a 6-step guided wizard (neuWizstate object,neuRender()/neuNext()/neuBack()/neuCollect()) that fully replaced the old single-form dialog: persönliche Daten → Kontakt & Adresse → Krankenversicherung → Pflegesituation → Einschätzung & Pflegeperson → Übersicht. Step 4 asks 5 plain-language situational questions (NEU_FRAGEN) plus a Zielgruppe question (NEU_ZG_OPTIONEN);neuSchaetzung()sums the answers into a rule-based, non-AI Pflegegrad range shown with a prominent disclaimer that it's not an official determination (only the Medizinischer Dienst's Begutachtung is). Server enforces a hard cap of 3 patients per non-admin account inapi_neuer_patient(admin exempt). - Admin dashboard — ✅ implemented. New
admindashpage (pgAdminDash()intemplates/index.html, nav item under.sb-admin), backed byapp.pyroutes under/api/admin/nutzer/<uid>/{sperren,entsperren,loeschen},/api/admin/audit-log,/api/admin/patienten/<pid>/dsgvo-export. Key design points, all confirmed with the user beforehand:- "Löschen" is a soft-delete, not
DELETE FROM nutzer: it setsaktiv=0and archives every active patient the account owns via_archiviere_patient()(same 10/30-year retention-deadline logic as the existing per-patient archive feature) — because care records carry a statutory retention duty (§ 630f BGB) that a hard delete would violate. The pre-existing hard-delete route (DELETE /api/nutzer/<uid>) still exists but now refuses (400) if the account still owns any patients, forcing admins through the archive-first path. nutzer.aktivis checked on every request (_get_current_user()), so locking an account invalidates an already-open session immediately — no separate session-store/invalidation mechanism was needed.- Every action requires a
begruendung(400 without one) and is written to the newadmin_audit_logtable (_log_admin_aktion()) — immutable, insert-only, same audit-trail pattern asnotizen_verlauf. This is the operator's evidentiary record for disputes. - DSGVO export (
POST /api/admin/patienten/<pid>/dsgvo-export) requires abegruendung(used as Rechtsgrundlage/Aktenzeichen), is admin-only, and streams back a ZIP (built in-memory withzipfile/io.BytesIO, no temp files) containingdaten.json(patient + antraege + leistungen + aufgaben + pflegetagebuch + dokumente + notizen_verlauf, plus anexport_infoblock) and the actual files fromstatic/briefe/<pid>/,static/dokumente/<pid>/, and the patient's photo. Every export is itself audit-logged. - Self-targeting is blocked for sperren/loeschen (an admin can't lock/soft-delete their own account) to avoid a lockout with no recovery path.
GET /api/nutzernow also returnspatienten_anzahlper account (used by the dashboard table; backward-compatible addition).
- "Löschen" is a soft-delete, not
All three refactoring.md pivot pieces are now implemented. Verified end-to-end (API tests for every guard/happy-path + audit-log content, and a Playwright screenshot pass of the dashboard UI and the confirmation modal).
Responsive design & visual style
The frontend was originally desktop-only (zero @media queries, fixed 228px sidebar, fixed-column form grids) and went through two visual passes per user feedback: a first "warm minimal" pass was rejected as still too clinical, then a full glassmorphism + Bento grid redesign (current state) — both entirely in templates/index.html.
- Glass design system:
:rootdefines a dedicated token set —--glass/--glass-strong(translucent white fills),--glass-border,--glass-blur,--glass-shadow/--glass-shadow-lg, plus four--bento-*gradient tokens (mint/lavender/peach/rose) for tile accents.bodyhas a fixed multi-blobradial-gradientbackground (background-attachment:fixed) that the translucent,backdrop-filter:blur(...)cards/sidebar/topbar/modal/login-box float over. Regular form inputs stay solid (var(--surface), opaque white) — only container-level chrome (.card,.modal,.sb,.topbar,.lo-box) is glassy, intentionally, for text legibility. Always pairbackdrop-filterwith-webkit-backdrop-filter(Safari). - Dashboard is a real Bento grid, not just a relabeled stat row:
.bentois a 4-column CSS grid with explicitgrid-column/grid-rowline placement per tile class (.bento-stat,.bento-hero,.bento-aufg,.bento-quick) — deliberately not relying on grid auto-placement, which does not reliably fill remaining same-row cells the way you'd expect when items of different spans are mixed. If you add/resize tiles, keep using explicit line numbers (or verify auto-placement with the debug technique below) rather than assumingspan Nwill pack tiles the way you expect. - Responsive tables → cards: any live-UI (non-PDF-export) table that needs to stay usable at phone width gets
class="tw rtable"on its wrapper anddata-label="…"on every<td>. Below 700px,.rtableCSS turns each<tr>into its own glass card (label stacked above value per cell, viatd[data-label]::before{content:attr(data-label)}) instead of squeezing table columns. Applied to: Dashboard's recent-patients table,renderPT(Patientenliste),pgAufgaben,renderAdminDash's Konten table and its Audit-Log (originally a fixed-min-width flex list — converted to an.rtablespecifically because flex rows withmin-widthdon't degrade gracefully, unlike this pattern), and the Patientenakte Anträge table. Follow this pattern for any new data table — don't ship a raw<table>(or a fixed-width flex row list) without it. - Accordion on the card view: within
.rtable, exactly one row is expanded at a time — the rest collapse to a single header line (td.rt-head, marked explicitly per table since it's usually the firstdata-labelcell) and stack tightly, so scanning many rows needs far less scrolling.rtInit()(shared helper, defined nearescH) wires this up: on load the first row gets.rt-open; clicking any row's header toggles it open and closes the others (clicks on nestedbutton,a,select,input,textarea,.tlare ignored so action buttons/links inside a row still work independently of the accordion). Every function that renders an.rtablemust callrtInit()after the DOM is in place (seepgDash,renderPT,pgAufgaben,renderAdminDash,loadPat) — a new table using this pattern that forgets the call will render but never expand/collapse. Cells withoutdata-label(action-button columns) are never hidden by the accordion, so those buttons stay reachable even on a collapsed row. The.rt-chevchevron icon isdisplay:noneby default and only shown inside themax-width:700pxbreakpoint — without that base rule it leaks into the plain desktop/tablet<table>as a stray icon. Expand/collapse animates viamax-height/opacity/paddingtransitions on eachtd[data-label](a fixedmax-height:130pxceiling, notheight:auto, sincedisplayandheight:autoaren't transition-able) — don't revert this to adisplay:none/blocktoggle, that's what made it feel instant/abrupt before. - Date-wheel picker, not native
<input type="date">, for any date field where users need to scroll far from today:openDatePicker(hiddenId, dispId, {fallback, titel, icon, yearMin, yearMax})opens anOM()modal with three CSS-scroll-snap columns (Tag/Monat/Jahr,.dwheel/.dwheel-col/.dwheel-item,DW_ITEM_H=38); the centered item per column is read on confirm (dwConfirm()), clamped for invalid day/month combos, and written back as an ISO string. The pattern is: a visible.dw-triggerdiv (click opens the picker, shows the formatted date viadwTriggerLabel(hiddenId, dispId, icon)) plus a paired<input type="hidden">that holds the realYYYY-MM-DDvalue everything else in the codebase already expects — soneuCollect()/fillWP()/fillM()/updM()etc. needed no changes beyond callingdwTriggerLabel()after programmatically setting the hidden input (e.g. when a patient is selected and auto-fills the form).openGebDatumPicker(hiddenId, dispId)is a thin wrapper aroundopenDatePickerfor birth dates specifically:icon:'ti-cake',fallback= today − 75 years (a far better scroll starting point than today, given this app's typical patient age) — used onfgeb(interview wizard),wgeb(Widerspruch-Generator),mpgeb(Musterbrief).openDatePickeris called directly (no wrapper, default fallback = today,icon:'ti-calendar') for near-term dates where "today" is already the right starting point, e.g.adt(Antragsdatum). Dates that are always within the last/next few months and don't need this at all (Bescheiddatum, Fällig-am, Tagebuch/Dokument-Datum, etc.) can stay a plain<input type="date">— only convert a field once someone actually needs to scroll years back from it. If you wire the picker to a field with existing live-update behavior (Musterbrief'supdM()was previously triggered viaoninput), you must call that update function explicitly fromdwConfirm()— switching totype="hidden"removes the inlineoninputhandler, and events dispatched on a hidden input don't automatically reach anything unless something explicitly listens for them. - Interview wizard step 4 asks one question at a time, not a long scrolling list:
neuWiz.q4sub(0 = Zielgruppe, 1-5 = eachNEU_FRAGENentry) tracks position within the step;NEU_Q4_TOTALis the count. A thin segmented progress bar (.subdots/.subdot, distinct from the big numbered.wiz-stepscircles for the outer 6 steps) shows position within step 4 specifically. Answering a question (neuAntwort/neuSetZG) auto-advancesq4subto the next one — except on the last question, which waits for an explicit "Weiter" tap so the user gets a moment before jumping to the estimate in step 5.neuNext()/neuBack()both special-casestep===4: they moveq4subwithin the step before ever touching the outerstepcounter, and entering step 4 from step 5 via "Zurück" lands on the last sub-question (q4sub=NEU_Q4_TOTAL-1), matching normal back-navigation expectations. If you add a 7th situational question, nothing else needs to change —NEU_Q4_TOTALand the subdots derive fromNEU_FRAGEN.lengthautomatically. - Mobile/tablet nav: below 900px the
.sbsidebar becomes a fixed off-canvas panel (transform:translateX(-100%), slides in via.sb.open), toggled by a hamburger button (#sb-toggle, only visible in that breakpoint) and a#sb-backdropoverlay that closes it on tap.toggleSidebar()/closeSidebar()manage this; every nav-item click auto-closes it. Form grids (.fg.c2/.c3), stat grids (.g4), and the bento grid collapse to fewer columns at 900px/700px/560px. Modals go full-screen below 560px. Form inputs are forced tofont-size:16pxon phones to stop iOS Safari's auto-zoom-on-focus. - Dashboard quick-actions become a sticky bottom bar on phones: below 560px,
.bento-quick(the "Schnellzugriff" tile) switches from a normal bento grid cell toposition:fixed;bottom:0, a horizontal icon-only tab bar (.bq-hdheading hidden,.bq-listbuttons go icon-over-2-line-label via-webkit-line-clamp:2), so the primary actions never require scrolling to reach..bento{padding-bottom:80px}in that same breakpoint reserves the space the fixed bar would otherwise cover — this is scoped to.bentospecifically (not a global#contentpadding) since.bento-quick/.bentoonly ever exist on the Dashboard page. - Verification gotcha #1 — internal scroll:
.contentscrolls internally (overflow-y:autoinside aheight:100vhshell), so Playwright'spage.screenshot({fullPage:true})does not capture content below the fold — only the viewport. Scroll.contentitself (document.querySelector('.content').scrollTop = ...) before screenshotting to see the rest. - Verification gotcha #2 — stale template cache:
start.pyruns withdebug=False, so Flask'sTEMPLATES_AUTO_RELOADis off and Jinja caches the compiledindex.htmlin memory for the life of the process, from the first request after startup. Editing the template while apython start.pyprocess is still running from before your edit is invisible to the browser no matter how many times you reload — you'll see stale HTML/JS and it's easy to misdiagnose as a CSS/JS bug. Always restart the server after editingtemplates/index.htmlbefore taking verification screenshots or you'll debug a ghost.
Subscription / payment scaffolding
The product owner wants the app to cost 4.99 €/month per account, provider likely PayPal (not yet integrated — no account/API keys exist yet). Since there's no live payment method, the paywall is built but wired to a global off switch so it can't lock anyone out before PayPal actually works:
einstellungenkeyabo_pflicht("1"/"0", default off) — read via_abo_pflicht_aktiv(). Toggle lives in the Backend-Einstellungen "Abo & Zahlung" card. Leave this off until PayPal is actually wired up.nutzer.abo_bis(date, NULL = never granted) /nutzer.abo_typ('kostenlos_admin'| future'paypal') track access per account._hat_aktiven_zugang(u)is the single source of truth: always true for admins, always true for the demo account (ist_demo, see "Demo account" below — it must never hit the paywall regardless ofabo_pflicht), always true whileabo_pflichtis off, otherwiseabo_bis >= today.global_auth_check()enforces this on every/api/*call except a small allowlist (/api/abo/status,/api/abo/aktivieren,/api/auth/logout) — returns 402 Payment Required (code: "abo_erforderlich") when blocked, so a blocked user can still see their status and (eventually) pay.POST /api/abo/aktivierenis an intentional stub (501, "noch nicht verfügbar") — real PayPal Checkout/Subscriptions integration replaces this later; do not fake a success response here.- Admin can grant free access via
POST /api/admin/nutzer/<uid>/abo-freischalten({monate, begruendung}, 1–12 months, extends from the later of today/existingabo_bis, admin-only, audit-logged like the other admin-dashboard actions) — this is the "1 Monat kostenlos freischalten" capability the operator asked for, surfaced as a "Freischalten" button per row in the Admin-Dashboard. - Frontend:
curAbo/aboBlockiertglobals are set ininitApp()fromGET /api/abo/status.go(name)redirects any navigation to themeinabopage whileaboBlockiertis true, so a blocked account always lands on its own status/pay screen instead of a broken page.pgMeinAbo()is that page (status, price, stub PayPal button) and doubles as the always-available "check my subscription" view when unblocked. - When actually integrating PayPal: replace
/api/abo/aktivieren's stub body with real Checkout/Subscription creation, add a webhook endpoint to updateabo_bis/abo_typon payment events, and only then flipabo_pflichtto"1".
This whole section describes pflegepilot_alpha_v2 only (_abo_pflicht_aktiv(), _hat_aktiven_zugang(), global_auth_check(), initApp() are its Flask/vanilla-JS naming). pflegepilot_v3 — now the production system the customer actually uses — diverged from this flat-price model on 2026-07-24 per direct customer pricing feedback:
- Tiered pricing, not a flat rate:
abo_preis_basis(14.99 €, covers the account plus one managed patient) +abo_preis_zusatzpatient(4.99 €/month per additional patient) replace the old singleabo_preiseinstellungen key.app/einstellungen.py: berechne_abo_preis(db, patienten_anzahl)is the one place this is computed — used by/api/abo/status(the logged-in user's own price), the admin Nutzerliste (abo_preis_aktuellper account, also drives the invoice-amount default in the Admin-Dashboard's "Rechnung" button instead of a hardcoded value), and/api/einstellungen/public(landing page). Change the twoabo_preis_*einstellungen keys to reprice; never hardcode a price string in a new call site. - No more hard patient cap:
app/routers/patienten.pypreviously refused aPOST /api/patientenpastMAX_PATIENTEN_PRO_KONTO = 3(non-admin only). Removed on 2026-07-24, same reasoning as the price change — once additional patients are a paid add-on rather than free, there's no reason to also cap them; the price itself is what scales, soberechne_abo_preisis the only enforcement point left. Don't reintroduce a numeric cap without checking whether it should instead just be a higher per-tier price. - Self-service cancellation:
nutzer.abo_gekuendigt(bool, migrationfba430fd99be) —POST /api/abo/kuendigen/POST /api/abo/reaktivieren, bothrequire_login, surfaced on themeinabopage. Cancelling does not revoke access immediately — access still runs toabo_bis(statutory "kündbar zum Ende der Abrechnungsperiode"), it only marks the account as "do not auto-renew" for whenever real recurring billing exists. Admin'sabo-freischalten(granting free months) resetsabo_gekuendigtback toFalse, since granting a fresh paid period is itself a renewal that supersedes a stale cancellation flag. - Reducing patient count naturally lowers the price at the next computation — there's no separate "cancel one additional patient" flow; deleting/archiving a patient is enough, since
berechne_abo_preisreads the current patient count live.
Ummeldung-Generator (pflegepilot_v3 only)
Added 2026-07-24 per customer feedback: when a patient moves, both the Krankenkasse and the Rentenversicherung need to be notified separately, and there was no letter generator for this (only Erstantrag/Höherstufung/Widerspruch existed). POST /api/patienten/{pid}/adressaenderung (app/routers/briefe.py) does three things in one call: updates the patient's strasse/plz/ort to the new address, and — independently toggleable per recipient (krankenkasse_benachrichtigen/rentenversicherung_benachrichtigen, both default True) — generates a PDF notification letter to the Krankenkasse and/or to a Rentenversicherungsträger, each stored as its own Antrag row (typ='adressaenderung', added to the ck_antrag_typ CheckConstraint in migration 5ff4e0a87733) via the same authorization-checked-download pattern as every other letter.
app/static_data/rentenversicherung.pyis a new static directory (DRV Bund + the 15 regional Träger, addresses sourced fromdeutsche-rentenversicherung.de's official "Anschriften-Übersicht"), mirroringkrankenkassen.py's shape/get_by_name/searchAPI. Deutsche Rentenversicherung Bund is the default recipient in the frontend — correct for the large majority of insured people since regional-Träger correspondence is generally forwarded internally; a free-text override (viaRentenversicherungAutocomplete) still works for the small number of cases where the specific regional Träger is known and preferred, falling back to the same"[Anschrift bitte ergänzen]"placeholder pattern as an unrecognized Krankenkasse name if typed manually and not found in the list.Patient.rentenversicherungsnummer(new column, same migration) is the Rentenversicherung analogue of the existingversichertennummer— optional, collected in the "Neuer Patient"-Wizard's Krankenversicherung step and reusable by any future feature needing it, not just this letter.app/briefe_text.py: build_adressaenderung_text()is shared by both recipient letters (only thekennzeichen_label/kennzeichen_wert— Versichertennummer vs. Versicherungsnummer — and the recipient block differ), rather than duplicating two near-identical templates.- The patient's previous address is captured before the DB update and shown in the letter as "Bisherige Anschrift" (or a placeholder if the patient never had a stored address) — don't reorder the update-then-read, capture old values first.
- This was, until 2026-07-24, the only way to edit a patient's address after creation — see "Patient bearbeiten" below, added the same day, which now supersedes that "first place" status.
Patient bearbeiten (pflegepilot_v3 only)
Added 2026-07-24: until then there was no way to fix a typo made at patient creation — only Pflegegrad (PATCH .../pflegegrad) and Notizen (POST .../notizen, audit-trailed via notizen_verlauf) had dedicated update routes/UI; every other field (name, address, contact, insurance) was write-once via the "Neuer Patient" wizard. PUT /api/patienten/{pid} (PatientUpdate schema, generic setattr loop in routers/patienten.py) already supported updating all of these — it just had no frontend form. app/(app)/patienten/[id]/bearbeiten/page.tsx is that form: a single-page (not multi-step, unlike the creation wizard) edit form prefilled from the current patient, "Bearbeiten" link added next to "Löschen" on the patient detail page. Deliberately excludes notizen (keeps going through its own audited flow) and patient_status/pflegegrad/foto_pfad (each has its own dedicated update path already).
Passwort-vergessen per SMTP (pflegepilot_v3 only, 2026-07-24) — deliberate exception to "no SMTP"
The operator noticed self-registered accounts had no email address at all, so a "Passwort vergessen" flow had no way to verify identity. Fixed by adding nutzer.email (unique, mandatory on new self-registrations, optional/backfillable on admin-created accounts) plus a standard token-based reset flow.
This was, until 2026-07-28, the only place in pflegepilot_v3 where the server sends email of its own accord. (The "Email: mailto hand-off, not SMTP" section further below describes pflegepilot_alpha_v2's design, not v3's — v3's own Musterbrief/Widerspruch/Ummeldung/Vollmacht generators don't send email at all, mailto or otherwise, they only offer a PDF download.) Two more proactive-email use cases were added on 2026-07-28 with operator sign-off; see "Proaktiver Server-Mailversand erweitert" below for the current, complete list and the reasoning for each. Don't add a fourth proactive-email use case without the same kind of explicit check-in — this isn't meant to become the default pattern for every notification idea.
app/email_util.py: send_email()is the one place SMTP is touched. Configured viaSMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASSWORD/SMTP_FROM/SMTP_USE_SSL/SMTP_USE_TLSenv vars (app/config.py), all optional. As long asSMTP_HOSTis unset, it only logs a warning and returns — never raises. This was deliberate, not a stopgap to remove later:POST /api/auth/passwort-vergessenmust always return the same generic response regardless of whether the email exists or whether sending succeeds (anti-enumeration), sosend_email()swallowing its own failures is exactly the right shape for that caller, not a bug.docker-compose.yml'sapi.environmentblock only passes through explicitly listed keys — adding a newSettingsfield inconfig.pyis not enough by itself, the corresponding${VAR}line must also be added toenvironment:(this bit locally the first time: the.envvalue was set correctly but never reached the container untildocker-compose.ymlitself was updated).SMTP_USE_SSL/smtp_use_sslwas added alongsidesmtp_use_tls(STARTTLS) because Strato (the operator's SMTP provider) offers both port 465 (implicit SSL,smtplib.SMTP_SSL) and 587 (STARTTLS on a plaintext-then-upgraded connection) — these are different connection modes, not interchangeable, and the operator's provider notes specified SSL/465. Live and verified 2026-07-24 against the operator's real Strato mailbox (support@db-onlinedienste.de, port 465/SSL) — both a directsend_email()call and the actual/api/auth/passwort-vergessenendpoint were tested end-to-end with no errors logged.- The real SMTP password lives in
pflegepilot_v3/secrets/SMTP-Secrets.txt(gitignored — this folder was untracked-but-NOT-ignored when the operator first created it, meaning a straygit add -Awould have committed a plaintext password; added to.gitignorein the same pass this feature shipped). Never read this file's contents into a chat response, a commit, or any other persisted location — when working with it, treat the value as write-only: read it, pipe it directly into.envvia file transfer (not inline shell arguments — special characters in the actual password broke naive shell-quoting attempts elsewhere in this project's history), never echo or log it. - Reset tokens:
secrets.token_urlsafe(32), only the SHA-256 hash is stored (nutzer.reset_token_hash/reset_token_laeuft_ab), 1-hour validity, single-use (cleared on successful reset). Same rate-limiting pattern aslogin/registrieren(is_rate_limited, keyed by IP). - The reset-link landing page (
/passwort-zuruecksetzen?token=...) had to go in(marketing)/, not(app)/— the(app)layout shows a blockingAuthOverlayfor anyone not logged in, which would swallow the page before the reset form ever rendered.(marketing)/has no such gate, which is exactly why Impressum/Datenschutz/AGB already live there. - Existing accounts created before this change have no email and can't use password-reset until one is set — either the account holder re-registers, or an admin sets
emailviaPUT /api/nutzer/{uid}(schema-ready; no dedicated admin edit-account modal exists yet in pflegepilot_v3's admin dashboard to do this through the UI — only the quick-create form and sperren/entsperren/löschen/Freischalten/Rechnung actions exist there today). Building that modal is a natural next step but wasn't part of this task.
Berechtigungsbestätigung je Patient (pflegepilot_v3 only, 2026-07-24)
Operationalizes what the ToS (Ziffer 5) and Datenschutzerklärung (Ziffer 7) already say in prose — that the Nutzer, not Pflegepilot, is responsible for having a legal basis (Einwilligung, Pflege-/Fürsorgebeziehung, or rechtliche Vertretung) to enter another person's data — as an actual captured declaration, same pattern as the Widerrufsrecht consent at registration (§ 356 BGB) added earlier the same day.
- Per patient, not once per account (deliberate choice, confirmed with the operator): each care recipient is a distinct person who may have a distinct authorization basis (e.g. Vorsorgevollmacht for one parent but not the other), so the checkbox appears on Step 6 ("Übersicht") of the "Neuer Patient" wizard every time, not just for the first patient created.
- Enforced server-side (
POST /api/patientenrejects with 400 ifberechtigung_bestaetigtisn'ttrue), not just a disabled button — same reasoning as the registration consent: a client-only gate is trivially bypassed by calling the API directly. patient.berechtigung_bestaetigt_am(migrationf552607a045e) stores the timestamp as the evidentiary record, mirroringnutzer.widerrufsverzicht_bestaetigt_am. Not exposed viaPatientOut(kept DB-only, same restraint as the Nutzer field) — query directly if ever needed.PatientCreate.berechtigung_bestaetigtis excluded from themodel_dump()spread into thePatient(...)constructor inrouters/patienten.py(it isn't a column — only_amis) — don't add fields toPatientCreatethat aren't 1:1Patientcolumns without checking this exclusion still holds.
Incident 2026-07-24: German-comma price value crashed the entire admin dashboard
app/einstellungen.py: abo_preise() used to do a bare decimal.Decimal(get_setting(...)). The admin saved the "Aufpreis je zusätzlichem Patienten" field in Backend-Einstellungen with a German-formatted value ("4,99" — comma, not dot) — completely natural input for a German user, but decimal.Decimal("4,99") raises InvalidOperation. Since abo_preise() is called from GET /api/nutzer (admin's Nutzerliste, to compute abo_preis_aktuell per account), this took down the entire admin account list with a 500 — reported by the operator as "the accounts are gone," when in fact no data was lost at all, only the listing endpoint crashed.
Fixed in two ways, not just one: _preis_decimal() normalizes , → . before parsing, and falls back to the compiled-in default on any other InvalidOperation rather than propagating the exception — a plain textarea/text-input settings field will eventually get a bad value again (typo, copy-paste artifact, whatever), and a single bad price string must never be able to crash every endpoint that happens to call abo_preise() (currently: admin Nutzerliste, /api/abo/status, /api/einstellungen/public, invoice generation). The admin Einstellungen form (app/(app)/admin/einstellungen/page.tsx) also got a small inline hint under both price fields ("bitte mit Punkt, nicht Komma") — cheap prevention, but the defensive parsing is the real fix; don't remove it even if the hint seems to make it redundant.
General lesson for this settings store: any numeric einstellungen value that gets decimal.Decimal()/int()/etc.-parsed at read time is one bad save away from taking down every caller, since get_setting() returns whatever string is stored with no validation at write time. If you add another numeric settings key, parse it as defensively as _preis_decimal() does — don't assume the stored string is well-formed just because the admin UI is "just" a plain text input.
Legal-page links inside the logged-in app shell (pflegepilot_v3 only, 2026-07-24)
Gap found by the operator: pflegepilot_alpha_v2 already has a .sb-legal sidebar link row for Impressum/Datenschutz/AGB (see that section above), but pflegepilot_v3's (app)/layout.tsx never got the equivalent — once logged in, there was no way to reach any of the three legal pages at all (they only existed via the (marketing)/ footer, which a logged-in user viewing /dashboard etc. never sees). Fixed with a small link row at the bottom of the sidebar <aside> (below the user/logout block), target="_blank" so it doesn't navigate the user away from whatever they were doing in the app. Lives in the same <aside> used for both the desktop-static and mobile-drawer sidebar, so no separate mobile handling was needed.
Widerrufsrecht-Einwilligung bei Registrierung (pflegepilot_v3 only, 2026-07-24)
Added while drafting the real Datenschutzerklärung/Nutzungsbedingungen (pflegepilot_v3/rechtstexte/, operator-authored legal text, not application code, but referenced here since it drove this feature): since the service begins performance immediately on registration (account access, letter generation) rather than after the statutory 14-day Widerrufsfrist, § 356 Abs. 5 BGB requires the consumer's express consent to that early start plus explicit confirmation that they know they lose the right of withdrawal once the service is fully performed — a generic "I accept the ToS" checkbox does not satisfy this.
- Two separate mandatory checkboxes on the registration form (
components/AuthOverlay.tsx: RegisterForm), each with its own exact legal wording (matchingNutzungsbedingungen.txtverbatim — if the ToS wording changes, update both places together): consent to early performance, and acknowledgment of losing the withdrawal right. Submit is disabled client-side until both are checked, and links to/agbopen in a new tab (target="_blank") so the registration form isn't lost. - Enforced server-side, not just client-side:
POST /api/auth/registrieren(routers/auth.py) rejects with 400 unless bothzustimmung_sofortbeginnandzustimmung_widerrufsverzichtaretrue— a client-side-only checkbox gate would be trivial to bypass by calling the API directly, which would leave no valid consent record. nutzer.widerrufsverzicht_bestaetigt_am(migration212cec88ae07) stores when this consent was given, as the evidentiary record the ToS text promises ("Der Zeitpunkt dieser Bestätigung wird zu Nachweiszwecken gespeichert") — if a customer ever disputes having lost their withdrawal right, this timestamp is the proof. Not currently surfaced in the admin UI (query the DB directly if ever needed); add a display there if that becomes a recurring need.
Krankenkassen-Stammdaten: completed against the official GKV list (2026-07-24)
A customer report of one missing insurer (BKK Gildemeister-Seidensticker) turned out to be a symptom of a bigger gap: static_data/krankenkassen.py was a curated subset (~87 entries) ported 1:1 from the old app, never checked against the actual complete set of German statutory insurers. Fixed properly rather than one entry at a time — cross-referenced against the official GKV-Spitzenverband list (93 gesetzliche Krankenkassen, Stand 2026) via a transparent-beraten.de summary PDF, then researched an individual verified address (from each insurer's own Impressum, preferring their stated Postfach/postal address over the street address where one exists) for every missing GKV entry. Now 94 German GKV entries (essentially complete — the file also carries the full existing PKV/AT-SV/CH-KVG sections unchanged).
- Three pre-existing data errors found and fixed in the same pass (not just gaps):
"AOK Berlin-Brandenburg"was a duplicate of"AOK Nordost – Die Gesundheitskasse"under an outdated name, and — worse — the other AOK Nordost entry itself had the wrong address (a regional Geschäftsstelle in Rostock instead of the actual Hauptsitz, Wilhelmstraße 1, 10963 Berlin); merged into one correct entry."IKK Nord"was renamed to"IKK – Die Innovationskasse"back on 2021-02-01 — the file still had the old name and, it turned out, the wrong city too."actimonda BKK"merged into"BIG direkt gesund"(already in the list) on 2021-01-01 and no longer exists as a separate insurer — removed rather than left as a dead end that would silently address mail to a defunct entity. Lesson for future maintenance of this file: a Krankenkasse name/address found once and never re-verified can go stale via mergers/renames — if a customer reports a wrong address (not just a missing one), check for exactly this pattern before assuming it's just outdated data entry. briefe_text.py: _kk_adresse()was fixed to skip the street line entirely whenstrasseis empty, rather than emitting a blank first line — needed because several insurers (e.g.Mercedes-Benz BKK,BKK Salzgitter) use a dedicated large-recipient postal code with no street at all (same pattern already handled for Rentenversicherung's_rv_adresse(), e.g. DRV Bund).- Many entries prefer a Postfach address over the street address where the insurer's own Impressum lists one as the designated correspondence address (mirroring the DRV Bund precedent) — don't "simplify" these back to a street address without checking the source first.
- This list still won't stay perfectly current forever — Krankenkassen merge and rename periodically (as the IKK/actimonda cases above show happened within the last few years). No automated staleness check exists; re-verifying against the GKV-Spitzenverband list is a manual, occasional task.
Budget-Tracker & Krisen-Checklisten (pflegepilot_v3 only, 2026-07-28)
Zwei Tester-Feedback-Punkte, beide unabhängig voneinander:
- Budget-Tracker (
leistungen.budget_jahr+ neue Tabelleleistung_ausgaben, Endpoints inrouters/leistungen.py, UI-Komponentecomponents/BudgetTracker.tsxje Leistung in der Patientenakte): Jahresbudget + Ausgabenerfassung pro Leistung, zeigt den Restbetrag mit einem festen Verfallshinweis "30.06. des Folgejahres" (passend zum bestehenden Entlastungsbetrag-Übertrag im Katalog). Bewusst kein Mehrjahres-Rollover-Ledger (z. B. getrennte Buchführung "verfallener Rest aus Vorjahr" vs. "aktuelles Jahresbudget") — das wäre für eine "kurze visuelle Übersicht" (Wortlaut des Tester-Feedbacks) überkonstruiert; bei Bedarf später ausbaufähig. - Krisen-Checklisten (
static_data/krisenguides.py, EndpointGET /api/krisenguides, Seite/guides): vier Schritt-für-Schritt-Anleitungen (plötzliche Hospitalisierung, Sturz/Notfall, Tod des Angehörigen, Ausfall der Pflegeperson) — Liste mit dem Betreiber abgestimmt, da der Tester nur ein Beispiel nannte und um Ausarbeitung bat. Reiner Content, kein Rechtstext — ersetzt nie den Notruf 112.
Proaktiver Server-Mailversand erweitert (pflegepilot_v3 only, 2026-07-28)
Tester-Feedback bat um zwei neue proaktive Benachrichtigungen (Leistungskatalog-Änderung, tägliche Tagebuch-Erinnerung), beide strukturell ohne jede Nutzeraktion, an die sich ein Versand anhängen ließe. Wichtig zur Einordnung: pflegepilot_v3s Musterbrief/Widerspruch/Ummeldung/Vollmacht-Generatoren haben kein Mailversand-Feature jeglicher Art (weder mailto noch SMTP) — sie erzeugen serverseitig ein PDF und bieten nur "PDF öffnen" zum Download an (die mailto:-Weiterleitung aus dem Abschnitt "Email: mailto hand-off, not SMTP" unten ist eine Beschreibung von pflegepilot_alpha_v2, nicht von v3 - dort erkennbar an templates/index.html/sendMailModal(), alten vanilla-JS-Bezeichnern). Die beiden neuen Punkte hier sind also nicht "eine dritte Ausnahme neben mailto", sondern schlicht die ersten beiden Fälle in v3, in denen serverseitig überhaupt eine Mail verschickt wird, außer dem Passwort-Reset. Vom Betreiber ausdrücklich freigegeben, nicht eigenmächtig entschieden. Die vollständige, aktuelle Liste proaktiver Mailversand-Faelle in v3:
- Passwort-Reset (siehe "Passwort-vergessen per SMTP" oben) — die ursprüngliche, einzige Ausnahme.
- Leistungskatalog-Änderungsbenachrichtigung (
app/katalog_watch.py) — vergleicht bei jedem App-Start den aktuellenKATALOG-Stand (static_data/katalog.py) gegen einen inkatalog_snapshotsgespeicherten Schnappschuss (Betrag/Voraussetzung/Rechtsgrundlage jekatalog_id). Weicht ein Eintrag ab, werden alle Nutzer benachrichtigt, die diese Leistung einem Patienten zugewiesen haben (leistungen.katalog_id) — sowohl der Nutzer selbst als auch der Patient, falls dieser eine eigene E-Mail hat. Erster Lauf nach Deploy legt nur den Baseline-Schnappschuss an, ohne zu benachrichtigen (sonst würde der komplette Katalog beim allerersten Start als "geändert" gemeldet). - Tägliche Tagebuch-Check-in-Erinnerung (
app/reminder_scheduler.py) — je Nutzer ein/ausschaltbar mit frei wählbarer Uhrzeit (nutzer.reminder_aktiv/reminder_uhrzeit, Einstellungsseite unter/einstellungen). Da diese App bewusst keine Scheduler-/Cron-Infrastruktur hat (der Demo-Reset läuft absichtlich über einen Lazy-Check bei jedem Request statt über einen eigenständigen Hintergrundprozess, siehe "Demo account" unten), war das die erste Stelle, an der eine echte, uhrzeitgenaue Wiederkehr gebraucht wurde. Gelöst mit einem simplen Python-Bordmittel-Hintergrundthread (kein neuer Dependency wie APScheduler/Celery) statt echtem Cron.
Beide neuen Mechanismen teilen dasselbe Multi-Worker-Problem und denselben Lösungsansatz: Gunicorn startet mehrere Worker-Prozesse, die denselben Startup-/Thread-Code alle gleichzeitig ausführen würden — ohne Schutz gäbe es doppelte Benachrichtigungsmails (dieselbe Klasse von Bug wie der dokumentierte Changelog-Duplikat-Vorfall, nur diesmal mit einem Seiteneffekt statt einem reinen Insert, wo ON CONFLICT DO NOTHING nicht ausreicht). Beide nutzen deshalb einen Postgres-Advisory-Lock (pg_try_advisory_lock, je ein fest gewählter, eindeutiger Integer-Schlüssel): bei katalog_watch.py nur für die Dauer eines einzelnen Abgleichs beim Start, bei reminder_scheduler.py über die gesamte Lebensdauer des Hintergrund-Threads (Lock hängt an einer dauerhaft offenen Connection). Nur der Worker, der den Lock zuerst bekommt, macht die Arbeit; alle anderen überspringen sie für diesen Prozess-Start vollständig — ausreichend, da genau ein Worker im gesamten Container die Aufgabe übernehmen muss, nicht jeder einzelne.
app/email_util.py: send_email() bleibt die einzige Stelle, an der SMTP angefasst wird, für alle drei Fälle gemeinsam genutzt.
Umkreissuche für lokale Pflege-Dienstleister (pflegepilot_v3 only, 2026-07-28, T10–T13)
Tester-Feedback wollte eine Umkreissuche nach PLZ für Pflegedienste, Sanitätshäuser, diverse Wohnformen (Pflegeheim, betreutes Wohnen, Tagespflege, Demenz-Wohngruppe, soziales Café) und Therapie/Reha-Einrichtungen (Logopädie, Physiotherapie, Reha-Zentrum). Anders als bei Krankenkassen/Rentenversicherung (ein paar Dutzend bis ~100 Einträge, handkuratierbar) gibt es hier mehrere Tausend Anbieter bundesweit — eine statische Liste war nie eine Option.
- Datenquellen-Entscheidung, bewusst vom Betreiber getroffen (nicht von Claude): OpenStreetMap statt Google Places API — kostenlos, kein API-Key/Account/Zahlungsmethode nötig, dafür lückenhaftere/uneinheitlicher gepflegte Daten als ein kommerzieller Dienst. Zwei separate OSM-Dienste sind beteiligt: Nominatim (PLZ → Koordinaten) und Overpass API (
overpass-api.de, getaggte Einrichtungen im Umkreis). Beide sind offene Community-Infrastruktur ohne Key — dafür ist der öffentliche Overpass-Mirror gelegentlich träge/kurzzeitig überlastet, siehe Betriebshinweis unten. app/umkreissuche.pyist die zentrale Logik:KATEGORIEN-Registry (Kategorie-Key → Label + Liste von Overpass-Tag-Filtern),_geocode_plz(),_overpass_suche()(baut die Overpass-QL-Query, ein Retry-Versuch mit 2s Pause gegen transiente Timeouts des öffentlichen Mirrors),suche()(Ergebnis-Parsing, Haversine-Distanzberechnung, Sortierung nächste zuerst, gekappt auf 30 Treffer). Bewusst als Registry statt if/elif-Kette — T10 legte nurpflegedienstan, T11–T13 haben ausschließlich neueKATEGORIEN-Einträge ergänzt, ohne Suchlogik/Router/Frontend anzufassen (Frontend-Kategorieauswahl ist datengetrieben überGET /api/umkreissuche/kategorien, brauchte für T11–T13 keine einzige Zeile Änderung).- OSM-Tags je Kategorie recherchiert gegen OSM-Wiki (
DE:Tag:...-Seiten) und taginfo.openstreetmap.org, nicht geraten:amenity=social_facility+social_facility=ambulatory_care(Pflegedienst),shop=medical_supply(Sanitätshaus),social_facility=nursing_home/assisted_living/day_care/group_home+for=dementia(Wohnformen — jeweils zusätzlich das ältere, laut Wiki noch nicht vollständig migrierteamenity=nursing_home/retirement_home-Tag mitabgefragt, damit nicht-migrierte Bestandsdaten nicht verloren gehen),healthcare=speech_therapist/physiotherapist/rehabilitation(Therapie/Reha). - Zwei bekannte, bewusste Lücken statt vorgetäuschter Vollständigkeit: "Soziales Café" hat kein verlässliches OSM-Tag — behelfsweise über
amenity=community_centreabgebildet, Label sagt explizit "(geringe Datenlage)". "Reha-Sport" wurde als eigene Kategorie ausgelassen — es gibt dafür überhaupt kein brauchbares OSM-Tag (Kurse finden meist in normalen Sportvereinen/Fitnessstudios ohne eigenen kartierbaren Ort statt); eine Kategorie mit strukturell unzuverlässiger Trefferquote anzubieten wäre schlechter, als sie wegzulassen. pflegedienst_24h(24h-Pflegedienst/Betreuung rund um die Uhr, ergänzt 2026-07-29 nach weiterem Tester-Feedback): gegen das OSM-Wiki recherchiert — es gibt kein eigenes Tag dafür; korrekt wäreambulatory_care+opening_hours=24/7, aberopening_hourswird bei Pflegediensten in der Praxis so gut wie nie gepflegt, eine reine Tag-Suche darauf liefert praktisch keine Treffer. Stattdessen ein Namens-Musterfilter auf denselbenambulatory_care-Treffern (["name"~"24.?h|24.?stunden",i]) — 24h-Anbieter/-Vermittler tragen "24h"/"24-Stunden" in aller Regel im eigenen Firmennamen (Branchenusus). Gleiche Kompromiss-Logik wie bei "Soziales Café", Label ebenfalls explizit mit "(geringe Datenlage)" markiert. Live gegen die echte Overpass-API verifiziert (nach anfänglichen 429/504 durch den ohnehin knappen Rate-Limit des öffentlichen Mirrors, 2 gleichzeitige Slots pro IP, siehe Betriebshinweis unten): die Suche lief für PLZ 10115/30 km fehlerfrei durch (0 Treffer) — bestätigt sowohl die korrekte Query-Syntax als auch die erwartete geringe Datenlage, kein Zeichen eines Fehlers im Code selbst.- Betriebshinweis: Der öffentliche Overpass-Mirror antwortet bei schnell aufeinanderfolgenden Anfragen (z.B. Kategorie-Wechsel im Sekundentakt) gelegentlich mit einem Timeout, obwohl er grundsätzlich erreichbar ist — beim Testen dieser Funktion mehrfach beobachtet und durch einfaches erneutes Anfragen (bzw. den eingebauten Retry) bestätigt transient. Kein Zeichen für einen echten Ausfall; wird sauber als 502 mit deutscher Fehlermeldung an den Nutzer durchgereicht, nicht als 500-Crash.
- Router
app/routers/umkreissuche.py:GET /api/umkreissuche/kategorien(Liste fürs Frontend-Select),GET /api/umkreissuche?plz=&kategorie=&radius_km=(5-stellige PLZ, Radius aus{5,10,15,25,50}, beides serverseitig validiert). Frontend:lib/umkreissuche.ts+(app)/umkreissuche/page.tsx, Nav-Eintrag unter "Verwaltung". - Favoriten sind bewusst je Nutzerkonto, nicht je Patient (explizite Vorgabe des Betreibers) — ein gefundener Pflegedienst/Sanitätshaus/etc. ist typischerweise für alle vom Konto verwalteten Patienten gleichermaßen relevant, nicht auf einen einzelnen beschränkt. Modell
UmkreissucheFavorit(umkreissuche_favoriten-Tabelle, Migrationa7c4e9f1b2d3),UniqueConstraint(nutzer_id, osm_type, osm_id)verhindert Duplikate und machtPOST /api/umkreissuche/favoritenidempotent (erneutes Merken desselben OSM-Objekts gibt den bestehenden Favoriten zurück statt einen Fehler zu werfen). Name/Adresse/Kontakt werden beim Merken kopiert (nicht live nachgeladen), damit ein Favorit auch anzeigbar bleibt, wenn Overpass gerade down ist oder der Eintrag in OSM verschwindet — das ist auch der Grund, warum jeder Umkreissuche-Treffer jetztosm_type+osm_idmitliefert (eindeutiger, stabiler Schlüssel für "ist das schon gemerkt?"-Abgleich im Frontend).GET/POST/DELETE /api/umkreissuche/favoriten[/{id}], allerequire_login-geschützt und implizit auf den eigenen Nutzer beschränkt (keinpatient_id-Bezug irgendwo in diesem Datenfluss). - Rechtstexte aktualisiert (2026-07-28):
rechtstexte/Datenschutzerklaerung.txtbekam eine neue Ziffer 11 ("Umkreissuche für Pflegedienste, Sanitätshäuser und weitere Einrichtungen"), die den Nominatim-/Overpass-Datenfluss offenlegt (nur PLZ+Kategorie+Radius werden übermittelt, serverseitig — die Nutzer-IP erreicht diese Dienste nie; kein AVV nach Art. 28 DSGVO möglich, da es sich um freie Gemeinschaftsinfrastruktur ohne Vertragspartner handelt, daher als eigenständige Drittübermittlung nach Art. 6 Abs. 1 lit. b/f DSGVO eingeordnet), alle Folgeziffern wurden entsprechend hochgezählt (12–18).Nutzungsbedingungen.txtbekam einen Feature-Bullet in Ziffer 2.1 plus einen Haftungsausschluss in Ziffer 11.6 für die Richtigkeit der von OSM stammenden Anbieterdaten. Wichtig: diese.txt-Dateien sind nur die Betreiber-Quelltexte — die tatsächlich angezeigten Rechtstexte liegen alseinstellungen-Keys (rechtstext_datenschutz/rechtstext_agb) in der DB und werden manuell über die Backend-Einstellungen im Admin-UI gepflegt (siehe "Admin focus, self-registration & legal pages"); es gibt keinen automatischen Datei→DB-Sync wie beim Changelog. Der Betreiber muss die aktualisierten Texte nach diesem Änderungsdurchlauf selbst ins Admin-UI übertragen, damit sie live wirksam werden.
Benachrichtigung bei Änderung eines Umkreissuche-Favoriten (pflegepilot_v3 only, 2026-07-29, T15)
Letzter Punkt aus dem ursprünglichen Tester-Feedback-Backlog: wenn sich Kontaktdaten eines gemerkten Umkreissuche-Treffers (Pflegedienst, Sanitätshaus, Wohnform, Therapie-/Reha-Einrichtung — siehe "Umkreissuche für lokale Pflege-Dienstleister" oben) in OSM ändern, wird der Kontoinhaber per E-Mail informiert.
app/umkreis_favoriten_watch.py, dritte Ausnahme vom "mailto statt SMTP"-Prinzip (nach Passwort-Reset und T14/Katalog-Änderung) — es gibt keine Nutzeraktion, an die sich der Versand hängen ließe.- Läuft als echter wiederkehrender Hintergrundjob (wie
reminder_scheduler.py), nicht nur einmalig pro App-Start wiekatalog_watch.py— ein OSM-Eintrag kann sich jederzeit ändern, nicht nur bei einem Deploy dieser App. Bewusst nur einmal täglich: der öffentliche Overpass-Mirror hat ein sehr knappes Rate-Limit — real erlebt während der Entwicklung dieses Features: nur 2 gleichzeitige Slots pro IP (curl https://overpass-api.de/api/status) —, eine häufigere Abfrage aller Favoriten aller Konten wäre unverhältnismäßig für eine kostenlose Gemeinschaftsinfrastruktur. - Sammel-Abfrage statt einer Anfrage je Favorit: alle Favoriten desselben
osm_type(node/way/relation) werden in möglichst wenigen Overpass-Anfragen per ID-Filter abgeholt (node(id:1,2,3);, siehe OSM-Wiki "Overpass QL – By element ID"), in Chunks von 100 IDs mit 2s Pause dazwischen — bei z. B. 50 gemerkten Favoriten sonst 50 statt 1-3 Anfragen pro Durchlauf. - Gleiches Multi-Worker-Problem/-Lösung wie bei allen anderen Hintergrund-Schedulern dieser App: Postgres-Advisory-Lock (
519274863) über die gesamte Thread-Lebensdauer. - Verglichene Felder: Name, Straße, PLZ, Ort, Telefon, E-Mail, Webseite. Bei Abweichung wird der gespeicherte Favorit-Snapshot auf den neuen Stand aktualisiert (sonst würde dieselbe Änderung an jedem folgenden Tag erneut gemeldet) und eine E-Mail an den Kontoinhaber verschickt — mehrere geänderte Favoriten desselben Kontos werden zu einer einzigen Mail pro Durchlauf gebündelt statt mehrerer Einzelmails.
- Bewusste Einschränkung: ist ein Favorit in OSM komplett nicht mehr auffindbar (gelöscht), wird das nur geloggt, nicht dem Nutzer gemeldet — ein "verschwunden statt geändert"-Notify-Flow bräuchte eine zusätzliche Schutzmaßnahme gegen tägliche Wiederholungsmeldungen (z. B. ein neues Schema-Feld oder automatisches Löschen des Favoriten), was für den ursprünglichen Tester-Wunsch ("Kontaktdaten geändert") überkonstruiert wäre.
- End-to-end gegen einen echten, produktiv gemerkten Favoriten verifiziert (nicht nur synthetisch): Overpass-ID-Abfrage lief mit HTTP 200 durch, die live aus OSM gelesenen Werte stimmten mit dem gespeicherten Stand überein — kein Fehlalarm, keine E-Mail ausgelöst, Datensatz unverändert.
Umkreissuche: Kategorie "24h-Pflegedienst" (pflegepilot_v3 only, 2026-07-29)
Zusätzliches Tester-Feedback nach Abschluss von T8/T10-T13: pflegedienst_24h als neue Kategorie in derselben KATEGORIEN-Registry (app/umkreissuche.py) — siehe dortigen Eintrag unter "Umkreissuche für lokale Pflege-Dienstleister" für die Details (kein eigenes OSM-Tag, Namens-Musterfilter statt Tag-Filter, "(geringe Datenlage)" im Label). Da die Frontend-Kategorieauswahl datengetrieben über GET /api/umkreissuche/kategorien läuft, war dafür keine einzige Frontend-Codezeile nötig — reine Backend-Registry-Ergänzung, gleiches Muster wie T11-T13.
Landingpage-Überarbeitung + SEO (pflegepilot_v3 only, 2026-07-29)
Betreiber-Wunsch: die Startseite einladender gestalten, die Feature-Breite der App sichtbar machen ("was er für sein bisschen Geld alles bekommt"), dabei keine Backend-/Technik-Details offenlegen, für SEO optimieren und wo sinnvoll Admin-editierbar machen.
(marketing)/page.tsxvon einer Client-Component ("use client", React-Query-Hook) zu einer async Server Component umgebaut — das war die Voraussetzung für echtes SEO: eine"use client"-Seite kann keingenerateMetadata()exportieren (Next.js verlangt dafür eine Server Component), und ein clientseitig nachgeladener Seiteninhalt ist für Suchmaschinen-Crawler weniger zuverlässig als serverseitig bereits im initialen HTML vorhandener Text. Die Seite brauchte ohnehin keine Interaktivität (nur<Link>, keine Events/State) — der Umbau war deshalb reine Entfernung von"use client"plus Umstellung vonusePublicEinstellungen()(Client-Hook) auf einen eigenen serverseitigenfetch()-Helper (ladeOeffentlicheEinstellungen()), den sowohlgenerateMetadata()als auch die Seite selbst aufrufen — Next.js dedupliziert identischefetch()-Aufrufe automatisch innerhalb desselben Requests, kein doppelter Roundtrip.- Neue Umgebungsvariable
API_INTERNAL_URL(nichtNEXT_PUBLIC_API_URL) für diesen serverseitigen Fetch — wichtiger Unterschied, der beim Bauen erst durch Nachschauen in der echten NAS-.envauffiel:NEXT_PUBLIC_API_URList dort bewusst leer (der externe Zugriff läuft über Pfad-Routing im Reverse-Proxy, der Browser ruft/api/*relativ zur selben Domain auf, siehe "Externer Zugriff über Nginx Proxy Manager" in der NAS-Deployment-Memory) — ein serverseitigerfetch()auf einen relativen Pfad hätte keine Basis-URL und würde fehlschlagen. Und selbst ein gesetzter Wert wäre falsch gewesen:localhost:8100ist nur der host-seitig gemappte Port, aus einem anderen Container (hier:web) heraus nicht erreichbar. Der korrekte Weg für Container-zu-Container-Kommunikation ist der interne Docker-Compose-Servicename (API_INTERNAL_URL=http://api:5050indocker-compose.yml, Default über${API_INTERNAL_URL:-http://api:5050}auch ohne.env-Eintrag) — gilt identisch für lokale Entwicklung und die NAS, da beide dieselbedocker-compose.ymlmit denselben Servicenamen nutzen. Per direktemnode -e "fetch(...)"im laufendenweb-Container sowie durch einen echten DB-Testwert (landing_seo_title, hinterher wieder entfernt) end-to-end verifiziert. Der Fetch nutztcache: "no-store"statt einer Revalidate-Frist — beim Testen blieb ein per DB-Testwert geänderter Seiteninhalt trotznext: { revalidate: 300 }und mehrfacher Anfragen im Abstand von Sekunden bis Minuten auf dem alten Stand (vermutlich eine Turbopack-Dev-Mode-Eigenheit), sodass ein Admin nach dem Speichern in den Backend-Einstellungen nicht zuverlässig sofort das Ergebnis gesehen hätte — bei so geringem Traffic auf einer einzelnen Landingpage ist der Verzicht auf Caching hier vernachlässigbar. - Feature-Liste erheblich erweitert (von 6 auf 10 Kacheln) — deckt jetzt alle inzwischen gebauten Funktionen ab (Vollmacht/Patientenverfügung, Ummeldung, Umkreissuche mit Favoriten, Dokumenten-/eGK-Scan, Budget-Tracker, Krisen-Checklisten), nicht nur den Stand von Phase 3. Bewusst keine Technik-/Backend-Begriffe im Text (kein "OpenStreetMap", "Tesseract", "PostgreSQL" etc.) — nur nutzerorientierte Nutzenbeschreibung.
- SEO-Metadaten:
generateMetadata()liefert Titel/Beschreibung/OpenGraph/Twitter-Card/kanonische URL, mit Fallback auf fest hinterlegte Standardtexte, falls die Einstellungen (noch) leer sind oder die API einmal nicht erreichbar ist (try/catch, gibt dann{}zurück statt die Seite abstürzen zu lassen). Zusätzlich einSoftwareApplication-JSON-LD-Block (schema.org) mit Preis aus den echtenabo_preis_*-Einstellungen, nicht hartkodiert. src/app/robots.tsundsrc/app/sitemap.tsneu (existierten vorher gar nicht) —robots.tserlaubt explizit nur die öffentlichen Marketing-Seiten (/,/impressum,/datenschutz,/agb) und disallowed explizit jede bekannte(app)-Route; bewusst zwei explizite Listen statt eines einzigendisallow:"/"mit Allow-Ausnahmen, damit eine künftig neu hinzukommende(app)-Route nicht versehentlich indexiert wird, nur weil sie vergessen wurde, in eine Disallow-Liste einzutragen. Neue Env-VarNEXT_PUBLIC_SITE_URL(Defaulthttps://meinpflegefuchs.de) für kanonische URLs/OpenGraph/Sitemap-Domain.- Modularität ins Admin-Dashboard eingebaut, wie gewünscht — aber bewusst nur für Hero-Text und SEO-Meta, nicht für die Feature-Kacheln selbst: vier neue
einstellungen-Keys (landing_hero_titel,landing_hero_text,landing_seo_title,landing_seo_description), als neue Karte "Landingpage & SEO" in den Backend-Einstellungen (admin/einstellungen/page.tsx) editierbar — genau das bereits bestehende generische Key-Value-Muster (einstellungen_lesen/einstellungen_speicherninrouters/admin.pynehmen ohnehin jeden beliebigen Key an, keine Backend-Änderung dort nötig), plus Aufnahme der vier neuen Keys in die_PUBLIC_KEYS-Allowlist von/api/einstellungen/public. Die zehn Feature-Kacheln selbst bleiben bewusst im Code fest verdrahtet (kein dynamischer Kachel-Editor/CRUD im Admin-UI) — sie sind 1:1 an echte App-Funktionen gebunden, ein frei editierbarer Marketingtext könnte sonst leicht von der tatsächlichen Funktion abweichen; der Aufwand für ein vollwertiges Kachel-CRUD (Icon-Auswahl, Reihenfolge, …) stünde zudem in keinem Verhältnis zu Marketingtext, der selten geändert wird. - Betriebs-Lehre aus dem Deploy dieser Änderung: die bisherige Annahme "
docker compose up -d --force-recreate webreicht immer" (siehe NAS-Deployment-Notizen) stimmte diesmal nicht — ein per DB-Testwert erwartbar geänderter Seiteninhalt blieb nach einem normalen Force-Recreate zunächst auf dem alten Stand hängen (vermutlich ein von Turbopack/Next.js gehaltener Fetch-Cache, der einen reinen Force-Recreate übersteht, kombiniert mit Docker Composes Standardverhalten, anonyme Volumes beim Neuerstellen vom alten Container zu übernehmen statt sie neu anzulegen). Tatsächlich wirksam war erstdocker compose build webgefolgt vondocker compose up -d --force-recreate --renew-anon-volumes web. Vorsicht:--renew-anon-volumesohne vorherigen Rebuild reißtnode_modulesweg (liegt selbst in einem anonymen Volume, das nur beim Image-Build aus dem Image befüllt wird) — immer erst bauen, dann mit--renew-anon-volumesneu erstellen, nie isoliert. Für reine Code-Änderungen ohne diese Cache-Problematik reicht weiterhin das einfachere--force-recreateallein.
Kalender-Export für Aufgaben (pflegepilot_v3 only, 2026-07-28)
Nicht aus dem Tester-Feedback, sondern eine Anschlussfrage des Betreibers zu T15/Kalenderthemen: pro fälliger Aufgabe zwei kleine Buttons ("Zum Kalender hinzufügen") auf der Aufgaben-Seite, mit den bekannten Apple-/Google-Icons (IconBrandApple/IconBrandGoogle, @tabler/icons-react — keine mehrfarbigen Marken-SVGs eingebettet, monochrome Icons reichen für die Wiedererkennung).
- Ein iCal-Format deckt beide Anbieter ab — Google Calendar kann
.ics-Dateien direkt importieren, es brauchte also keine separate "Google-Integration":GET /api/aufgaben/{aid}/ics(app/routers/aufgaben.py) liefert ein minimalesVCALENDAR/VEVENT(ganztägig amfaellig_am-Datum,DTEND= Folgetag nach iCal-Konvention für Ganztags-Events), normal sessionauthentifiziert (kein Token/Abo-Mechanismus nötig, da es ein direkter Klick-Download ist, keine wiederkehrend abgerufene Abo-URL) — Ownership-Check via neuem_owned_aufgabe_or_404()-Helper (aus dem bereits existierenden Inline-Check inerledigt()extrahiert). Sonderzeichen (Komma/Semikolon/Backslash/Zeilenumbruch) werden über_ics_escape()nach RFC 5545 escaped; Zeilen-Folding (>75 Oktette) bewusst weggelassen, da für die kurzen Titel/Beschreibungen dieser App nicht praxisrelevant. - Der Google-Button braucht gar keinen Backend-Aufruf: Googles öffentliches, unauthentifiziertes "Quick Add"-URL-Schema (
calendar.google.com/calendar/render?action=TEMPLATE&text=&dates=&details=) wird rein clientseitig aus den bereits geladenen Aufgaben-Daten gebaut (lib/aufgaben.ts: googleCalendarUrl()), kein OAuth/API-Key/Google-Cloud-Projekt nötig. - Bewusst kein Abo-Feed (kontinuierlich synchronisierender externer Kalenderabo-Link) — das wäre die teurere, im Gespräch verworfene Alternative gewesen (bräuchte einen eigenen, regenerierbaren Token pro Nutzerkonto, da Kalender-Clients Abo-URLs ohne Session-Cookie periodisch selbst abrufen). Aktuelle Lösung ist bewusst ein einmaliger Pro-Aufgabe-Export, kein laufend aktualisiertes Abo.
PLZ→Ort-Autofill bei Nutzer- und Patienten-Anlage (pflegepilot_v3 only, 2026-07-28)
Sobald eine 5-stellige Postleitzahl eingegeben ist, wird das Ort-Feld automatisch übernommen — auf der Selbstregistrierung (Rechnungsanschrift, AuthOverlay.tsx), im "Neuer Patient"-Assistenten (patienten/neu/page.tsx, Step 2) und im Patient-Bearbeiten-Formular (patienten/[id]/bearbeiten/page.tsx).
Ort ist ein von der PLZ abgeleitetes Feld, kein einmaliger Vorschlag (Betreiber-Vorgabe): vollständige, bekannte PLZ → Ort wird übernommen, auch wenn vorher ein anderer Wert dort stand; unvollständige oder gelöschte PLZ → Ort wird automatisch geleert. Damit bleibt bei einem Vertipper nie ein zur PLZ nicht mehr passender Ort stehen, den man sonst manuell erst löschen müsste — Korrektur der PLZ genügt, der Ort folgt automatisch mit. Das Ort-Feld bleibt trotzdem ein normales, editierbares Textfeld (kein Dropdown/readonly) — für die seltenen Fälle, in denen die PLZ nicht in der Datenbank steht oder der Vorschlag falsch ist, kann der Nutzer weiterhin manuell eintragen; dieser manuelle Wert bleibt nur so lange bestehen, bis die PLZ das nächste Mal geändert wird.
- Datenbasis statt Live-Geocoding: anders als bei der Umkreissuche (dort bewusst Nominatim/Overpass live, da Umkreissuche echte Koordinaten braucht) ist PLZ→Ort eine stabile, sich praktisch nie ändernde Zuordnung — dafür passt eine gebündelte, lokale Datei besser als ein externer Live-Call bei jedem Tastenanschlag: schneller, kein Rate-Limit-Risiko, keine Abhängigkeit von einem Drittserver für einen so grundlegenden Formular-Baustein.
app/static_data/plz_orte.json(~9.000 Einträge) + dünner Loaderplz_orte.py, Quelle: GeoNames.org Postal-Code-Export für Deutschland (download.geonames.org/export/zip/DE.zip, CC BY 4.0). - Grosskunden-PLZ-Problem: der GeoNames-Rohexport (~10.800 Zeilen) enthält neben normalen Wohnort-PLZ auch ca. 1.800-2.000 deutsche Grosskunden-PLZ (dedizierte Einzelfirma-Postleitzahlen, z. B. „10875" ausschliesslich für Mercedes-Benz-Gesellschaften in Stuttgart) — bei diesen steht im Ortsnamen-Feld ein Firmen-/Behördenname statt eines echten Orts. Über eine Firmennamen-Erkennung (Rechtsformen wie GmbH/AG/KG, Behördenbegriffe wie Finanzamt/Ministerium/Bezirksregierung, Kassen/Verbände/Kammern etc.) grösstenteils herausgefiltert — nicht perfekt (vereinzelte reine Regierungssitz-PLZ ohne echten Orts-Kandidaten, z. B. „01095" → Sächsische Staatskanzlei, können noch falsch zugeordnet sein), aber für eine Autofill-Komfortfunktion ausreichend, da der Vorschlag jederzeit überschreibbar ist.
- Mehrdeutige PLZ (ca. 2.000 Fälle, meist ländliche PLZ, die mehrere Nachbargemeinden/Ortsteile abdecken, z. B. Dresdner Stadtteile) lösen sich auf den kürzesten Kandidatennamen auf (meist der schlichte Orts-/Stadtname ohne Ortsteil-Zusatz) — bewusst kein Mehrfachauswahl-Dropdown, da für eine einfache Autofill-Funktion nicht gerechtfertigt.
GET /api/plz/{plz}(app/routers/plz.py) ist bewusst ohne Login-Pflicht — die Selbstregistrierung braucht die Autofill-Funktion, bevor überhaupt ein Konto existiert; keine sensiblen Daten involviert (reine öffentliche Postleitzahl-Referenzdaten), gleiches Risikoprofil wie/api/einstellungen/public.- Frontend:
lib/plz.ts: usePlzSync(plz, setOrt, aktiv=true). Deraktiv-Parameter existiert ausschliesslich fuer das Bearbeiten-Formular: dort ist beim Laden bereits eine vorhandene, potenziell korrekte Adresse im Formular (die nicht zwingend aus dieser PLZ-Datenbank stammt) — ohne Schutz wuerde das blosse Oeffnen der Seite den bestehenden Ort sofort mit dem Datenbank-Vorschlag ueberschreiben, noch bevor der Nutzer irgendetwas angefasst hat. Deshalb bekommtusePlzSyncdort zusaetzlich einenplzBeruehrt-State (gleiches Muster wie das bereits bestehendeversichertennummerBeruehrt), der erst per PLZ-Feld-onChangeauftruegesetzt wird - die Sync-Logik (uebernehmen/leeren) greift dann erst ab der ersten echten Nutzereingabe. Die beiden Anlage-Formulare (Registrierung, "Neuer Patient") haben keine vorhandene Adresse zu schuetzen und lassenaktiveinfach auf dem Defaulttrue. - Datei ist eine einmalige Momentaufnahme (Stand 2026-07-28) — Postleitzahlen ändern sich in Deutschland selten, aber nicht nie (Neubaugebiete, Gemeindefusionen); keine automatische Aktualisierung eingerichtet, bei Bedarf manuell gegen einen neuen GeoNames-Export re-generieren.
Patientenverfügung-Generator (pflegepilot_v3 only, 2026-07-28)
Fünfter Dokument-Generator neben Widerspruch/Musterbrief/Ummeldung/Vollmacht — auf ausdrücklichen Wunsch als mehrstufiges Interview statt eines einzelnen Formulars, weil vor der PDF-Erzeugung inhaltlich einiges abgeklärt werden muss (in welchen Situationen soll die Verfügung gelten, welche medizinischen Maßnahmen in diesen Situationen gewünscht/abgelehnt werden, Organspende, Bezug zu einer bereits hinterlegten Vollmacht etc.).
- Formulierungen sind wortgetreu aus der offiziellen Broschüre "Patientenverfügung" des Bundesministeriums der Justiz übernommen (bmj.de, Abschnitt 2 "Textbausteine Patientenverfügung", Stand Januar 2023 laut dem PDF selbst) — siehe
app/static_data/patientenverfuegung_textbausteine.pyfür die vollständige Begründung, warum das zulässig ist: die Arbeitsgruppe "Patientenautonomie am Lebensende" hat diese Textbausteine ausdrücklich als Formulierungshilfen zur wörtlichen Übernahme durch Bürgerinnen und Bürger veröffentlicht — genau das ist der Verwendungszweck hier, kein Zitat im Sinne des Urheberrechts. Nicht selbst formuliert, sondern per PyMuPDF (fitz) aus dem offiziellen PDF extrahiert und 1:1 geprüft, um keine eigene, rechtlich unbelastbare Formulierung unterzuschieben. - Struktur folgt der von der Broschüre selbst empfohlenen Gliederung (Abschnitt 1.11 "Empfohlener Aufbau"): Eingangsformel → Situationen (2.2) → Festlegungen zu ärztlichen Maßnahmen (2.3, alle 8 Kategorien: Lebenserhaltung, Wiederbelebung A+B, Schmerzbehandlung, künstliche Ernährung, Beatmung, Dialyse, Antibiotika, Blut) → Ort/Beistand (2.4) → Schweigepflichtentbindung (2.5) → Verbindlichkeit/Auslegung (2.6) → Vorsorgevollmacht-/Betreuungsverfügung-Bezug (2.7) → Organspende (2.9) → Schlussformel (2.10) → Schlussbemerkungen (2.11, immer enthalten) → Beratung (2.12) → optionales Freifeld für spätere ärztliche Bestätigung der Einwilligungsfähigkeit (2.13).
- Fortlaufende statt fester Abschnittsnummerierung (
build_patientenverfuegung_text()inapp/briefe_text.py, Variableabschnitt): mehrere Abschnitte sind optional (nur bei entsprechenden Angaben, z.B. kein Ort/Beistand-Abschnitt wenn nichts dazu angegeben wurde) — mit fest einprogrammierten Ziffern hätte eine leere Angabe zu Nummerierungslücken geführt (z.B. "2. ... 5. ..." ohne 3./4.), was in einem rechtlich bedeutsamen Dokument wie eine Fehlstelle wirken würde. Gefunden und gefixt noch während der Verifikation dieser Session, nicht erst durch Nutzer-Feedback. - Kein Fensterkuvert-Layout, kein Unterzeichner-Wechsel bei hinterlegter Vollmacht — anders als die anderen vier Generatoren ist dies kein Brief an einen Empfänger, sondern eine eigenständige persönliche Erklärung. Neues, eigenes PDF-Template
app/pdf_templates/patientenverfuegung.html(kein Absender-/Empfänger-Fenster, dafür Titel-Layout). Eine Patientenverfügung drückt begrifflich zwingend den eigenen Willen der betroffenen Person aus und kann nicht durch eine bevollmächtigte Person an ihrer Stelle unterschrieben werden — die_unterzeichner()/Vollmacht-Wechsel-Logik ausrouters/briefe.pywird hier bewusst nicht verwendet, es unterschreibt immer nur der Patient selbst. - Layout-Fix während der Verifikation: die optionale ärztliche Bestätigung (2.13) wurde zunächst in den Fließtext eingebettet und erschien dadurch vor der eigentlichen Patienten-Unterschrift im PDF — inhaltlich falsche Reihenfolge (die eigene Unterschrift gehört direkt nach der Erklärung, der ärztliche Anhang ist ein nachgelagerter, optionaler Zusatz). Gefixt, indem der Baustein aus
build_patientenverfuegung_text()entfernt und stattdessen als eigener, bedingter Block nach demunterschriften-Div direkt im Template gerendert wird (aerztliche_bestaetigung-Flag anrender_pdf()). Antrag.typerweitert um'patientenverfuegung'(Migrationb8d3e5f7a1c9), nutzt denselben autorisierungsgeprüften Download-Endpunkt wie alle anderen Briefe (GET /api/patienten/{pid}/antraege/{aid}/pdf) — keine neue Speicher-/Download-Infrastruktur nötig.GET /api/patientenverfuegung/textbausteineliefert alle Options-Daten fürs Interview (Situationen, Maßnahmen-Kategorien mit Formulierungen, Vertrauensperson-/Organspende-Optionen) — normalrequire_login-geschützt (anders als der öffentliche PLZ-Endpunkt, da dies nur innerhalb der eingeloggten App gebraucht wird).- Kein Anspruch auf Vollständigkeit oder Rechtsberatung: Fußzeile im generierten PDF weist explizit auf die Quelle (BMJ-Broschüre) hin und stellt klar, dass dies keine rechtliche oder ärztliche Beratung ersetzt — eine Patientenverfügung ist nur wirksam, wenn sie tatsächlich der freie Wille der volljährigen, einwilligungsfähigen Person ist (§ 1901a BGB); das kann Software nicht prüfen. Im Ergebnis-Bildschirm nach Erstellung wird zusätzlich auf das Zentrale Vorsorgeregister (vorsorgeregister.de, Bundesnotarkammer) als Möglichkeit hingewiesen, die Verfügung im Ernstfall auffindbar zu machen — die App selbst registriert dort nichts automatisch.
Interview-Wizard Schritt 4: neutralere Fragen + Sprung für "Bedürftige" (pflegepilot_v3 only, 2026-07-30)
Bei gemeinsamer Durchsicht mit dem Betreiber aufgefallen: die 5 Pflegegrad-Situationsfragen (static_data/pflegegrad_regeln.py: FRAGEN) waren für alle drei Zielgruppen (Alterspflege/Bedürftige/Behinderte) exakt identisch — bestätigt durch Code-Prüfung: weder GET /api/pflegegrad/fragen noch POST /api/pflegegrad/schaetzen erhalten die Zielgruppe überhaupt als Parameter. Fachlich teils gerechtfertigt (das echte NBA-Begutachtungsverfahren nach § 15 SGB XI ist bewusst ursachenunabhängig), aber zwei konkrete Schwachstellen behoben:
- Altersgebundene Klammerbeispiele neutralisiert: "Nutzt Hilfsmittel (Stock, Rollator)…" → "…(z. B. Gehhilfe, Rollstuhl)…"; die Kognitionsfrage nannte explizit "(z. B. bei Demenz)" — jetzt allgemeiner "Gibt es Gedächtnisprobleme, Orientierungsschwierigkeiten oder auffälliges Verhalten?", ohne die Diagnose vorwegzunehmen (passt so auch für z. B. eine angeborene Behinderung).
- Zielgruppe "Bedürftige / soziale Notlage" überspringt die 5 Situationsfragen komplett: hier geht es typischerweise um Grundsicherung (§ 41 SGB XII), nicht um eine SGB-XI-Pflegegrad-Einschätzung. Der Wizard-Reducer (
patienten/neu/page.tsx) springt bei dieser Zielgruppe direkt von der Zielgruppen-Auswahl (Schritt 4, Unterfrage 0) zu Schritt 5 — Ergebnis:state.antwortenbleibt leer, dieschaetzung-Mutation feuert nie (BedingungObject.keys(state.antworten).length > 0),pflegegradbleibt beim Default0. Schritt 5 zeigt dafür einen eigenen erklärenden Hinweis statt eines nie auflösenden "Schätzung wird berechnet…"; Pflegegrad-Buttons/Pflegeperson/Notizen bleiben nutzbar (falls doch ein Pflegegrad vorliegt). - Leistungen-Filter (Schritt 6) brauchte dafür keine Backend-Änderung:
pflegegradbleibt automatisch bei0, undGET /api/katalogs bestehender Filter (pflegegrad_min <= pflegegrad <= pflegegrad_max) schließt dadurch bereits alle Leistungen aus, die einen echten Pflegegrad voraussetzen (z. B. Pflegegeldpflegegrad_min=2) — nur Einträge mitpflegegrad_min=0(der Großteil der Katalog-Einträge, 75 von 98) plus passender Zielgruppe bleiben übrig. Verifiziert durch Durchsicht der tatsächlichenpflegegrad_min-Werte im Katalog, keine Vermutung.
Einheitliche Button-Stile statt unterstrichener Text-Links (pflegepilot_v3 only, 2026-07-30)
Betreiber-Feedback: Aktions-Auslöser ("+"-Funktionen, Bearbeiten, Löschen, PDF) sahen "noch nach Entwicklung aus" — durchgehendes Muster im gesamten Frontend war className="text-{farbe}-600 underline" auf reinem Text statt echter Button-Chrome (Hintergrund/Rand), zusätzlich auf Mobilgeräten schwer treffsicher.
- Neue geteilte Stile in
lib/theme.ts:buttonStyles— acht benannte Varianten (secondary,primary,danger,success,small,smallDanger,smallSuccess,smallPrimary,add), alle mitpy-1.5+ für ausreichend große Tippflächen.addist eine Pill-Form speziell für "+"-Aktionen (z. B. "+ Widerspruch", "+ Neuer Patient", "+ eigene Leistung erfassen"). - Betroffene Stellen: Patientenakte (Bearbeiten/Löschen im Header, Leistungen Aktivieren/Deaktivieren/Löschen, Anträge-PDF-Link, + Widerspruch/Musterbrief/Ummeldung/Vollmacht, Tagebuch-/Dokumente-Löschen),
BudgetTracker.tsx(Budget-Toggle, Ausgabe-Löschen, + Erfassen), Dashboard (PDF-Link in der Anträge-Vorschau, + Neuer Patient), Admin-Dashboard (Sperren/Entsperren/Löschen/Freischalten/Rechnung, Backup Herunterladen/Löschen), Changelog-Admin (Bearbeiten/Löschen, + Neuer Eintrag), Aufgaben (Erledigt), Interview-Wizard Schritt 6 (Fragen überspringen/zurück/erneut beantworten),NaechsteSchritte.tsx(Antrag-stellen-/Unterlagen-hochladen-CTAs). - Bewusst unverändert gelassen: reine Navigations-/Inhalts-Links ohne CRUD-Charakter — Login-Bildschirm-Umschalter ("Passwort vergessen?", "Jetzt registrieren"),
tel:/mailto:/Webseite-Kontaktzeilen in der Umkreissuche, Dokument-/Dateiname als Link zum Öffnen, Dashboard "Alle ansehen", sowie "Abbrechen" in den Kamera-Scan-Flows (EgkScan/DokumentScan) — dort ist ein dezenter Text-Link die etablierte, passende Konvention für eine sekundäre Abbrechen-Aktion neben einem primären Button, kein "sieht nach Entwicklung aus"-Symptom. AntragCheckliste.tsxs "Hochladen"-Beschriftung blieb unverändert, da sie bereits Teil einer voll button-gestylten Zeile ist (ganze<li>ist ein farbig hinterlegter Button), nicht isolierter Text.
Interview-Wizard Schritt 6: geführter Frage-Dialog statt reiner Checkbox-Liste (pflegepilot_v3 only, 2026-07-30)
Tester-Feedback: die Leistungsauswahl in Schritt 6 wirkte für Laien "erschlagend" — unklar, ob eine Leistung überhaupt gebraucht wird oder wie viele man ankreuzen darf. Zwei Lösungsansätze wurden dem Betreiber vorgelegt (A: nur die bestehende Liste optisch/inhaltlich aufwerten; B: ein geführter Ja/Nein-Fragenteil davor, der Einträge automatisch vorwählt) — Option B wurde explizit gewählt, trotz höherem Aufwand.
- Bewusst nur auf die 22 Einträge der Kategorie "Pflegekasse (SGB XI)" begrenzt, nicht auf den gesamten Katalog (98 Einträge, 27 Kategorien) — das sind die häufigsten, direkt am Pflegegrad hängenden Leistungen; die übrigen ~76 Einträge (Krankenkasse, Landesleistungen, Steuerliches, Wohnen & Bauen etc.) sind zu heterogen für einen sinnvollen Ja/Nein-Fragenkatalog und bleiben nur in der bestehenden Liste sichtbar, die unterhalb des Frage-Dialogs unverändert weiter angezeigt und editierbar bleibt (nichts wird "unsichtbar" vorausgewählt).
- 8 Fragen, gleiches Frage-pro-Bildschirm-Muster wie Schritt 4 (
LEISTUNGS_FRAGEN-Konstante inpatienten/neu/page.tsx, direkt vorStepLeistungen): Frage 1 ("Wie wird die Person hauptsächlich gepflegt?") ist eine Einfachauswahl mit 4 Optionen (nur Angehörige / nur Pflegedienst / gemischt / Pflegeheim), die restlichen 7 sind Ja/Nein. Jede Option trägt die zugehörigen Katalog-IDs, die bei Auswahl automatisch instate.ausgewaehlteLeistungenübernommen werden. - Neue Reducer-Aktion
SET_LEISTUNGEN(add/remove-Arrays) statt der bisherigenTOGGLE_LEISTUNGfür diesen Zweck — nötig, damit beim (erneuten) Beantworten einer Frage zuerst alle IDs der zuvor gewählten Option dieser Frage entfernt werden, bevor die neuen hinzukommen (sonst blieben bei einer Antwortänderung alte Vorauswahlen hängen). - Abhängige Leistungen werden direkt mit ausgewählt, ohne eigene Frage:
pk_leistungszuschlag(automatischer Zuschlag im Heim) ist Teil der "Pflegeheim"-Option;pk_beratungsbesuch(Pflicht-Beratungsbesuch bei Pflegegeld-Bezug, § 37 Abs. 3 SGB XI) ist Teil der "nur Angehörige"- und "gemischt"-Optionen — beide sind laut Katalogabhaengigkeitenvom jeweiligen Haupteintrag, brauchen aber keine eigene Nutzerfrage. - 4 Fragen werden übersprungen, sobald "Pflegeheim" gewählt wurde (
ueberspringenWennHeim-Flag: Tages-/Nachtpflege, Verhinderungs-/Kurzzeitpflege, Pflegehilfsmittel, Wohnumfeldverbesserung) — bei vollstationärer Pflege sind diese häuslichen Leistungen typischerweise nicht relevant. - "Fragen überspringen" springt jederzeit direkt zur Liste, "Fragen erneut beantworten" (erscheint nach Abschluss) setzt den Dialog zurück auf Frage 1 — bereits gesetzte Häkchen in der Liste bleiben davon unberührt, bis eine Frage tatsächlich erneut beantwortet wird.
- Bekannte, akzeptierte Einschränkung: ändert man nach Beantwortung späterer Fragen die Antwort auf Frage 1 rückwirkend auf "Pflegeheim", werden zuvor über die übersprungenen Fragen gesetzte Häkchen nicht automatisch wieder entfernt (die Liste bleibt aber sichtbar/editierbar, der Nutzer kann manuell abwählen) — für den seltenen Rückwärtskorrektur-Fall bewusst nicht mit einem Undo-Kaskaden-Mechanismus überbaut.
- Nischen-Leistungen ohne eigene Frage (Wohngruppen-Zuschlag, Pflegeunterstützungsgeld, DiPA,
pk_stationaer_behindert) bleiben nur über die normale Liste erreichbar, gleiches Prinzip wie bereits bei Reha-Sport in der Umkreissuche (keine erzwungene Frage für seltene Fälle). - Betrifft alle drei Zielgruppen gleichermaßen; bei "Bedürftige" (Pflegegrad bleibt 0) filtert der bestehende Katalog-Filter die meisten Pflegekasse-Einträge ohnehin heraus (nur
pflegegrad_min=0-Einträge bleiben sichtbar) — der Frage-Dialog wird trotzdem angezeigt, bleibt für diese Gruppe aber weitgehend wirkungslos, da die meisten Ziel-IDs gar nicht in der gefilterten Liste auftauchen.
T30: Durchsuchbare Dokumenten-Scans + Volltextsuche (pflegepilot_v3 only, 2026-08-02)
Letzter offener "niedrig"-Punkt aus dem T26–T37-Nachtrag. Original-Feedback war nur eine Rückfrage des Testers ("Dokumenten Scan mittels OCR optimieren? Bedarf vorhanden??") — der Umfang wurde über zwei AskUserQuestion-Runden geklärt (was genau "optimieren" heißt, dann wie die "in unsere Generatoren übernehmen"-Anforderung konkret aussehen soll) und ist am Ende deutlich größer als "niedrig": durchsuchbare PDFs, serverseitig persistierter OCR-Text, Volltextsuche im UI, Text-Anzeige-und-Kopieren-Funktion.
app/dokument_scan.py: bilder_zu_durchsuchbarem_pdf()ersetztbilder_zu_pdf()im Scan-Upload-Pfad (die alte Funktion bleibt als Fallback bestehen). Nutzt Tesseracts eigenen PDF-Renderer (pytesseract.image_to_pdf_or_hocr(bild, extension="pdf")) statt eine Textebene manuell über das Bild zu legen — Tesseract liefert Bild+unsichtbare Textebene bereits korrekt positioniert in einer einzigen PDF-Seite. Mehrseitige Scans werden mitpypdf(neue, bewusst leichtgewichtige Abhängigkeit — reines Seiten-Zusammenfügen, kein Rendering nötig, daher nicht das schwerere PyMuPDF/fitz) zu einem gemeinsamen PDF zusammengefügt. Liefert zusätzlich den erkannten Volltext fürDokument.ocr_textzurück.- Gleiche Kulanz-Haltung wie die bestehende Kantenerkennung: jeder OCR-Fehler (korrupte Bilddaten, Tesseract-Ausnahme) fällt auf das alte reine Bild-PDF ohne Textebene zurück, statt den Upload zu blockieren — verifiziert durch einen simulierten Fehlschlag (gemockter
pytesseract-Aufruf), sauberer Fallback ohne Crash bestätigt. Dokument.ocr_text(Migrationa3d6f9c2e5b8,Text-Spalte, nullable) — nur bei per Kamera gescannten Dokumenten gesetzt, nicht bei regulären Datei-Uploads (bewusst auf den tatsächlich referenzierten "Dokumenten-Scan" beschränkt, nicht auf den generischen Upload-Pfad ausgeweitet).- Volltextsuche:
GET /api/patienten/{pid}/dokumente?suche=...filtert per einfachemILIKEsowohl aufbezeichnungals auch aufocr_text— bewusst kein Postgres-tsvector(Dokumentenmenge je Patient bleibt klein, ein Substring-Treffer reicht für "Inhalte leichter finden"). Im Frontend aber nicht darüber verdrahtet — die Patientenakte nutzt stattdessen client-seitige Filterung der bereits geladenen, ungefilterten Dokumentenliste:NaechsteSchritte(T24) braucht die vollständige, ungefilterte Liste für seine "Unterlagen hochgeladen"-Fortschrittsprüfung, ein serverseitig gefilterter State hätte diese Prüfung während einer aktiven Suche verfälscht. Der Backend-Suchparameter bleibt trotzdem als eigenständige, getestete Fähigkeit bestehen (z. B. für künftige größere Dokumentenmengen oder andere Oberflächen). - "Text anzeigen": neuer Button pro Dokument (nur sichtbar, wenn
ocr_textgesetzt ist) öffnet ein Modal mit einerreadOnly-Textarea des erkannten Texts — Klick fokussiert und markiert automatisch den gesamten Text (onFocus-Handler), sodass ein einziger Klick + Strg/⌘+C zum Kopieren reicht. Bewusst kein automatisiertes Feld-Erkennungs-/Ein-Klick-Übernahme-System (das war die im Klärungsgespräch explizit abgelehnte, aufwendigere Alternative) — der Nutzer markiert/kopiert selbst, was er braucht, funktioniert dafür zuverlässig bei jedem beliebigen Dokumentlayout. - End-to-end verifiziert (synthetisches Testbild mit bekanntem Text, da keine echten Scans verfügbar sind — gleiche Einschränkung wie beim ursprünglichen Dokumenten-Scan und dem eGK-Scan): OCR-Text korrekt erkannt, PDF-Textebene über
pypdfsextract_text()als tatsächlich vorhanden bestätigt (nicht nur angenommen), Fallback-Pfad bei simuliertem OCR-Fehler geprüft, echter Upload über den tatsächlichen HTTP-Endpunkt mit korrekt gespeichertemocr_text, Volltextsuche mit drei Fällen (Treffer über OCR-Inhalt, Treffer über Bezeichnung, kein Treffer) bestätigt.next buildfehlerfrei, auf der NAS deployed (neuespypdferforderte einendocker compose build api, nicht nur Neustart).
DateInput-Bugfix: führende Null verhinderte Eingabe von Tag/Monat 10–31 (pflegepilot_v3 only, 2026-08-02)
Direkt nach dem T26-Umbau gemeldeter Bug: sobald Monat und Jahr bereits vollständig ausgefüllt waren, machte schon die erste eingetippte Ziffer im Tag-Feld (z. B. "1") ein vollständiges, gültiges Datum — dieses wurde sofort an den Parent gemeldet, dessen aktualisierter value-Prop floss zurück in die Komponente, und der dortige Sync-Effekt (für externe Wertänderungen wie eGK-Scans gedacht) füllte die einzelne Ziffer sofort zweistellig auf ("01"), noch bevor die zweite Ziffer getippt werden konnte — dadurch war der 10.–31. Tag/Monat praktisch nicht eintippbar.
- Fix: ein
useRef<string | null>(letzterEigenerWert) merkt sich den zuletzt selbst gemeldeten ISO-Wert. Der Sync-Effekt gleichtvaluejetzt gegen diesen Ref ab und überspringt die Neuformatierung, wenn der eingehende Wert exakt die eigene, gerade erst gemeldete Eingabe ist (reines Echo) — nur einvalue, der tatsächlich von außen kommt (z. B. eGK-Scan-Ergebnis), löst noch eine Neusynchronisation der Anzeige aus. - Betrifft alle 6 Einbindungsstellen von
DateInputgleichermaßen, da der Fix zentral in der Komponente sitzt. - Verifiziert per manueller Zustands-Simulation der React-Update-Reihenfolge (kein Browser in dieser Umgebung verfügbar) — Tippfolge "1" dann "2" im Tag-Feld bei bereits gesetztem Monat/Jahr ergibt jetzt durchgängig "12" statt zwischenzeitlich auf "01" zurückgesetzt zu werden.
Incident: E-Mail-Absendername noch "Pflegepilot" statt "MeinPflegeFuchs" (pflegepilot_v3 only, 2026-08-02)
Betreiber-Meldung: der Absendername in versendeten E-Mails zeigte noch "Pflegepilot" statt "MeinPflegeFuchs". Ursache war kein Code-Bug — app/config.pys smtp_from-Default war bereits korrekt auf "MeinPflegeFuchs <no-reply@meinpflegefuchs.de>" gesetzt (Teil der ursprünglichen Umbenennung, siehe CLAUDE.md-Abschnitt "Naming"). Die tatsächlich wirksame SMTP_FROM-Umgebungsvariable in beiden .env-Dateien (lokal und NAS, docker-compose.yml reicht sie durch) trug aber noch den alten Wert "Pflegepilot <support@db-onlinedienste.de>" — vermutlich beim ursprünglichen SMTP-Setup (siehe "Passwort-vergessen per SMTP") vor der Umbenennung eingetragen und beim Rename-Durchlauf übersehen, da .env-Werte nicht Teil der Code-/Grep-Suche über den Projektbaum waren.
- Fix: nur der Anzeigename in
SMTP_FROMgeändert (Pflegepilot→MeinPflegeFuchs), die tatsächliche Absenderadressesupport@db-onlinedienste.de(echte, aktiv genutzte Mailbox) unverändert gelassen — kein Code-Deploy nötig, nurdocker compose up -d --force-recreate apiin beiden Umgebungen (ein reinerrestarthätte die geänderte.envnicht neu eingelesen, da Docker Compose Umgebungsvariablen nur beim Container-Erstellen neu auswertet). - Verifiziert:
settings.smtp_fromnach Neustart korrekt in beiden Umgebungen geprüft, zusätzlich eine echte Testmail über die Admin-Test-Mail-Funktion (POST /api/admin/email-test) verschickt und imemail_logalsgesendetbestätigt. - Lehre für künftige Rename-/Branding-Durchläufe:
.env-Dateien (lokal wie NAS) sind bei einem Namens-/Branding-Wechsel explizit mitzuprüfen, nicht nur der Code — sie sind gitignored und tauchen deshalb in keiner Grep-Suche über den versionierten Projektbaum auf.
T26-Korrektur: Datumsfelder als Zahlenfelder statt Kalender-Dialog (pflegepilot_v3 only, 2026-08-02)
Fehleinschätzung aus der vorherigen Runde korrigiert. T26 war ursprünglich als "bereits gelöst" markiert worden, weil ein bestehender DateWheelPicker (drei Scroll-Wheel-Spalten in einem Popup-Dialog) angezeigt in TT.MM.JJJJ-Format vorhanden war — die Annahme war, dass ein Wheel-Picker das Anliegen "kein Standard-Tastatur-Popup, klares Datumsformat" bereits erfüllt. Betreiber-Korrektur: genau der Wheel-Picker-Dialog selbst war nicht gewünscht — stattdessen ein echtes Zahlen-Eingabefeld TT.MM.JJJJ ohne jeden Kalender-/Dialog-Umweg, mit Validierung auf kalendarisch mögliche Werte (Beispiel des Betreibers: 31.02.2000 muss abgelehnt werden).
components/DateInput.tsxersetztDateWheelPicker.tsxkomplett (Datei gelöscht, nicht nur deaktiviert) — drei nebeneinanderliegende<input inputMode="numeric">-Felder (Tag/Monat/Jahr) mit Punkt-Trennzeichen dazwischen, automatischer Fokus-Sprung zum nächsten Feld bei vollen 2 (bzw. 4) Ziffern, roter Rand + Fehlertext "Ungültiges Datum" sobald alle drei Felder gefüllt aber die Kombination kalendarisch unmöglich ist. Validierung übernew Date(jahr, monat, 0).getDate()(liefert die tatsächliche Tageszahl des Monats inkl. korrekter Schaltjahr-Berechnung durch die native JS-Date-Arithmetik) statt einer hartkodierten Tage-pro-Monat-Tabelle — automatisch korrekt für Schaltjahre, ohne das explizit nachzubilden.- API-Erweiterung, keine neuen Anpassungen bei den Aufrufern nötig:
onChangeliefert jetztstring | nullstatt nurstring(vorher lieferte der Wheel-Picker erst beim expliziten "Übernehmen"-Klick einen vollständigen Wert; ein Freitextfeld hat dagegen während des Tippens zwangsläufig unvollständige/ungültige Zwischenzustände, die alsnulldurchgereicht werden) — alle 6 bestehenden Aufrufer hatten ihren Datums-State bereits alsstring | nulltypisiert, keine Änderung an den Callern nötig außer Komponentenname/Import.value-Prop-Änderungen von außen (z. B. eGK-Scan/OCR füllt das Feld programmatisch) werden weiterhin peruseEffectübernommen, aber nur bei einem tatsächlich gesetzten Wert — einnullvon außen löscht keine laufende, noch unvollständige Nutzereingabe. - 6 Einbindungsstellen aktualisiert: Geburtsdatum ("Neuer Patient", "Bearbeiten"), Bescheiddatum (Widerspruch-Generator, Status-bearbeiten-Formular in der Patientenakte), Gültig-ab-Datum (Ummeldungs-Generator), Fällig-am (Aufgabe-anlegen-Modal). Redundante externe
<label>-Wrapper an drei Stellen entfernt, daDateInputseinlabel-Prop jetzt selbst sichtbar über den Feldern rendert (vorher wurdelabelnur als Popup-Dialogtitel verwendet — an mehreren Stellen gab es dadurch vorher gar keine sichtbare Beschriftung im Hauptformular, nur im geöffneten Dialog). - Validierungslogik isoliert unit-getestet (Node-Skript außerhalb des Frameworks, da kein Test-Runner im Projekt eingerichtet ist): alle Kernfälle bestätigt, inkl. des vom Betreiber genannten Beispiels 31.02.2000 (ungültig), 29.02.2000 (gültig, Schaltjahr), 29.02.2001 (ungültig, kein Schaltjahr), Tage-pro-Monat-Grenzen (30. April gültig, 31. April ungültig).
next buildfehlerfrei (die von der strengenreact-hooks/set-state-in-effect-Regel gemeldeten Fehler für den externen-Wert-Sync-Effekt sind dieselbe bereits im Projekt an mehreren Stellen tolerierte Klasse von Lint-Meldung, die den Produktions-Build nachweislich nicht blockiert — kein Blocker, aber nicht eigens umgangen).
Tester-Feedback-Nachtrag T27/T28/T29/T33 (pflegepilot_v3 only, 2026-08-02)
Vier weitere Punkte aus dem T26–T37-Nachtrag in Feedbackfromtester.md. Vor der Umsetzung drei genuine Design-Entscheidungen per AskUserQuestion geklärt: neuer Rubrik-Name (T27), Eigenentwurf vs. Themenvorgabe für den neuen Guide (T28), UX-Lösungsansatz für Auto-Advance (T33).
- T27 (Rubrik-Umbenennung + Formulierungsfixes): "Krisen-Checkliste" → "Wegweiser für schwierige Momente" (Betreiber-Wahl aus drei vorgeschlagenen Optionen), geändert in
(app)/layout.tsx(Sidebar-Label),guides/page.tsx(H1) und(marketing)/page.tsx(Feature-Kachel auf der Landingpage, für Konsistenz mit angepasst, obwohl nicht explizit gefordert). Zwei Formulierungsfixes instatic_data/krisenguides.py: der Hospitalisierung-Guide heißt jetzt "Plötzlich ins Krankenhaus" statt des Fachbegriffs; die unsinnige "Haustiere, Pflanzen ... pausieren"-Zeile wurde aufgeteilt in eine Versorgungs-Organisation für Tiere/Pflanzen (die man nicht einfach "pausieren" kann) und separat absagbare Termine. - T28 (neuer Guide "Umzug ins Pflegeheim"): Betreiber wählte "selbst entwerfen lassen" statt Themenvorgabe. Fünfter Eintrag in
KRISENGUIDES(id="umzug_pflegeheim"), 8 Schritte nach demselben Muster wie die bestehenden vier: Heimvergleich, § 43 SGB XI-Antrag (mit Verweis auf den bestehenden Musterbrief-Generator), Kostenübernahme/Eigenanteil, Adressänderung (Verweis auf den bestehenden Ummeldungs-Generator — bewusste Cross-Promotion eigener Features statt allgemeiner Ratschläge), Vertragskündigungen, Umzugslogistik, Nachsendeauftrag, emotionale Seite.guides/page.tsxsAKZENTE-Array um eine fünfte Farbe (amber) erweitert. - T29 (doppelte Anträge/Briefe — Root-Cause + Löschen): Root-Cause-Prüfung zuerst: alle 5 Brief-Generatoren (Widerspruch/Musterbrief/Ummeldung/Vollmacht/Patientenverfügung) hatten bereits
disabled={mutation.isPending}auf dem Submit-Button — ein reiner Doppelklick-Bug ist damit ausgeschlossen. Plausiblere Ursache: eine verlorene Serverantwort während der (spürbar langsamen) WeasyPrint-PDF-Erzeugung — die Anfrage wird serverseitig trotzdem fertig verarbeitet, der Client bekommt aber keine Bestätigung und der Nutzer klickt erneut. Gefixt mit_juengster_duplikat()(routers/briefe.py): vor jeder PDF-Erzeugung wird geprüft, ob für denselben Patienten/Typ bereits ein inhaltsgleicher (brief_text-identischer) Antrag innerhalb der letzten 20 Sekunden existiert — falls ja, wird dieser zurückgegeben statt PDF+DB-Zeile ein zweites Mal zu erzeugen (spart auch die teure PDF-Neu-Generierung). Bewusst kein Idempotenz-Key über alle 5 Formulare hinweg (unverhältnismäßiger Umbau für dieses Symptom) — der Zeitfenster-Abgleich reicht, da die Formulare ohnehin serverseitig deterministischen Text erzeugen. End-to-end verifiziert: zwei identische Aufrufe hintereinander liefern dieselbe Antrag-ID zurück (nur 1 DB-Zeile), ein Aufruf mit anderemtyperzeugt korrekt eine neue Zeile (kein Über-Deduplizieren). Zusätzlich fehlte komplett ein Lösch-Endpunkt fürAntrag-Datensätze —DELETE /api/antraege/{aid}(routers/antraege.py, gleicherequire_paid_access-Eigentumsprüfung wie das bestehendePUT) neu ergänzt, löscht auch die zugehörige PDF-Datei von der Platte. Frontend: "Löschen"-Button (kein Bestätigungsdialog, gleiches Muster wie die bereits bestehenden Leistungen-/Tagebuch-/Dokumente-Lösch-Buttons) in der Anträge-Liste der Patientenakte. - T33 (Interview Schritt 6: Bestätigung vor Auto-Weiterleitung): Betreiber wählte "Bestätigen-Button statt Auto-Advance" (gleiches Prinzip wie bereits die letzte Unterfrage in Schritt 4).
waehleOption()inpatienten/neu/page.tsxmarkiert eine Antwort jetzt nur noch (visuell hervorgehoben,border-indigo-500 bg-indigo-100), ein neuer, bis zur Auswahl disabled "Weiter"-Button (weiter()) löst den Sprung zur nächsten Frage aus. Nebeneffekt (kein zusätzlicher Code nötig): beim Zurückgehen zu einer bereits beantworteten Frage wird die vorherige Auswahl jetzt automatisch korrekt hervorgehoben, da dieselbegewaehlt-State-Map fürs Highlighting wiederverwendet wird. Bewusst nur Schritt 6 geändert, nicht die analoge Schritt-4-Logik (Pflegegrad-Situationsfragen) — der Tester-Feedback-Text bezog sich explizit auf "Step 6 Fragenkatalog".
Alle vier Punkte end-to-end gegen die echte lokale Docker-Postgres-Instanz verifiziert (Guides-Endpunkt liefert 5 Einträge mit korrektem Wortlaut, Duplikat-Schutz und Löschen wie oben beschrieben getestet, danach alle Testdaten entfernt), next build fehlerfrei, auf der NAS deployed.
Tester-Feedback-Nachtrag T26/T31/T32 (pflegepilot_v3 only, 2026-08-01)
Drei Punkte aus dem am 2026-08-01 in Feedbackfromtester.md ergänzten Nachtrag (T26–T37, siehe dortige Gruppen K–O), auf Betreiber-Wunsch als erste drei bearbeitet:
- T26 (Geburtsdatum-Eingabeformat) — kein Code geändert, bereits gelöst: Der Tester-Wunsch "DD.MM.JJJJ-Schreibweise, auf Mobilgeräten Nummernblock statt regulärer Tastatur" ist für alle drei Geburtsdatum-Stellen (
patienten/neu/page.tsx,patienten/[id]/bearbeiten/page.tsx) bereits durch die bestehendecomponents/DateWheelPicker.tsxerfüllt — geprüft, nicht angenommen: die Komponente zeigt das gewählte Datum übertoLocaleDateString("de-DE", {day:"2-digit", month:"2-digit", year:"numeric"})exakt im gewünschtenTT.MM.JJJJ-Format an, und öffnet beim Bearbeiten drei Scroll-Wheel-Spalten statt eines Texteingabefelds — es gibt dort also gar keine Tastatur (weder alphanumerisch noch Nummernblock), die geöffnet werden könnte. Die übrigen, nicht in Wheel-Picker umgewandelten Datumsfelder der App (Antragsdatum-Bearbeiten, Budget-Tracker-Ausgabedatum, Changelog-Admin) sind bewusst reguläre<input type="date">für nahe/kurzfristige Daten — native<input type="date">öffnet auf Mobilgeräten ohnehin bereits einen systemeigenen Datums-Picker, keinen freien Text-/Zeichen-Tastaturblock, erfüllt das Anliegen also strukturell ebenfalls schon. Bleibt trotzdem in der Backlog-Datei mit "ggf. bereits gelöst" vermerkt, bis das beim nächsten Tester-Kontakt aktiv bestätigt wird — die Rückmeldung könnte sich zeitlich vor dem Wheel-Picker-Rollout ereignet haben. - T31 (offizieller MDK-Text auf der Ergebnisseite) — blockiert, braucht echten Text: Das Original-Feedback ("Seite 4: offizieller Text des MDK noch hinzufügen ... <--- Rsp Marcel") verweist auf einen noch ausstehenden Wortlaut von einer dritten Person ("Marcel"), der bislang nirgends im Repository vorliegt (geprüft: kein vorhandener MDK-Volltext in
pflegepilot_v3oderpflegepilot_alpha_v2, nur der bereits bestehende Kurz-Disclaimer "Das ist keine offizielle Feststellung – nur der Medizinische Dienst kann per Begutachtung einen verbindlichen Pflegegrad bescheinigen" inpatienten/neu/page.tsx). Bewusst kein Text erfunden/angenommen — ein als "offizieller MDK-Text" ausgegebener, tatsächlich von Claude erfundener Text wäre ein greifbares Falschdarstellungsrisiko in einer App, die genau in diesem Punkt (Pflegegrad-Einschätzung) ohnehin schon Vertrauens-/Genauigkeitsfragen hat (siehe T34). Noch offen, braucht den echten Wortlaut vom Betreiber. - T32 (Musterbrief: "Pflege wird durchgeführt von") — umgesetzt, kein neues Formularfeld:
Patient.pflegepersonexistierte bereits als freies Textfeld (erfasst in Schritt 5 "Einschätzung & Pflegeperson" des "Neuer Patient"-Assistenten, editierbar im Bearbeiten-Formular) — die Frontend-Musterbrief-Seite (musterbrief/page.tsx) hatte dafür aber gar kein eigenes Feld, undbuild_musterbrief_text()/MusterbriefRequestgaben den Wert bislang nirgends aus. Statt eines neuen Frontend-Felds/Request-Parameters liestmusterbrief_erstellen()(routers/briefe.py) den Wert direkt vom ohnehin schon geladenenpatient-Objekt (patient.pflegeperson) und übergibt ihn anbuild_musterbrief_text(..., pflege_durch=...)— eine neue, optionale Zeile "Pflege durch: ..." erscheint im generierten Erstantrag-Text nur, wenn das Feld tatsächlich gesetzt ist (keine leere Zeile bei fehlendem Wert). Gilt für alle Musterbrief-Typen (nicht nur Erstantrag), dapflegepersonunabhängig vom Antragstyp dieselbe Information ist. End-to-end gegen einen echten Testpatienten mit gesetztempflegeperson-Feld verifiziert (Zeile erscheint korrekt formatiert/ausgerichtet; ohne Wert bleibt sie korrekt weg), Testdaten danach entfernt.
Echtes Logo + Favicon eingebunden (pflegepilot_v3 only, 2026-08-01)
Betreiber hat ein Fuchs-Logo (logo.png, Linien-Grafik, dunkles Slate-Blau auf transparentem Grund, 599×503) und ein mehrauflösiges Favicon (Fav.ico, 9 Größen 16px–256px) bereitgestellt — bis dahin lief die gesamte App (Web + E-Mails) ausschließlich mit einer Text-Wortmarke "MeinPflegeFuchs" (siehe email_templates.pys ursprüngliche Begründung "kein Bild-Logo vorhanden").
- Zwei Logo-Varianten:
frontend/public/logo.png(Original, dunkles Blau — für alle hellen Hintergründe: Web-Header, Login-Box) undfrontend/public/logo-white.png(per PIL generiert, alle nicht-transparenten Pixel auf Weiß gesetzt, Alpha-Kanal unverändert — für dunkle Hintergründe, aktuell nur der E-Mail-Header). Ohne die weiße Variante wäre das dunkle Liniengrafik-Logo auf dem dunklen#1e293b-Mail-Header (praktisch) unsichtbar gewesen — vor dem Einbau via PIL-Komposit auf#1e293bgeprüft. - Favicon:
Fav.icoersetzt direktfrontend/src/app/favicon.ico— Next.js' Datei-Konvention übernimmt das automatisch, keine Code-Änderung nötig. - Web-Einbindung (jeweils
<img src="/logo.png">, bewusst keinnext/image— im gesamten Frontend bislang nirgends verwendet, hätte fürs erste Mal extra Optimierer-/Sharp-Abhängigkeit in denstandalone-Produktions-Build gebracht, für ein paar kleine statische Logo-Einblendungen unverhältnismäßig): App-Sidebar-Header (Desktop<aside>+ Mobile-Topbar) in(app)/layout.tsx, Marketing-Header in(marketing)/layout.tsx, Login/Registrierungs-Box inAuthOverlay.tsx. - SEO/Social-Preview:
(marketing)/page.tsxsgenerateMetadata()bekamopenGraph.images/twitter.imagesauf/logo.png, das JSON-LD-SoftwareApplication-Objekt einimage-Feld — vorher hatte keiner dieser drei Orte ein Bild hinterlegt. - E-Mail-Header (
app/email_templates.py: render_email()): der dunkle Header-Balken zeigt jetzt<img src="{LOGO_URL}">(weiße Variante) neben dem Text "MeinPflegeFuchs" statt nur reinem Text.LOGO_URListf"{settings.web_origins[0]}/logo-white.png", Modul-Konstante (erfordert neufrom app.config import settingsinemail_templates.py) — als ganz normal per HTTPS gehostete Bild-URL eingebunden, nicht als Base64-Data-URI: viele Clients (u. a. Gmail) blockieren/entfernen eingebettete Base64-Bilder als Spam-Heuristik, eine echte Bild-URL ist der branchenübliche, zuverlässigere Weg.alt="MeinPflegeFuchs"liefert bei blockierten Bildern denselben Text-Fallback, den vorher die reine Text-Wortmarke bot — der Text-Schriftzug bleibt zusätzlich neben dem Bild stehen, keine reine Bild-Lösung. - Visuelle Abnahme über Artifact-Vorschau (kein Browser in dieser Umgebung verfügbar, gleiches Vorgehen wie beim E-Mail-HTML-Redesign): eine gerenderte Testmail (Katalog-Änderung) wurde mit dem Logo als Base64-Data-URI (nur für die Vorschau, nicht im echten Versand — der echte Versand nutzt die gehostete URL) als Artifact veröffentlicht.
Test-Mail-Funktion im Admin-Dashboard (pflegepilot_v3 only, 2026-08-01)
Betreiber-Wunsch nach der E-Mail-Bündelung/-Redesign-Runde: "gib mir eine mail-test funktion im admin bereich um test mails zu versenden" — die 7 proaktiven Mailtypen (siehe "Proaktiver Server-Mailversand erweitert") ließen sich bis dahin nur pruefen, indem die jeweilige echte Versandbedingung im System nachgestellt wurde (faellige Aufgabe anlegen, Katalog-Snapshot zuruecksetzen, Widerspruchsfrist in die Vergangenheit legen, …) — unpraktisch fuer eine schnelle Design-/Text-Kontrolle.
app/email_test.pyist eine neue, bewusst separate Registry mit einem Builder pro Mailtyp (_tagebuch_reminder(),_aufgabe_faellig(), …,TEST_MAIL_TYPEN: dict[str, tuple[label, builder]],baue_test_mail(typ)). Jeder Builder ruft dieselbenemail_templates-Bausteine und dieselbe Formulierung wie die echte Versandstelle auf (reminder_scheduler.py,katalog_watch.py,umkreis_favoriten_watch.py,routers/auth.py), aber mit fest hinterlegten Platzhalterdaten ("Max" als Vorname, "Grete Musterfrau" als Beispiel-Patientin — Namenskonvention wie beim Demo-Account) statt echter DB-Objekte, da fuer eine Testmail keine reale Aufgabe/kein realer Antrag/Favorit existieren muss. Magic-Links (Antragsstatus, Widerspruch-Eskalation) bekommen ein Fake-Tokentestvorschaustatt eines echtenantrag_token-Eintrags — funktional inaktiv, aber strukturell identisch zur echten Mail. Bewusste Kehrseite: da die Builder die echte Formulierung duplizieren statt sie direkt wiederzuverwenden, muss ein Wortlaut-Update an einer echten Versandstelle manuell hier nachgezogen werden, sonst laeuft die Testmail-Vorschau inhaltlich auseinander — ein groesserer Refactor (z. B. echte DB-Objekte + Builder aus optionalen Parametern speisen) waere fuer eine reine Admin-Testfunktion unverhaeltnismaessig gewesen.GET /api/admin/email-test/typenliefert die Registry fuers Frontend-Dropdown,POST /api/admin/email-test({empfaenger, typ}) validiert die Adresse (gleiches Regex-Muster wie die Registrierung), verschickt ueber das normalesend_email()und protokolliert mittyp="test_<original_typ>"imemail_log— bewusst ein eigener Typ-Praefix, damit Testversendungen die echten Volumen-/Erfolgsquote-Statistiken der "E-Mail-Versand"-Karte nicht verfaelschen. Betreff bekommt zusaetzlich ein "[Testmail]"-Praefix, damit im Posteingang sofort klar ist, dass es kein echter Versand war.- Kein
begruendung-Pflichtfeld / kein Admin-Audit-Log-Eintrag fuer diese Aktion — anders als Sperren/Loeschen/DSGVO-Export/Backup-Erstellen (die "vollstaendiger Datenexport/-eingriff"-Kategorie in diesem Codebase) ist das Verschicken einer harmlosen Test-Mail an eine selbst gewaehlte Adresse kein Vorgang, der eine Begruendung braucht — der bereits bestehendeemail_log-Eintrag (jetzt mittest_-Praefix erkennbar) reicht als Nachvollziehbarkeits-Spur. - Frontend:
lib/admin.ts: useEmailTestTypen()/useEmailTestSenden(), neue Collapsible-Karte "Test-Mail versenden" inadmin/page.tsxdirekt unter der bestehenden "E-Mail-Versand"-Karte (Akzentfarbecyan, bis dahin ungenutzt) — Dropdown fuer den Mailtyp, Empfaenger-Feld (vorbelegt mit der eigenen Admin-E-Mail aususeAuth(), frei ueberschreibbar), Senden-Button mit Erfolgs-/Fehlermeldung. - End-to-end gegen die echte lokale SMTP-Konfiguration verifiziert (nicht nur synthetisch): alle 7 Builder fehlerfrei durchlaufen lassen, dann ein echter Versand ueber die HTTP-API an eine echte Adresse ausgeloest —
email_log-Statusgesendet, Betreff korrekt mit "[Testmail]"-Praefix. Zusaetzlich negative Faelle geprueft (ungueltige E-Mail-Adresse → 400, unbekannter Typ → 400, keinemail_log-Eintrag bei beiden). Test-Admin-Account und Test-email_log-Zeile danach wieder entfernt.
"Nächste Schritte" auf der Patientenakte (pflegepilot_v3 only, 2026-07-30, T24)
Tester-Feedback: nach der Pflegegrad-Schätzung im Wizard und nach "Patient speichern" fehlte ein konkreter Leitfaden — "ich als Kunde habe von der Thematik kein Verständnis". Nach Klärungsgespräch (Betreiber wollte die anspruchsvollste der drei skizzierten Optionen): eine dynamische Fortschritts-Checkliste, keine statische Hinweisbox.
- Neue Komponente
components/NaechsteSchritte.tsx, oben auf der Patientenakte. Drei Schritte: "Antrag gestellt" → "Unterlagen hochgeladen" → "Bescheid erhalten". Bewusst kein neuer Datenbank-Zustand — der Fortschritt wird ausschließlich aus den bereits geladenenAntrag-/Dokument-Daten des Patienten abgeleitet (kein manuelles Abhaken, keine Fehlerquelle durch veraltete Haken). Verschwindet automatisch, sobald alle drei Schritte erreicht sind. - Schritt 1 ist nach Zielgruppe personalisiert (Alterspflege → Erstantrag Pflegegrad, Bedürftige → Grundsicherung, Behinderte → Eingliederungshilfe) und verlinkt direkt zum passenden Musterbrief-Antragstyp. Dafür bekam
/musterbrieferstmals Query-Parameter-Vorbelegung (?patientId=&typ=) — vorher war der Patient/Typ dort nur manuell über die Dropdowns wählbar. Da/musterbriefals statische Seite gebaut wird (next buildmarkiert sie○), musste die Seite dafür in eineSuspense-Grenze gewrappt werden (useSearchParams()ohne Suspense bricht sonst den Produktions-Build) — gleiches Muster wie bereits beipatienten/[id]/page.tsx. - Nachbesserung nach direktem Nutzer-Feedback ("Farbtöne sehr ähnlich, schwer zu erkennen"): ursprüngliche Fassung nutzte durchgehend denselben Sky-Blauton (Hintergrund/Rand/Überschrift/Links) UND ⬜/✅-Emoji als Status-Symbole — Letztere haben auf hellem Hintergrund kaum Eigenkontrast, Ersteres kollidierte zudem farblich mit dem "Leistungen"-Bereich weiter unten auf derselben Seite (nutzt ebenfalls Sky-Akzent). Ersetzt durch echte Tabler-Icons (
IconCircleCheckgrün für erledigt,IconCircleDashedindigo für offen) und einen weißen Karten-Hintergrund mit Indigo-Rand statt eines flächigen Blau-Hintergrunds; erledigte Schritte zusätzlich durchgestrichen/grau, offene Schritte fett+farbig — deutlich klarerer Kontrast zwischen "erledigt" und "als Nächstes zu tun". - Zweite Nachbesserung ("Unterlagen hochladen, welche denn? Was muss ich tun?"): die bereits bestehende
AntragCheckliste-Komponente (T1, zeigt je Antragstyp die üblicherweise benötigten Unterlagen mit Direkt-Upload) wird jetzt inline in Schritt 2 eingebettet, für den tatsächlich gestellten Antragstyp des Patienten — statt eines generischen "Unterlagen hochladen"-Links. "Unterlagen hochgeladen" gilt jetzt erst als erledigt, wenn alle für diesen Antragstyp laut Checkliste benötigten Dokumenttypen tatsächlich vorhanden sind (nicht mehr nur "irgendein Dokument existiert"). - Dabei ein echter, vorbestehender Bug gefunden und behoben:
musterbrief_erstellen()(routers/briefe.py) speicherte denAntrag.typfür die drei im Musterbrief-Generator schon länger wählbaren Subtypen "Eingliederungshilfe"/"Grundsicherung"/"Blindenhilfe" immer stillschweigend als"erstantrag"— derck_antrag_typ-CheckConstraint erlaubte diese drei Werte schlicht nicht, der Router hat das lautlos auf"erstantrag"zurückgefallen lassen, statt einen Fehler zu werfen. Der PDF-Inhalt selbst war korrekt (nutztpayload.typdirekt), nur der gespeicherteAntrag-Datensatz trug den falschen Typ — sichtbar u. a. im Dashboard unter "Offene Anträge" (falsches Label) und jetzt für T24 relevant geworden, da die "Nächste Schritte"-Checkliste sonst für zwei von drei Zielgruppen die falsche Unterlagen-Liste gezeigt hätte. Migrationd7f1a3c5e8b2erweitert den CheckConstraint um alle drei Werte, der Router speichert jetztpayload.typdirekt,ANTRAG_TYP_LABELim Dashboard um die drei fehlenden Labels ergänzt.
Dashboard: Patienten-Kacheln + klickbare offene Anträge (pflegepilot_v3 only, 2026-07-29)
Betreiber-Feedback: das Dashboard soll als Einstiegspunkt mehr Information bieten. Zwei Änderungen an GET /api/dashboard (app/routers/dashboard.py) und (app)/dashboard/page.tsx:
- "Zuletzt bearbeitete Patienten" (reine Namensliste) ersetzt durch "Patienten im Überblick": kleine anklickbare Kachel pro Patient (Name, Pflegegrad-Badge, Status, plus Badges "X Anträge offen"/"Y Aufgaben offen" — nur wenn >0, sonst kein Badge). Datenquelle:
DashboardOut.patienten_uebersichtstattletzte_patienten(Feld entfernt, keine anderen Verwender im Frontend). - "Offene Anträge"-Kachel ist jetzt klickbar (Tester-/Betreiber-Feedback: die reine Zahl war nicht klickbar) und springt per Anchor-Link (
#offene-antraege) zu einer neuen Sektion "Offene Anträge nach Patient" darunter — je Patient eine Zeile mit dem am längsten wartenden offenen Antrag als Vorschau plus Gesamtzahl ("+N weitere"), anklickbar zur Patientenakte. - Bewusst gedeckelt (
MAX_PATIENTEN_KACHELN=6,MAX_OFFENE_ANTRAEGE_GRUPPEN=5indashboard.py), damit das Dashboard nicht überladen wirkt — explizite Vorgabe aus dem Feedback ("soll aber nicht zu voll werden"). Wer mehr Patienten/offene Anträge hat, findet den Rest über/patientenbzw. die einzelne Patientenakte. - Aggregation läuft in Python (nicht als SQL-GROUP-BY), da die Datenmengen pro Konto klein sind und so sowohl die Kacheln als auch die Gruppierung aus denselben zwei bereits geladenen Listen (offene Anträge, offene Aufgaben) gespeist werden können, ohne zusätzliche Datenbank-Rundfahrten.
- Kleiner Nebenfund beim Anfassen von
models.py: dieCheckConstraint-Zeichenkette fürAntrag.typwar seit der Patientenverfügung-Migration nicht mehr aktuell (fehlte dort,vollmachtwar schon nachgezogen). Folgenlos, da diese App Schema ausschließlich über Alembic-Migrationen provisioniert (keinBase.metadata.create_all()im Code) — trotzdem korrigiert, damit der Code nicht in die Irre führt.
Dokumenten-Scan (pflegepilot_v3 only, 2026-07-29)
Betreiber-Feedback: die Zielgruppe ist zu ca. 80% Tablet-/Handy-Nutzer/innen, die oft keinen eigenen Scanner besitzen bzw. keinen PC mehr für Dateiverwaltung nutzen. Neben dem bestehenden Datei-Upload (hochladen) gibt es jetzt eine zweite Option im "Dokumente"-Bereich der Patientenakte: Kamera-Direktaufnahme mit serverseitiger Kantenerkennung/Perspektivkorrektur, mehrere Seiten zu einem PDF zusammengefasst.
- Bewusst serverseitig (Python/OpenCV), nicht clientseitig (JavaScript/WASM) — eine clientseitige Lösung (z. B. OpenCV.js) müsste jedem Handy vor der ersten Nutzung ~8-9 MB WASM zumuten; serverseitig lädt das Handy nur das rohe Foto hoch, die Rechenarbeit läuft auf dem Server. Direkte Konsequenz aus der genannten Zielgruppe.
app/dokument_scan.py: klassisches OpenCV-Rezept (Kontur des Dokuments finden → auf 4 Eckpunkte reduzieren → Perspektive entzerren). Zwei Stufen der Kulanz, bevor der Upload jemals blockiert wird: (1) findet sich eine ausreichend große Kontur, die sich aber nicht sauber auf 4 Eckpunkte reduzieren lässt, wird ersatzweise deren umschließendes gedrehtes Rechteck (cv2.minAreaRect) verwendet; (2) findet sich überhaupt keine ausreichend große Kontur (schlechtes Licht, unklarer Hintergrund), wird das Originalfoto unverändert weiterverwendet — explizite Betreiber-Entscheidung (Alternative wäre manuelles Nachjustieren der 4 Ecken gewesen, bewusst nicht für den ersten Wurf).- 2026-07-29 nachgebessert, nachdem der Betreiber die Kantenerkennung mit echten Fotos als mangelhaft zurückgemeldet hat: Median-basiertes Auto-Canny statt fixer Schwellenwerte 50/150 (robuster über unterschiedliche Belichtung hinweg), bilateraler statt Gauß'scher Weichzeichner (glättet Papierstruktur, ohne die Kante zu verwischen),
MORPH_CLOSEschließt kleine Lücken in der erkannten Kante (häufig durch Schatten verursacht), mehrere Epsilon-Werte fürapproxPolyDPstatt nur einem festen Wert, Arbeitsauflösung von 500px auf 700px erhöht (zu grob für komplexere Hintergründe). Mit einem realistischeren synthetischen Test (Bildrauschen + schwacher Kontrast, den die alte Version nicht erkannt hätte) verifiziert — die eigentliche Bewährungsprobe sind aber weiterhin echte Handyfotos unter echten Lichtverhältnissen, dafür fehlt weiterhin eine größere Testreihe. Die Erkennungsschwelle (MIN_KONTUR_ANTEIL = 0.2) bleibt der erste Regler, an dem bei anhaltenden Problemen gedreht werden sollte. - Die Bezeichnung eines Scans lautet jetzt
{Scan-Datum} ({Seitenzahl} Seiten)(z. B. „29.07.2026 (2 Seiten)“) statt generisch „Scan (2 Seiten)“ — Betreiber-Feedback: bei mehreren Scans desselben Dokumenttyps (z. B. mehrere „Kopie eines Bescheids“) waren die Einträge in der Liste sonst nicht unterscheidbar.
- 2026-07-29 nachgebessert, nachdem der Betreiber die Kantenerkennung mit echten Fotos als mangelhaft zurückgemeldet hat: Median-basiertes Auto-Canny statt fixer Schwellenwerte 50/150 (robuster über unterschiedliche Belichtung hinweg), bilateraler statt Gauß'scher Weichzeichner (glättet Papierstruktur, ohne die Kante zu verwischen),
- Mehrseitig: mehrere per Kamera aufgenommene Fotos werden zu einem gemeinsamen PDF zusammengefasst (
bilder_zu_pdf(), Pillowsave_all=True) — eine Scan-Session kann mehrere Seiten eines Dokuments sein (z. B. ein mehrseitiger Bescheid). Bewusste Betreiber-Entscheidung, da einzelne Seiten für echte Behörden-/Arztdokumente unrealistisch gewesen wären. - Kein HEIC-Support — weder OpenCV noch Pillow können HEIC ohne Zusatzbibliothek dekodieren. Unkritisch für den eigentlichen Kamera-Direktaufnahme-Flow (iOS liefert bei "Foto aufnehmen" über ein Web-
<input capture>immer JPEG, unabhängig von der Kamera-Einstellung des Geräts) — nur relevant, falls jemand statt der Live-Aufnahme ein bestehendes HEIC-Foto aus der Mediathek wählt; würde dann mit einer klaren 400-Fehlermeldung abgelehnt, nicht stillschweigend falsch verarbeitet. - Neuer Endpunkt
POST /api/patienten/{pid}/dokumente/scan(mehrere Dateien unter demselben Formularfelddateien) ergänzt, nicht ersetzt den bestehendenPOST /api/patienten/{pid}/dokumente(Einzeldatei-Upload beliebigen Formats bleibt für PDFs/Word-Dokumente etc. bestehen) — beide erzeugen dieselbeDokument-Zeile mittyp/bezeichnung, dieselbe autorisierte Download-Route. - Frontend:
components/DokumentScan.tsx— Kamera-Button sammelt Seiten client-seitig (Thumbnail-Vorschau, einzeln entfernbar) und lädt erst beim expliziten "Fertig, hochladen" alle gesammelten Fotos in einer Anfrage hoch, statt bei jedem Foto einzeln einen Request zu senden. - Neue Abhängigkeit
opencv-python-headless(keine GUI-Bibliotheken wie libGL nötig, die der Container ohnehin nie nutzt) — braucht eindocker compose build api(nicht nurrestart) beim Deploy, da neue Python-Pakete zur Image-Bauzeit installiert werden.
System-Backup im Admin-Dashboard (pflegepilot_v3 only, 2026-07-29)
Aus einem Sicherheitsgespräch mit dem Betreiber: Datenbank-Einträge sind für ihn als Admin direkt einsehbar (kein Feld-Verschlüsselungs-Feature) — das ist der Standardfall und für sich genommen kein DSGVO-Problem (Art. 32 DSGVO ist risikobasiert, Zugriff ist bereits auf den Betreiber selbst beschränkt und in der Datenschutzerklärung Ziffer 7 offengelegt). Das eigentlich relevante Risiko-Szenario ist ein Totalverlust des NAS — dagegen hilft nur ein 1:1-Backup außerhalb des Geräts, kein Verschlüsselungsfeature.
- Ein Backup ist ein
.tar.gz-Archiv mitdatabase.sql(kompletterpg_dump, Klartext-SQL statt Custom-Format für universellere Wiederherstellbarkeit ohne Versions-Zwang beipg_restore) plusuploads/(1:1-Kopie des kompletten Upload-Verzeichnisses aller Konten — Scans, Briefe/PDFs, Fotos). Bewusst kein Betroffenenrechte-Export wie das bestehende DSGVO-Export-Feature (das ist auf einen Patienten beschränkt) — dies ist ein System-Backup über alle Konten hinweg, ausschließlich für den Betreiber. - Eigenes Docker-Volume (
pflegepilot_v3_backups, gemountet unter/app/app/backups) statt eines Unterordners vonuploads_dir— bewusste Trennung, damit ein Backup-Archiv nie als "Patientendokument" durchgehen kann. - Manueller Button + automatisch täglich, beides im Admin-Dashboard (Betreiber-Entscheidung nach Rückfrage):
app/backup_scheduler.pyfolgt exakt dem Muster vonreminder_scheduler.py(Postgres-Advisory-Lock über die gesamte Thread-Lebensdauer, eigene Lock-ID412857930, stündlicher Check). Es gibt bewusst keine eigene "letztes Backup war am..."-Spalte — die Quelle der Wahrheit ist schlicht der Zeitstempel des jüngsten vorhandenen Backup-Dateinamens (backup.liste_backups()); ist der nicht von heute, wird eines erstellt. - Rollierende Aufbewahrung der letzten 5 (Betreiber-Entscheidung "3-5"), sowohl nach dem automatischen als auch nach dem manuellen Erstellen aufgeräumt (
alte_backups_aufraeumen()) — verhindert unbegrenzt viele Vollkopien aller Patientendaten auf der Platte. - Erstellen und Herunterladen werden im Admin-Audit-Log protokolliert (gleiche Begründung wie beim DSGVO-Export: ein vollständiger Datenexport über alle Konten ist ein Vorgang, der nachvollziehbar bleiben sollte) — Erstellen braucht wie bei allen anderen Admin-Aktionen eine Begründung (
BegruendungModal), Herunterladen wird automatisch mit fester Begründung "Download durch Admin" geloggt, ohne den Download durch ein zusätzliches Eingabefeld zu bremsen. Löschen eines Backups wird bewusst nicht geloggt (reine Aufräumaktion, kein Datenexport). - Dateinamen-Validierung gegen ein festes Muster (
backup_YYYYMMDD_HHMMSS.tar.gz) im Download-/Lösch-Endpunkt — Schutz vor Path Traversal über einen vom Client frei wählbaren Dateinamen. - Neue Abhängigkeit im Image:
postgresql-client(Dockerfile) für diepg_dump-Binary — braucht eindocker compose build api, nicht nurrestart, plusmkdir -p .../backupsim selbenRUN-Schritt wieuploads(sonst würde das neue Docker-Volume beim ersten Mount mit falschem Owner initialisiert, da derchown -R appuserdavor läuft). - pg_dump-17/psql-16-Versionsunterschied — ursprünglich als "harmloser Schönheitsfehler" eingeschätzt, dann durch echten Test als echter Blocker entlarvt: Das Debian-Repository des
python:3.12-slim-Basisimages liefertpostgresql-clientin Version 17, der DB-Server läuft auf Version 16 (postgres:16-Image). Ein mit pg_dump 17 erzeugter Dump enthält eineSET transaction_timeout = 0;-Zeile, die es in Postgres 16 noch nicht gibt. Solange nur der Dump selbst mit einem normalen (nicht-transaktionalen)psql-Lauf angeschaut wurde, blieb das folgenlos (Zeile erzeugt nur eine Warnung, der Rest des Dumps spielt sich korrekt ein) — das war die ursprüngliche Einschätzung unten im Restore-Abschnitt. Das stimmte nur so lange, wie kein--single-transaction --set ON_ERROR_STOP=1im Spiel war. Der eigentliche Restore-Feature (siehe unten) nutzt genau das für Atomarität — und unter dieser Kombination bricht die eineSET-Zeile die komplette Transaktion sofort ab, sodass gar nichts wiederhergestellt wird, nicht nur eine kosmetische Meldung erscheint. Das wurde erst durch einen echten, vom Betreiber ausdrücklich autorisierten Restore-Testlauf auf der NAS-Testinstanz entdeckt (nicht durch Code-Review vorhersehbar) — siehe_dump_kompatibel_machen()im Restore-Abschnitt für den tatsächlichen Fix. Lehre für diese Codebasis: ein als "kosmetisch" abgetaner Versionsunterschied kann durch eine spätere, unabhängige Änderung (hier:--single-transactionfür ein ganz anderes Feature) nachträglich zu einem echten Blocker werden — bei jeder neuen Verwendung vonpg_dump/psql-Ausgaben erneut prüfen, ob die bisherige Einschätzung noch zutrifft, statt sie unverändert zu übernehmen.
Restore aus System-Backup (pflegepilot_v3 only, 2026-07-29)
Ergänzung zum Backup-Feature (siehe oben): "wie kriege ich die ZIP-Backup-Datei nach einem Totalausfall wieder ins System" — per Single-Upload im Admin-Dashboard, das Script übernimmt den Rest. app/backup.py: restore_backup(archiv_pfad), Endpoint POST /api/admin/backups/restore (archiv: Datei-Upload, begruendung: Pflichtfeld wie bei jeder Admin-Aktion).
- Reihenfolge bewusst so, dass nichts Destruktives passiert, bevor die Datei nicht minimal als gültig bestätigt ist: 1) Archiv in ein Temp-Verzeichnis entpacken (
tarfile.extractall(..., filter="data")— Pfad-Traversal-Schutz),database.sqlmuss existieren, sonst Abbruch ohne jede Änderung am laufenden System. 2) Erst danach eine Sicherheitskopie des aktuellen Zustands anlegen (Betreiber-Entscheidung: schützt vor einem Restore mit versehentlich falscher/veralteter Datei — der Dateiname der Sicherheitskopie wird in der API-Antwort und im Audit-Log-Eintrag mitgeliefert). 3) Datenbank perpsql --single-transaction --set ON_ERROR_STOP=1einspielen — entweder komplett oder gar nicht. 4)alembic upgrade headerneut ausführen, falls seit dem Backup-Stand neue Migrationen zum aktuell laufenden Code hinzukamen. 5) Uploads-Verzeichnis durch den Inhalt aus dem Archiv ersetzen. 6)engine.dispose(), damit nachfolgende Requests garantiert frische DB-Verbindungen aufbauen. - Zwei echte Bugs, beide erst durch einen echten Restore-Testlauf auf der NAS-Testinstanz gefunden (nicht durch Code-Review oder isolierte Unit-Tests vorhersehbar — beide betreffen die Interaktion mit der echten Docker-/Postgres-Umgebung):
transaction_timeout-Blocker (siehe oben im Backup-Abschnitt für die volle Herleitung):_INKOMPATIBLE_SET_MUSTER/_dump_kompatibel_machen()inbackup.pyentfernt dieSET transaction_timeout = ...;-Zeile aus dem extrahierten Dump, bevor er anpsqlgeht — gezielter Fix statt der (für einen einzigen Zeilen-Bug unverhältnismäßigen) Alternative, ein zweites PGDG-Apt-Repository für exakte Versions-Parität einzurichten.- Uploads-Verzeichnis ist ein Docker-Volume-Mountpunkt: der ursprüngliche Code rief
shutil.rmtree(settings.uploads_dir)gefolgt vonshutil.copytree(...)auf — schlug mitOSError: [Errno 16] Device or resource busyfehl, weil ein Mountpunkt selbst (anders als sein Inhalt) nicht gelöscht/neu angelegt werden kann. Gefixt, indem nur der Inhalt des Verzeichnisses geleert wird (os.listdir()+ einzelnrmtree/removeje Eintrag), der Mountpunkt selbst aber bestehen bleibt, dann die neuen Dateien pershutil.move()hineingeschoben werden.
- Vollständig end-to-end gegen die echte NAS-Testinstanz verifiziert (mit expliziter Betreiber-Freigabe für einen echten — nicht nur simulierten — Restore-Test, nachdem Claude Codes eingebauter Sicherheits-Klassifizierer den ersten direkten Versuch dieses destruktiven Befehls blockiert hatte und stattdessen nachgefragt wurde): ein Test-Marker-Wert in
einstellungensowie die Patientenanzahl/der Alembic-Migrationsstand wurden vor und nach dem Restore verglichen, zusätzlich die tatsächlichen Datei-Inhalte des wiederhergestelltenuploads/-Verzeichnisses stichprobenartig geprüft — alle korrekt. Nach erfolgreichem Test wurden die dabei automatisch erzeugten Sicherheitskopien sowie der Test-Marker-Eintrag wieder von der NAS entfernt. - Ein Restore aus einem älteren Backup lässt den Datenbank-Zustand während des Wiederherstellens serverseitig ungeschützt (kein Wartungsmodus/Sperre für parallele Requests) — bewusst kein zusätzlicher Aufwand dafür, da diese Funktion für den seltenen "Totalausfall"-Fall gedacht ist, nicht für den laufenden Betrieb; ein Restore sollte praktisch immer in einem Wartungsfenster ausgeführt werden.
eGK-Scan / OCR-Kartenerfassung (pflegepilot_v3 only, 2026-07-29, T8)
Letzter offener Punkt aus dem Tester-Feedback (siehe Feedbackfromtester.md): Krankenkassenkarte abfotografieren statt Versichertennummer/Krankenkasse/Name manuell abzutippen. Vier Produktentscheidungen vorab mit dem Betreiber abgestimmt (AskUserQuestion):
- Tesseract OCR statt Cloud-Vision-API: kostenlos, läuft offline im Container, kein Account/API-Key/Zahlungsmethode nötig (gleiche Grundsatzentscheidung wie OSM statt Google Places bei der Umkreissuche), Kartenfoto verlässt nie den eigenen Server. Kehrseite: spürbar geringere Erkennungsgenauigkeit als eine Cloud-Lösung, insbesondere bei schlechtem Licht/Winkel — bewusst in Kauf genommen.
- Nur GKV (elektronische Gesundheitskarte), keine PKV-Karten: die eGK hat ein von gematik/GKV-Spitzenverband standardisiertes Layout (Versicherten-ID immer 1 Buchstabe + 9 Ziffern, feste Feldbezeichnungen "Name"/"Vorname"), das eine regelbasierte Erkennung überhaupt erst realistisch macht — PKV-Karten haben kein einheitliches Layout.
- Alle fünf möglichen Felder werden übernommen (Vorname, Nachname, Geburtsdatum, Versichertennummer, Krankenkasse), nicht nur die beiden Versicherungsfelder — Betreiber-Vorgabe: der Scan-Button sitzt deshalb in Schritt 1 des "Neuer Patient"-Assistenten (persönliche Daten), nicht erst in Schritt 3 (Krankenversicherung), damit ein einziger Scan beide Schritte vorausfüllt. Alle Felder bleiben normal manuell überschreibbar, eine verpasste/falsche Erkennung blockiert nie die Nutzung.
- Verfügbar sowohl im "Neuer Patient"-Assistenten als auch im Patient-Bearbeiten-Formular (z. B. bei Kassenwechsel eines bestehenden Patienten).
Technisch:
app/egk_ocr.py: Tesseract (pytesseract, deutsches Sprachpaket) liest den Kartentext, danach regelbasierte Feld-Extraktion per Regex (Versicherten-ID:[A-Z]\d{9}; Geburtsdatum:TT.MM.JJJJ) und Label-Zeilenerkennung ("Name"/"Vorname" gefolgt vom Wert in derselben oder der nächsten Zeile). Die Kartenkontur-Erkennung/Entzerrung vor der OCR wird 1:1 vondokument_scan.erkenne_und_entzerre()wiederverwendet statt eine zweite Kantenerkennung zu schreiben — eine eGK ist geometrisch nur ein kleineres Rechteck als ein A4-Dokument, dieselbe Heuristik passt.- Krankenkassen-Erkennung per Fuzzy-Matching (
difflib.SequenceMatcher) gegen die bestehendekrankenkassen.py-Stammdatenliste, da der Kassenname auf der Karte oft als Logo statt als OCR-lesbarer Text gedruckt ist. Ein echter Fehltreffer wurde beim Verifikationstest mit einem synthetischen Testbild gefunden und behoben, bevor das Feature als fertig galt: eine kurze, komplett unpassende Zeile (Testname "Mustermann") wurde mit einem zu niedrigen Cutoff fälschlich als "Barmer" erkannt. Gefixt mit zwei Sicherungen: (1) ein Längenunterschied von mehr als 4 Zeichen zwischen OCR-Zeile und Kandidatenname wird gar nicht erst verglichen (SequenceMatcher-Werte werden bei sehr unterschiedlich langen Strings unzuverlässig), (2) ein fester Mindest-Punktzahl-Schwellenwert (_KASSE_MIN_PUNKTZAHL = 0.72) statt "bester Kandidat, egal wie schwach" — lieber kein Treffer als ein falscher. POST /api/egk/scan(app/routers/egk.py), 1-2 Fotos (Vorder-/Rückseite),require_login-geschützt, bewusst nicht patientengebunden (keinpidim Pfad) — wird sowohl vor der Patientenanlage (wo noch keine Patienten-ID existiert) als auch beim Bearbeiten genutzt. Keine Persistierung: weder das Kartenfoto noch das Erkennungsergebnis werden serverseitig gespeichert, die Response füllt ausschließlich clientseitig Formularfelder vor.- Frontend:
components/EgkScan.tsx(Kamera-Capture nach demselbencapture="environment"-Muster wieDokumentScan.tsx, max. 2 Fotos), eingebaut inpatienten/neu/page.tsx(Step1) undpatienten/[id]/bearbeiten/page.tsx. - Neue Abhängigkeiten:
tesseract-ocr+tesseract-ocr-deu(Dockerfile, apt),pytesseract(requirements.txt) — braucht eindocker compose build api, nicht nurrestart. - Ohne echte Testfotos verifiziert (wie ursprünglich beim Dokumenten-Scan) — nur ein synthetisches Testbild mit bekanntem, sauberem Text stand zur Verfügung. Die Feld-Heuristiken (insb. Name/Vorname-Zeilenerkennung, die auf dem exakten Aufdruck-Wortlaut der eGK beruht) sollten nach den ersten echten Nutzungen erneut geprüft werden — gleiche Lehre wie beim Dokumenten-Scan ("Kantenerkennung ist mangelhaft" → Nachbesserung erst nach echten Fotos möglich).
Email: mailto hand-off, not SMTP
There is no server-side email sending anywhere in the app — no SMTP client, no credentials, no relay. This was a deliberate removal (not a missing feature) done ahead of Docker containerization, because post-Docker the operator (Daniel) is the sole admin with no per-role differentiation to lean on — any email solution had to work identically for every account, not just an admin-configured one. mailto: is the "universal command": every OS/browser resolves it to the user's own default mail client (iOS → Mail, Android → Gmail/Outlook/whatever is set as default, desktop → whatever is set as default), with zero server involvement and zero secrets to manage in the container.
- Previously removed:
smtplib/email.mime.*imports,_smtp_cfg(),_smtp_send(),POST /api/email/senden,POST /api/email/test, the "E-Mail (SMTP)" Backend-Einstellungen card, and theemail_smtp_*settings keys. None of this exists anymore — don't resurrect it as a "fallback"; the user explicitly chose full removal over keeping SMTP as a backup path. - Frontend flow (
sendMailModal(typ)/sendMail(typ)intemplates/index.html, shared by both Musterbrief and Widerspruch-Generator via thetypparameter): the modal collects Empfänger/Betreff/Anschreiben, then on send the existing client-sidehtml2pdf.jsPDF generation runs exactly as it already did for the plain "PDF herunterladen" button (dlW()/dlM()) — the blob is downloaded to the user's device and persisted to the patient's Akte viaPOST /api/patienten/<pid>/briefe— thenlocation.href='mailto:'+to+'?subject=...&body=...'opens the user's mail client with recipient/subject/body pre-filled. - Hard limitation, not a bug:
mailto:cannot carry attachments — this is a browser/OS security restriction, not something this app can work around. The email body includes a bracketed reminder ("Bitte die heruntergeladene Datei „…" anhängen, dann diese Zeile löschen") and the modal's info box explains the same thing before sending, so the manual-attach step is expected UX, not a silent gap. - If you add a third document type that needs "send by email", follow the same pattern: reuse the existing PDF-blob-download code for that document, then call the same
mailto:construction — don't reintroduce a server-side send path.
Update-Benachrichtigung / Changelog
A per-account "what's new" system tells a user, once per account, what changed since they last looked — needed now that the app ships as versioned Docker images rather than one continuously-running dev process (see "Docker deployment" above).
Incident 2026-07-28: changelog_seed.py on the NAS had two historical entries ("1.2.0" – "Checkliste, Leistungsauswahl, Vollmacht und mehr" dated 2026-07-27, and "Version 1.0.1" – "Klein-Anpassungen") that were never present in the git-tracked copy of this file — the same class of NAS/git divergence documented for pdf_templates/brief.html (see the NAS-deployment memory). A new local entry was written using version "1.2.0" without first diffing against the NAS, which would have silently never synced (since sync_changelog() only inserts missing versions, an already-used version string is a silent no-op, not an overwrite or an error) — caught before deploying by comparing the live GET /api/changelog output against the local file. Fixed by restoring both missing historical entries into git (so the file is a complete record again) and renumbering the new entry to "1.3.0". Lesson: before adding a new changelog entry, always fetch the current GET /api/changelog (or check the DB) rather than assuming the local changelog_seed.py is the complete history — the DB is the runtime source of truth and can contain entries the git file never had, exactly like the brief.html case.
Policy: every feature/change from here on gets a changelog entry (either a new changelog.py entry, or an admin-added one via the UI described below), not just a commit message and a CLAUDE.md note — the two aren't a substitute for each other. CLAUDE.md is for future-Claude/developers; the changelog is for end users. If you ship a user-visible change, add the entry as part of that same piece of work, not as an afterthought.
- Two-layer source of truth:
changelog.pyis the developer-authored seed (same static-module pattern askatalog.py/krankenkassen.py) — aCHANGELOGlist of{version, datum, titel, punkte}entries, always kept newest-first. The actual runtime source of truth is the DB tablechangelog_eintraege(id, version, datum, titel, punkte—punktestored as a JSON-encoded string list). On everyinit_db()run, anychangelog.pyentry whoseversionisn't yet in the DB gets inserted (for eintrag in CHANGELOG: ... INSERT ... if not already present) — a one-way sync, not a mirror: editing an already-synced entry via the admin UI (below) does not get overwritten back fromchangelog.pyon the next restart, since the sync only inserts missing versions, never updates existing ones. To ship a release note tied to a real code change: add one new entry to the top ofCHANGELOGinchangelog.pyand nothing else — it'll sync into the DB on next startup. For a quick announcement or correction that doesn't need a deploy, use the admin UI instead (below). _changelog_alle()inapp.pyreads all DB entries ordereddatum DESC, id DESC(newest first)._app_version()is_changelog_alle()[0]["version"](or"0.0.0"if the table is somehow empty) — there is deliberately no separate version constant anywhere to remember to bump; whatever's newest in the DB is the current version.nutzer.letzte_gesehene_version(TEXT, nullable) tracks the last version each account has acknowledged._changelog_ungesehen(letzte_gesehene_version)walks_changelog_alle()from the top and collects entries until it hits the stored version (or returns everything if the value isNone/not found — "show more rather than risk hiding something").- New vs. existing accounts are handled differently on purpose: every
INSERT INTO nutzer(default admin bootstrap, demo bootstrap, andPOST /api/nutzerfor admin-created accounts) setsletzte_gesehene_version = _app_version()immediately, so a brand-new account starts "caught up" and is never shown historical entries it never lived through. Existing accounts, by contrast, get the column via the usualALTER TABLE ADD COLUMNmigration (see schema-evolution pattern above), which leaves itNULL— so on the release where this shipped, and on any future upgrade of a running deployment, already-existing accounts correctly see whatever's newer than what they've acknowledged. Don't backfill existing rows to "already seen" — that would silently suppress the exact notice this feature exists to show. GET /api/auth/mecarriesapp_versionandchangelog_ungesehen(the actual unseen entries, not just a flag) on every call, so the frontend needs no extra round-trip to decide whether to show anything.POST /api/changelog/gelesenmarks the current version as seen for the logged-in user.GET /api/changelogreturns the full history withids included (needed by the admin edit/delete UI, see below) and is intentionally not on the public allowlist — it's only reachable once logged in, same as the rest of the app shell.- Frontend:
initApp()shows a modal (showChangelogModal(), built on the existingOM()modal helper) with any unseen entries right after login/app-load, unlessaboBlockiertis true (a payment-blocked account gets sent tomeinaboinstead — no point distracting it with release notes it can't act on yet; it'll see the notice on its next unblocked login). A sidebar nav item "Was ist neu?" (pgChangelog(), under theKontosection next toMein Abo) shows the full history at any time and carries a small red#cl-badgedot (matching the existing#abadge/#kbadge/#tb-reminder-badgepattern) while there's anything unseen; visiting the page itself also calls/api/changelog/gelesen, so opening the history counts as acknowledging it too, not just clicking "Gelesen" in the modal. - Admin can manage entries directly in the app —
POST/PUT/DELETE /api/admin/changelog[/<id>](all@admin_required), surfaced as "+ Neuer Eintrag" plus per-entry edit/delete icons on thepgChangelog()page itself whencurUser.rolle==='admin'(clFormModal()/clSpeichern()/clLoeschen()intemplates/index.html) — no separate admin-only page, the same "Was ist neu?" view just gets extra controls.punkteis entered as one bullet per textarea line client-side, split into a list before sending. All rendered text (titel,punkte,version) goes throughescH()now that this content can come from a UI form, not just a trusted static file — don't drop that when touching this code. Note: the existingeditNutzerModal(n)elsewhere in this file passes a whole object viaonclick="fn("+JSON.stringify(n)+")"inlined into an HTML attribute, which is fragile (breaks if any field contains a"); the changelog admin UI deliberately avoids that pattern and instead keeps aclEntriesCachearray in JS and passes only the numericidintoonclick, looking the entry back up client-side — prefer this approach for any new "edit via inline button" UI.
Demo account
A permanent demo/demo account (nutzer.ist_demo=1) exists for showing the app to prospects, created automatically in init_db() if missing. It's a completely ordinary nutzer-role account — no special-casing in the isolation model — with two exceptions layered on top:
- Immune to the subscription paywall:
_hat_aktiven_zugang()returnsTrueforist_demoaccounts unconditionally, before theabo_pflichtcheck. Don't remove this — a demo visitor must never see "Zahlung erforderlich" regardless of the operator's billing settings. (This was caught by testing, not designed in from the start — worth re-checking if_hat_aktiven_zugang()is ever refactored.) - Email is a non-issue for the demo account: since email sending was migrated to the client-side
mailto:hand-off (see "Email: mailto hand-off, not SMTP" below), there is no server-side outbound relay left to abuse — a demo visitor's "send" click only opens their own local mail client via amailto:link, never the operator's infrastructure. The earlier 403 block on/api/email/senden//api/email/testforist_demoaccounts was removed along with those routes; no replacement guard was needed.
Auto-reset every 6 hours, lazily rather than via a background thread/cron (this app has no task scheduler and stays that way — a lazy check fits the existing "one Flask process, no queue" architecture and can't miss a tick across restarts): _demo_reset_faellig() compares the einstellungen key demo_reset_at against now; _demo_reset() deletes every patient owned by the demo account (cascades to antraege/leistungen/aufgaben/pflegetagebuch/dokumente via the existing ON DELETE CASCADE FKs — SQLite enforces this because get_db() sets PRAGMA foreign_keys = ON), then reseeds one fictional pre-filled patient (_demo_seed(): "Grete Musterfrau", Pflegegrad 3, one Antrag, two Leistungen, two Aufgaben, one Pflegetagebuch-Eintrag) and bumps demo_reset_at. The check/reset call happens in two places — api_auth_login() (fresh state at the start of every demo session) and global_auth_check() (catches a session that's still open when the 6h boundary passes mid-visit) — both call the same _demo_reset_faellig()/_demo_reset() pair, so there's one source of truth for "is a reset due."
The demo account can create up to 2 more patients itself (same 3-per-account cap as any nutzer account) via the normal interview flow — nothing special needed there, it's just an ordinary account under the hood.
Frontend: #demo-badge in the topbar (toggled in initApp() from me.ist_demo) makes it visually obvious when a session is in demo mode. Login screen has a dedicated "Demo ausprobieren" button (doDemoLogin()) that fills the credentials and reuses doLogin() — no separate login code path.
Admin focus, self-registration & legal pages
Four related pieces shipped together, all from open.md items surfaced by real usage after the Docker test run:
- Admin's sidebar is decluttered: any
.sb-sec/.nielement carryingdata-role="nutzer"(Patienten, Leistungskatalog, Formulare & Recht, Aufgaben & Fristen, Mein Abo) is hidden forrolle==='admin'ininitApp()— admin has no patients of their own, so those sections are noise. Admin lands onadmindash(notdashboard) right after login (doLogin()) and on page reload (the bottominitApp().then(...)call) — Nutzerverwaltung is the admin's actual job here, not care-record data entry. - Admin-Dashboard is now the one-stop nutzer-management view:
renderAdminDash()'s Konten table gainedRegistriert(erstellt_am) andLetzter Login(+ IP) columns, plus a Bearbeiten button (adminNutzerBearbeitenModal()) that edits name/rolle/aktiv/passwort and the Rechnungsanschrift in one modal — previously only the simpler Backend "Nutzerverwaltung" card (still there, still fine for quick account creation) could edit a profile at all, and it had no billing-address or login-history visibility.letzter_login/letzte_ip(nutzercolumns) are stamped on every successful login and self-registration inapi_auth_login()/api_auth_registrieren()—X-Forwarded-Foris honored ahead ofrequest.remote_addrfor when a reverse proxy sits in front (relevant once the DynDNS/testbench setup is live), taking the first hop only. - Self-registration (
POST /api/auth/registrieren, public — added to theglobal_auth_check()allowlist): a "Noch kein Konto? Jetzt registrieren" link on the login screen (showRegisterForm()/hideRegisterForm()toggle the same.lo-box, no separate page) collects Benutzername/Passwort/Name plus a mandatory Rechnungsanschrift (rg_name/rg_strasse/rg_plz/rg_ortonnutzer) and auto-logs the new account in on success (doRegister()). New accounts are markedselbst_registriert=1— admin-created accounts (POST /api/nutzer, still admin-only) stay0and are deliberately unaffected by everything below, so nothing changes for how admin onboards trusted/manual accounts. - Freemium write-lock for unpaid self-registrations:
_hat_bezahlten_zugang(u)(realabo_bischeck, admin/demo always true) is deliberately separate from_hat_aktiven_zugang()/abo_pflicht— a self-registered account without a paid/granted subscription can browse and read everything, butglobal_auth_check()blocksPOST/PUTto the letter-generation (.../briefe), Leistungen-assignment (.../leistungen/katalog,.../deaktivieren) and Aufgaben-creation (/api/aufgaben) endpoints with a 402 regardless of the globalabo_pflichttoggle. This only applies whenselbst_registriert=1, so it can go live immediately (no PayPal needed yet) without touching existing admin-created accounts.GET /api/auth/meexposesschreibzugriff_gesperrtso the frontend can short-circuit before doing client-side work (PDF generation, etc.) rather than relying on the 402 alone —_schreibgesperrt()intemplates/index.htmlis the shared guard, called at the top ofdlM()/dlW()/sendMail()/assignL()/saveAufg(); it toasts and redirects tomeinabo. Lifting the lock is just admin grantingabo-freischalten(existing mechanism) or, eventually, real payment — no separate "unlock" code path needed. - Admin can generate a legally compliant PDF invoice per account (
rechnungModal()→rechnungErstellen()intemplates/index.html) — client-side PDF via the samehtml2pdf.jspattern as Musterbrief/Widerspruch (Courier-style text block,addPageNums()), but the invoice number itself is server-issued:POST /api/admin/rechnungsnummer/naechste(admin-only) atomically increments aneinstellungen-backed counter (rechnungsnummer_counter+rechnungsnummer_jahr, format{Jahr}-{Nummer:04d}, resets to 1 on a new calendar year) so two invoices can never collide on the same number — required by § 14 Abs. 4 Nr. 4 UStG ("fortlaufende, einmalige Nummer"), which a purely client-side/date-based guess couldn't guarantee. The rest of § 14 UStG's Pflichtangaben are covered by a dedicated "Rechnungsstellung" card in Backend-Einstellungen (rechnung_absender_name/_strasse/_plz/_ort,rechnung_steuernummer,rechnung_ustid,rechnung_kleinunternehmer,rechnung_ust_satz) — deliberately separate from the "Betrieb" card, since the invoice seller address/tax data can legitimately differ from (or need to be more complete than) the branding address, and isn't needed by the public/api/einstellungen/publicendpoint the way Impressum/Datenschutz are, so it's fetched fresh from the admin-only/api/admin/einstellungeninsiderechnungModal()instead of the cached_einstellungenglobal.rechnungErstellen()refuses to generate a PDF (with a toast, not a silent blank field) if the seller address or a Steuernummer/USt-IdNr is missing. Depending onrechnung_kleinunternehmer, the invoice either shows a Netto/USt-Satz/USt-Betrag/Brutto breakdown (betragBrutto / (1 + ustSatz/100)for Netto) or the § 19 UStG small-business exemption note instead of a tax line — never both. No server-side persistence/archival of the invoice PDF itself (unlike patient documents) — these aren't care records under the §630f BGB retention duty, so a simple download suffices for "on customer request"; only the invoice number counter is persisted, since that's the part that must never repeat or gap. - Legal pages: three
rechtstext_impressum/rechtstext_datenschutz/rechtstext_agbkeys ineinstellungen, edited as plain textareas in the Backend-Einstellungen card that replaced "KI – Claude API" (same slot, samesaveEinst()pattern). They're deliberately included in the public/api/einstellungen/publicpayload (no login required) since Impressum/Datenschutz shouldn't hide behind a login wall, andpgRechtstext(typ)(routed viago('impressum'|'datenschutz'|'agb')) reads them straight from the already-cached_einstellungenobject. A small.sb-legallink row was added to the sidebar bottom, andgo()exempts these three page names from theaboBlockiertredirect-to-meinabologic — a payment-blocked account must still be able to read the legal pages. - The "Betrieb" card was deliberately kept, not removed, despite the original
open.mdwording ("das kann weg") —firmenname/firmen_logo/firmen_strasse/etc. are actively used for sidebar branding and the "Erstellt mit {firmenname}" footer on generated PDFs (Musterbrief, Widerspruch, Pflegetagebuch, Patientenakte); deleting the only UI to edit them would have been a silent regression unrelated to the actual ask. If this was in fact meant to go, that's a call for the product owner, not something to infer from "veraltet."
Known constraints worth knowing before touching auth/security-sensitive code
app.secret_keyinapp.pyis a hardcoded string, not loaded from an environment variable.- The default admin account (
admin/admin) is created automatically and nothing forces a password change. - These are pre-existing conditions in the current alpha, relevant context if working on the auth/admin-dashboard parts of the refactor above.
iOS Safari: Autofill-Attribute nachgerüstet (pflegepilot_v3 only, 2026-07-29)
Tester-/Kunden-Feedback: auf iOS Safari funktionierte Autofill "nicht sauber" und einzelne Wizard-Schritte blieben teils bei "Lädt…" hängen. Bei der Untersuchung wurde festgestellt, dass praktisch jedes Formularfeld der eingeloggten App komplett ohne name- und autoComplete-Attribute war — nur Login/Registrierung/Passwort-Reset hatten das korrekt gesetzt. Das ist ein bekanntes React+Safari-Muster: ohne diese Attribute trägt iOS Safaris Autofill den Wert teils direkt ins DOM ein, ohne das Event auszulösen, auf das Reacts kontrollierte Inputs lauschen — das Feld sieht sichtbar ausgefüllt aus, der React-State bleibt aber leer (Validierung schlägt fehl / leere Werte werden abgeschickt).
- Nachgerüstet in: "Neuer Patient"-Assistent, Patient-Bearbeiten-Formular, Musterbrief/Widerspruch/Ummeldung/Vollmacht/Patientenverfügung-Generatoren, Aufgabe-anlegen-Modal, Login/Registrierung/Passwort-Reset (dort schon vorhanden, jetzt zusätzlich mit stabilen
name-Attributen), Admin-Nutzer-anlegen, Umkreissuche-PLZ-Feld, Katalog-/Patienten-Suchfelder,KrankenkasseAutocomplete/RentenversicherungAutocomplete. - Wichtige Unterscheidung, kein pauschales
autoComplete="name"überall: Felder, die den Kontoinhaber selbst betreffen (Rechnungsanschrift bei der Registrierung, "Bevollmächtigte/r (Sie)" bei der Vollmacht, Admin-Nutzer-Passwort alsnew-password), bekamen echte semantische Autofill-Tokens (given-name/family-name/street-address/postal-code/address-level2/tel/email/new-password) — dort ist Browser-Autofill mit den eigenen gespeicherten Daten korrekt und erwünscht. Felder, die eine andere Person betreffen (die gepflegte Person im "Neuer Patient"-Assistenten, die "Versicherte Person" im Musterbrief, Vertrauenspersonen in der Patientenverfügung), bekamen bewusstautoComplete="off"statt eines passenden Tokens — sonst würde iOS Safaris Kontakt-Autofill das Formular leicht mit den eigenen Kontaktdaten des Nutzers befüllen, was falsch wäre und ebenfalls unter "Autofill funktioniert nicht sauber" fallen würde. - Noch nicht angefasst (bewusst niedrige Priorität, kein Kundenkontakt oder keine personenbezogenen Felder): Changelog-Admin-Editor,
BudgetTracker-Beträge, Zeitfelder in den Erinnerungs-Einstellungen. - Ohne echtes iOS-Gerät verifiziert (Betreiber hat keins zur Verfügung) — Absicherung ausschließlich über Code-Review gegen ein bekanntes, gut dokumentiertes Browser/React-Zusammenspiel, TypeScript- und ESLint-Prüfung. Sollte das Problem nach diesem Fix fortbestehen, ist der zweite Verdacht (siehe nächster Abschnitt) der wahrscheinlichere Haupttäter.
Frontend-Produktions-Build statt next dev (pflegepilot_v3 only, 2026-07-29, Phase 7)
Zweiter, vermutlich gewichtigerer Verdacht zum selben iOS-Safari-Feedback ("Lädt…" bleibt stehen): die Produktion lief bis dahin über next dev (Next.js-Entwicklungsserver, Turbopack) statt eines echten Produktions-Builds — im Frontend-Dockerfile stand das explizit als offener "Phase 7"-Punkt. Der Dev-Server braucht eine Hot-Module-Reload-Websocket-Verbindung im Hintergrund; es gab in diesem Repo bereits eine dokumentierte frühere Begegnung mit fast demselben Symptom (next.config.ts-Kommentar zu allowedDevOrigins: "…hängt die HMR-Verbindung, was im Turbopack-Dev-Modus die Hydration blockiert (Seite bleibt bei der server-gerenderten 'Lädt…'-HTML stehen)"). iOS Safari geht mit WebSockets über Hintergrund-Tabs/Reverse-Proxy-Verbindungen nachweislich anders um als Chrome — unabhängig vom genauen Mechanismus ist ein Dev-Server in Produktion ohnehin nie für echten Nutzerverkehr gedacht.
next.config.ts:output: "standalone"ergänzt — Next erzeugt dadurch ein eigenständiges, minimales Server-Bundle (server.js+ nur benötigtenode_modules), Grundlage für ein schlankes Produktions-Image.frontend/Dockerfile: komplett auf einen mehrstufigen Build umgestellt (builder-Stage:npm ci+npm run build;runner-Stage: nur.next/standalone+ separat kopiertes.next/static+public/, non-rootappuser).CMDist jetztnode server.jsstattnpm run dev.NEXT_PUBLIC_*-Variablen werden beim Build eingebacken, nicht erst zur Laufzeit gelesen — anders als normale Server-Env-Variablen (z. B.API_INTERNAL_URL, die weiterhin ganz normal zur Laufzeit gelesen wird).docker-compose.ymlsweb-Service reichtNEXT_PUBLIC_API_URL/NEXT_PUBLIC_SITE_URLdeshalb jetzt als Build-Args (build.args) durch, nicht nur alsenvironment:— ein reines Setzen zur Laufzeit hätte keine Wirkung mehr auf den bereits fertig gebauten Client-Code gehabt.- Kein Bind-Mount/keine anonymen Volumes mehr für
web(anders als beim bisherigennext dev-Setup und anders als beimapi-Service, der das Bind-Mount-Muster für schnelle Iteration behält) — das Image enthält das fertig gebaute Bundle. Wichtigste Konsequenz für künftige Arbeit: eine Code-Änderung am Frontend braucht ab jetzt immerdocker compose build webvordocker compose up -d --force-recreate web(reinesrestart/up -dohne vorherigen Build zeigt nur den alten Stand) — das bisherige Muster "Datei per scp kopieren, dann nur neustarten" funktioniert fürwebnicht mehr. Fürapibleibt das alte Muster unverändert gültig (dort weiterhin Bind-Mount +next dev-Analogon existiert nicht, Uvicorn/Gunicorn liest Code-Änderungen ohnehin erst nach Neustart, kein Rebuild nötig). - Betreiber-Entscheidung: ein Modus für lokale Entwicklung UND NAS/Produktion (keine separate
docker-compose.dev.yml/docker-compose.prod.yml) — bewusst in Kauf genommen, dass lokales Iterieren jetzt ebenfalls einen Rebuild pro Änderung braucht, statt zwei Compose-Konfigurationen parallel zu pflegen. - Vor dem eigentlichen Umschalten auf der NAS verifiziert, ohne den laufenden Dev-Container zu unterbrechen: neues Image separat gebaut (
docker compose build webändert nur das Image, nicht den laufenden Container), testweise unter einem anderen Port (docker run ... -p 3199:3000) parallel gestartet, dort Startseite/Dashboard/robots.txt sowie alle referenzierten_next/static-JS-Chunks auf HTTP 200 geprüft, SEO-Metadaten/JSON-LD/Feature-Kacheln der Landingpage inhaltlich verglichen — erst danach den echtenweb-Service umgeschaltet und zusätzlich über die echte Domain via Reverse-Proxy (curl --resolve meinpflegefuchs.de:443:127.0.0.1 https://meinpflegefuchs.de/…, korrektes SNI nötig, sonst TLS-Handshake-Fehler) bestätigt. - Ohne echtes iOS-Gerät verifiziert (Betreiber hat keins zur Verfügung) — die Kernannahme (kein Dev-Server/HMR mehr im Produktionspfad) ist aber unabhängig vom tatsächlichen Safari-Verhalten schon für sich genommen die richtige Grundlage für einen produktiven Deploy.
Antragsstatus-Tracking + automatischer Widerspruchs-Vorschlag (pflegepilot_v3 only, 2026-07-31, T25)
Letzter Punkt aus dem Tester-Feedback-Backlog (Feedbackfromtester.md, Side-Plan ~/.claude/plans/antragsstatus-tracking.md). Wunsch: das System soll "regelmäßig abfragen", ob ein gestellter Antrag bewilligt/abgelehnt wurde. Realitätscheck (mit Betreiber vorab geklärt): Kranken-/Pflegekassen bieten keine Status-API — "regelmäßig abfragen" bedeutet technisch, dass das System periodisch den Nutzer selbst per E-Mail fragt, nicht dass echte externe Automatisierung stattfindet.
- Drei neue Antrag-Felder (Migration
a1c4e7f9b3d6):bewilligter_betrag,aktenzeichen(vorher nur transient inWiderspruchRequest, nie persistiert),naechste_statuserinnerung_am(Fälligkeitsdatum der nächsten "ist der Bescheid da?"-Mail) sowiewiderspruch_eskalation_gesendet(Dedupe-Flag). Neue Tabelleantrag_aktions_tokenfür Magic-Link-Einmal-Token (SHA-256-Hash-Muster wieNutzer.reset_token_hash, sieheapp/antrag_token.py). - Erinnerungsintervall je Antragstyp (
static_data/antrag_fristen.py: ANTRAG_ERINNERUNGS_TAGE): 35 Tage fürerstantrag/hoeherst(orientiert an der 25-Arbeitstage-Frist § 18 Abs. 3 SGB XI + Puffer), 56 Tage fürwiderspruch, 42 Tage füreingliederung/grundsicherung/blindenhilfe. Wiederholt sich alle 21 Tage, solange keine Antwort eingetragen wird, bricht nach 180 Tagen ganz ab (kein endloses Nerven).adressaenderung/vollmacht/patientenverfuegungsind bewusst nicht im Tracking, da dort kein Bescheid erwartet wird. - Magic-Link-E-Mail statt reiner Statusabfrage in der App:
reminder_scheduler.py(selber Hintergrund-Thread/Advisory-Lock wie Tagebuch-/Aufgaben-Reminder, kein neuer Scheduler nötig) verschickt eine Mail mit drei Links ("Ja, bewilligt" / "Ja, abgelehnt" / "Noch keine Antwort"), je ein Klick landet auf(marketing)/antrag-status(bewusst in der(marketing)-Gruppe, nicht(app), aus demselben Grund wie die Passwort-Reset-Seite —AuthOverlaywürde sonst blockieren, bevor der Token überhaupt eingelöst ist), löst dortPOST /api/antraege/aktion-token/einloesenaus (öffentlicherrouter_publicohnerequire_login, exakt wiepasswort-zuruecksetzen) und setzt dabei eine echte Session (request.session["nutzer_id"] = ...), sodass der Rest der App danach ganz normal hinter dem bestehenden Login-Schutz weiterläuft. Ein Token deckt alle drei Ergebnis-Links ab (Ergebnis kommt als eigener Query-Parameter) und ist strikt Einmalgebrauch — nach einem Klick sind die anderen beiden Links derselben Mail ungültig. - Bei Ablehnung eines Pflegegrad-Antrags (
erstantrag/hoeherst) landet der Nutzer auf einer neuen "Keine Sorge"-Seite ((app)/antraege/[id]/keine-sorge): sachlich-ermutigender Text (bewusst ohne erfundene Erfolgsquote — nicht verifizierbar, rechtlich heikel; stattdessen häufige, tatsächliche Ablehnungsgründe aus der bereits bestehendenWGRUENDE-Bibliothek in einfacher Sprache), Fristanzeige (§ 84 SGG, 1 Monat, farbcodiert nach Resttagen) und ein eingebetteter Mini-Widerspruch-Generator (Patient/Bescheiddaten vorbefüllt, Nutzer wählt weiterhin selbst die zutreffenden Gründe — das kann die App nicht automatisch wissen). Für die anderen drei Antragstypen (eingliederung/grundsicherung/blindenhilfe) gibt es noch keine passenden Widerspruchsgründe inWGRUENDE— dort verlinkt die Seite nur auf das bestehende allgemeine/widerspruch-Formular. Ein "Vollständiges Formular öffnen"-Link übergibt alle bereits erfassten Werte als Query-Parameter an/widerspruch(das dafür wie/musterbriefeinen Suspense-Wrapper füruseSearchParams()bekam) — kein doppeltes Ausfüllen. - Fristeskalation: zusätzlich zur einmaligen Erklärung auf der Keine-Sorge-Seite verschickt
reminder_scheduler.py._sende_widerspruch_eskalation()ca. 7 Tage vor Ablauf der Widerspruchsfrist eine dringlichere Mail, falls noch kein Widerspruch erstellt wurde. Die Prüfung "wurde schon Widerspruch eingelegt" läuft rein über eine Patient+Zeitpunkt-Korrelation (Antrag.typ='widerspruch'miterstellt_amnach dem ursprünglichen Antrag) — kein neues FK-Feld zwischen einem Antrag und seinem Widerspruch. - Sowohl App-generierte als auch extern gestellte Anträge werden getrackt: der bereits bestehende generische
POST /api/antraege-Endpunkt deckte das Nachtragen extern gestellter Anträge schon ab, es fehlte nur eine Frontend-Fläche — neuer Button "+ Antrag manuell erfassen" in der Patientenakte. Ebenso neu: eine "Status bearbeiten"-Inline-Form pro Antragszeile (Status, Bescheiddatum, Aktenzeichen, bei genehmigt/abgelehnt zusätzlich zugeteilter Pflegegrad, bei genehmigt zusätzlich bewilligter Betrag) über den bereits bestehenden generischenPUT /api/antraege/{id}. - End-to-end gegen die echte lokale Docker-Postgres-Instanz verifiziert (nicht nur Unit-Ebene): Migration lief sauber durch, Antrag-Erstellung setzt
naechste_statuserinnerung_amkorrekt,_sende_antragsstatus_erinnerungen()/_sende_widerspruch_eskalation()einmal direkt aufgerufen (E-Mail-Versand schlug am echten SMTP-Ziel erwartungsgemäß fehl, wurde aber sauber abgefangen, ohne den Durchlauf abzubrechen), Token-Einlösung inkl. Session-Login und Einmalgebrauch (Zweitversuch mit selbem Token schlägt korrekt fehl) geprüft, Eskalations-Dedupe und die "schon Widerspruch eingelegt"-Heuristik beide bestätigt.next buildlief fehlerfrei durch (/widerspruchweiterhin statisch,/antraege/[id]/keine-sorgekorrekt dynamisch), alle neuen Routen lieferten HTTP 200 in einem containerisierten Produktions-Build. Kein Klick-Test im echten Browser (keine Anzeige/Playwright in dieser Umgebung verfügbar) — die UI-Logik selbst (bedingtes Rendering, Formulare) ist damit ungeprüft auf visuelle Überraschungen, nur der Datenfluss ist backend-seitig vollständig bestätigt.
Admin-Dashboard: E-Mail-Log, Scheduler-Status, Speicherplatz-Übersicht (pflegepilot_v3 only, 2026-07-31)
Betreiber-Feedback: keine Übersicht über den E-Mail-Versand (wie viele Mails, welcher Typ, Erfolg/Fehler) — relevant für Skalierungsentscheidungen. Claude hat zwei verwandte Punkte ergänzend vorgeschlagen (Scheduler-Status, Speicherplatz), da beide dieselbe "wie skaliere ich das"-Fragestellung betreffen; der Betreiber hat alles zusammen freigegeben ("bau mir das alles ein"), plus eine vierte, separat gewünschte Ergänzung (Nutzerliste um E-Mail/Rechnungsadresse).
email_log-Tabelle (Migrationb2d5e8f0c3a7, ModellEmailLogEintrag):send_email()(app/email_util.py) ist die einzige Stelle mit SMTP-Zugriff im gesamten Backend (7 Call-Sites: Passwort-Reset, Tagebuch-/Aufgaben-Reminder, Antragsstatus-Erinnerung + Widerspruchs-Eskalation aus T25, Katalog-Änderung, Umkreis-Favoriten-Änderung) — die Funktion bekam dafür eine neue Pflicht-Parameter-Reihenfolge (db: Sessionals erstes Argument,typ: strkeyword-only) und protokolliert jeden Aufruf mit Statusgesendet/fehlgeschlagen/uebersprungen(Letzteres: keinSMTP_HOSTkonfiguriert). Die Log-Zeile wird nur perdb.add()vorgemerkt, nicht selbst committet — das bleibt Aufgabe des jeweiligen Aufrufers, kein zusätzlicher Roundtrip gegenüber vorher.GET /api/admin/email-log?tage=30liefert die letzten 200 Einzel-Einträge plus serverseitig aggregierte Zählung nach Typ/Status.- Scheduler-Status ohne neue Tabelle: alle 4 Hintergrund-Scheduler (
reminder_scheduler.py,backup_scheduler.py,umkreis_favoriten_watch.pyje Schleifendurchlauf;katalog_watch.pyeinmalig beim Start) schreiben ihren Zeitstempel über die neue Hilfsfunktioneinstellungen.py: stempel_scheduler_lauf(name)in die bereits bestehende generischeeinstellungen-Key-Value-Tabelle (scheduler_letzter_lauf_<name>) — passt zum etablierten Muster (wird schon fürdemo_reset_at/rechnungsnummer_countergenutzt), keine neue Migration dafür nötig.GET /api/admin/scheduler-statusberechnet serverseitigstatus(ok/verzoegert/nie_gelaufen) je Scheduler;katalog_watch.pyist alseinmalig=Truemarkiert, da es nur einmal pro App-Start läuft und deshalb nie sinnvoll als "verzögert" gegen ein Wiederholungsintervall geprüft werden kann (jeder vorhandene Zeitstempel bedeutet dort "beim letzten Start erfolgreich gelaufen"). - Speicherplatz-Übersicht (
GET /api/admin/speicherplatz, keine Persistenz, live berechnet): Uploads-Größe rekursiv peros.walkübersettings.uploads_dir, aufgeschlüsselt nach Unterordner (briefe/dokumente/fotos); Backup-Größe aus dem bereits bestehendenbackup.py: liste_backups()summiert (keine doppelte Logik); Datenbankgröße überSELECT pg_database_size(current_database()). - Nutzerliste um E-Mail + Rechnungsadresse (
admin/page.tsx, rein Frontend): Backend lieferteemail/rg_name/rg_strasse/rg_plz/rg_ortinGET /api/nutzerbereits vollständig mit — es fehlten nur die Tabellenspalten. Bewusst nur sichtbar gemacht, nicht editierbar (eineaktualisieren-Mutation für ein Bearbeiten-Modal existierte zwar schon ungenutzt inlib/admin.ts, war aber nicht Teil dieser Anfrage). - End-to-end gegen die echte lokale Docker-Postgres-Instanz verifiziert: Migration lief sauber durch, alle 3 neuen Endpunkte per curl geprüft, ein echter
send_email()-Fehlschlag (SMTP-Ziel lehnte ab) erzeugte korrekt einenemail_log-Eintrag mit Typ/Status/Fehlermeldung, alle 4 Scheduler stempelten ihren ersten Lauf direkt nach Container-Start korrekt. Ein scheinbares Mojibake-Problem bei Umlauten (Äerschien in der Terminal-Ausgabe verstümmelt) stellte sich bei genauerer Prüfung der rohen Response-Bytes als reines Terminal-/Encoding-Anzeigeproblem dieser Windows-Entwicklungsumgebung heraus, nicht als echter Bug — die API liefert korrektes UTF-8.next buildlief fehlerfrei durch,/adminblieb weiterhin eine statische Route. Achtung bei künftigem Debugging dieser lokalen.env: sie enthält das echte, produktiv genutzte SMTP-Passwort im Klartext (dieselbe lokale.envwird für Realismus mit Produktionsdaten befüllt) — beim Prüfen von SMTP-Settings gezielt einzelne Keys grep'en, nicht die ganze Datei ausgeben. - Nachbesserung noch am selben Tag (Betreiber-Feedback: die Konten-Tabelle wirkte durch die zwei neuen Spalten (E-Mail, Rechnungsadresse) "zerfetzt"): die generische
ResponsiveTable/Column-Tabelle wurde für die Konten-Liste durch eine eigene Karten-Liste mit fester 3-Zeilen-Gliederung ersetzt (Zeile 1: Konto/Status/E-Mail, immer sichtbar; Zeile 2: Rechnungsadresse/Preis/Zugang/Registriert; Zeile 3: Patienten/Aktionen — beide nur bei aufgeklapptem Konto). Jedes Konto ist einzeln auf-/zuklappbar (offeneKonten-Set, unabhängig voneinander, nicht wie das alte.rtable-Akkordeon-Muster auspflegepilot_alpha_v2mit genau einem offenen Element), Default ist eingeklappt. Dazu eine Klartext-Suche (kontoSuche, filtert clientseitig über Benutzername/Name/E-Mail/Rechnungsname) — kein Backend-Change nötig, daGET /api/nutzerohnehin alle Konten auf einmal liefert.ResponsiveTableselbst bleibt unverändert bestehen, wird an anderer Stelle (aufgaben/page.tsx,patienten/page.tsx) weiterhin genutzt.
Admin-Bereich: neue Statistik-Seite (pflegepilot_v3 only, 2026-08-01)
Betreiber-Wunsch nach einer eigenen Statistik-Seite im Admin-Bereich, im Anschluss an das E-Mail-Log-Feature. Umfang über zwei AskUserQuestion-Runden geklärt: 7 Kategorien wurden gebaut (Konten-Wachstum, Patienten & Pflegegrad-Verteilung, Anträge & Erfolgsquote inkl. Widerspruch-Rate, Umsatz aktuell, Aktivierungsquote, Time-to-Value, Retention-Näherung); API-Response-Times und Fehlerquoten wurden bewusst nicht gebaut (keinerlei Request-Timing-/Error-Logging existiert bisher irgendwo in der App — das wäre ein komplett neues Instrumentierungs-Feature vom Umfang des E-Mail-Logs, für eine Solo-Betreiber-App nicht im Verhältnis).
- Neues Modul
backend/app/statistiken.py(berechne_statistiken(db)), neuer EndpunktGET /api/admin/statistiken(routers/admin.py) — ein einziger aggregierter Response, gleiches Muster wie das E-Mail-Log. Keine Migration nötig, rein lesende Aggregation gegen vorhandene Tabellen. - Konsistente Filterregel: alle wachstums-/geschäftsbezogenen Kennzahlen filtern auf
rolle='nutzer' AND ist_demo=False("echte Kunden") — nur die Konten-Übersicht zeigt zusätzlich Admin-/Demo-Konten als eigene Segmente. Verifiziert: der vonapp.seedangelegte Demo-Patient taucht korrekt in keiner einzigen wachstumsbezogenen Kennzahl auf. - Wiederverwendete Bausteine statt neu erfunden:
einstellungen.py: berechne_abo_preis()für den Umsatz,static_data/antrag_fristen.py: ANTRAG_ERINNERUNGS_TAGEfür die "trackbaren" Antragstypen (identisch zu T25), die Widerspruch-Korrelations-Heuristik ausreminder_scheduler.py: _sende_widerspruch_eskalation()(gleicher Patient,typ='widerspruch',erstellt_amnach dem Ausgangsantrag) für die Widerspruch-Rate. - Umsatz-Bucket-Logik:
abo_typ == 'kostenlos_admin'→ Bucket "kostenlos" (nicht in der MRR-Summe), alles andere mit aktivemabo_bis→ Bucket "zahlend" — ehrliche Zahl unabhängig davon, ob echte Zahlungen über PayPal schon fließen oder aktuell nur Freischaltungen existieren. - Kein Chart-Package im Frontend (
package.jsonhatte bis dahin nur@tabler/icons-react+@tanstack/react-query) — stattdessen eine schlanke, abhängigkeitsfreie Inline-SVG-Komponentecomponents/BarChart.tsx, gebaut nach derdataviz-Skill-Methode (Mark-Specs: Balken ≤24px, 4px abgerundete Kappe/quadratische Grundlinie, 2px Lücke, Werte direkt an der Balkenspitze statt einer Hover-Tooltip-Statemachine — bei ≤12 Balken pro Diagramm laut Skill-Referenz sanktioniert). Farbzuordnung nutzt bewusst die bereits etablierten Tailwind-Hex-Werte dieser App statt der Skill-Referenzpalette (die Methode ist laut Skill design-system-agnostisch — "substitute this file's values"):lib/chartColors.tsmitsequential(ein Blauton, reine Größenvergleiche),ordinalPflegegrad()(ein Indigo-Ton, Helligkeit steigt mit dem Pflegegrad 0→5),statusFarbe()(exakt dieselbe emerald=gut/red=kritisch/amber=Warnung-Konvention, dieadmin/page.tsxschon für Status-Badges nutzt). Kein automatisierter Lauf vonvalidate_palette.jsgegen diese Tailwind-Hexwerte — bewusste, dokumentierte Vereinfachung für eine kleine interne Ein-Personen-Admin-Seite. - Neue Route
admin/statistiken/page.tsx, Nav-Eintrag in(app)/layout.tsxsADMIN_SECTIONS— der bestehende/admin*-Zugriffsschutz deckt sie automatisch mit ab, keine separate Schutzlogik nötig. - End-to-end mit handangelegten Testdaten verifiziert (3 Kundenkonten mit unterschiedlichen Registrierungsdaten/Login-Zeitpunkten/Abo-Zuständen, 1 Patient, 3 Anträge inkl. eines Widerspruchs) — alle 13 Kennzahlen des Response gegen die von Hand berechneten Erwartungswerte exakt geprüft (u. a. Monats-Buckets, MRR-Bucket-Trennung zahlend/kostenlos, Time-to-Value-Median, Retention-Quote je Kohorte), nicht nur oberflächlich auf HTTP 200 getestet.
next buildbestätigt/admin/statistikenals statische Route. - Nachbesserung direkt im Anschluss (Betreiber-Feedback: Balkendiagramme kollidierten mit den umgebenden Karten): Ursache war, dass ein SVG-Wurzelelement standardmäßig NICHT am
viewBox-Rand clippt (overflow:visible) — bei gekippten/langen Achsenbeschriftungen (z. B. "Eingliederungshilfe", "In Prüfung") war das reservierte Platzband incomponents/BarChart.tsxzu knapp geschätzt, der Text malte dadurch sichtbar über die eigene Box hinaus in Nachbarkarten hinein. Gefixt durch tatsächliche (nicht geschätzte) Geometrie-Berechnung des Platzbedarfs aus dem Rotationswinkel (Math.cos/Math.sinauf die Label-Pixelbreite), plus Kürzung langer Labels auf 7 Zeichen mit „…" (voller Text bleibt im<title>-Hover erhalten) und einem steileren Rotationswinkel (50° statt 35°, reduziert die horizontale Ausdehnung des gekippten Labels und damit die Kollisionsgefahr mit dem Nachbarbalken). Bewusst großzügig geschätzte Zeichenbreite (6.2px bei font-size 10) statt einer knappen Schätzung — lieber etwas mehr Abstand reservieren als eine erneute Kollision riskieren.
Hard-Delete für Nutzerkonten im Admin-Dashboard (pflegepilot_v3 only, 2026-08-01)
Betreiber-Feedback: der bereits existierende Hard-Delete-Endpunkt (DELETE /api/nutzer/{uid} in routers/admin.py, verweigert wie in pflegepilot_alpha_v2 mit 400, solange das Konto noch Patienten besitzt, blockt Selbstlöschung) hatte keine Frontend-Anbindung — nur der Soft-Delete-Button ("Löschen", archiviert Patienten statt sie zu löschen) war in der Kontenliste erreichbar.
- Beim Anfassen ein Konsistenz-Gap gefunden und mitbehoben: anders als jede andere destruktive Admin-Aktion in dieser App (Sperren/Entsperren/Soft-Delete/Abo-Freischalten – alle verlangen eine Begründung und werden über
log_admin_aktion()unveränderlich protokolliert) verlangte der Hard-Delete-Endpunkt bislang weder eine Begründung noch erzeugte er einen Audit-Log-Eintrag. Nachgezogen:payload: BegruendungRequestPflichtfeld,log_admin_aktion(db, admin, "konto_hart_geloescht", "nutzer", uid, benutzername, grund)vor demdb.delete()(Name/Begründung werden VOR dem Löschen erfasst — der Audit-Log-Eintrag selbst hat bewusst keinen FK aufnutzer.id, sieheaudit.py, und überlebt die Löschung). - Frontend: neuer Button "Hart löschen" in der Konten-Kartenliste (
admin/page.tsx), bewusst nur sichtbar, wennpatienten_anzahl === 0(vermeidet einen Klick, der serverseitig ohnehin abgelehnt würde). Nutzt dieselbeBegruendungModal-Komponente wie die anderen Aktionen, die dafür zwei neue optionale Props bekam:warnung(roter ⚠️-Warntext oberhalb des Formulars, hier: "endgültig aus der Datenbank – keine Archivierung, keine Wiederherstellung") undfehler(zeigt den Mutation-Fehler direkt im Modal — ein Gap, der beim Einbauen auffiel: keine der bestehenden Aktionen (Sperren/Entsperren/Soft-Delete/Abo) zeigte ihren Fehlerfall bisher sichtbar an; jetzt über eine gemeinsameaktionFehler()-Hilfsfunktion für alle Aktionstypen einheitlich gelöst, nicht nur für Hard-Delete). - End-to-end verifiziert (nicht nur HTTP 200 oberflächlich): Konto mit noch zugeordnetem Patienten → korrekt 400 mit der erwarteten Meldung; Konto ohne Patienten → 200, Konto tatsächlich aus der DB verschwunden, korrekter
admin_audit_log-Eintrag (aktion="konto_hart_geloescht"); Versuch, das eigene Konto zu löschen → korrekt 400 blockiert.
Leistungskatalog-Aktualisierung auf Stand 01/2026 (pflegepilot_v3 only, 2026-08-01)
Betreiber hat drei offizielle BMG-Quelldokumente bereitgestellt (BMG_Ratgeber_Demenz_bf.md, Stand 03/2026, plus pflege2026.json/Pflegebeduerftig.json/pflegeleistungen_2026_strukturierte_daten.json, alle Stand 01/2026, im Projekt-Wurzelverzeichnis abgelegt — nicht Teil des Codes, reine Abgleichs-Quelle) zum Abgleich von static_data/katalog.py (dort stand noch "Stand: 2025").
- Betroffen ausschließlich die Kategorie "Pflegekasse (SGB XI)" (22 Einträge, Zeilen 1–350) — die übrigen Kategorien (Sozialhilfe, Wohnen & Bauen, Landesleistungen) werden von den bereitgestellten BMG-Dokumenten nicht abgedeckt, blieben unverändert. Der Demenz-Ratgeber lieferte inhaltlich keine neuen/abweichenden Zahlen gegenüber den drei JSON-Dateien (gegengeprüft), aber ein neueres Datum (03/2026 vs. 01/2026) — beide Stände sind intern konsistent.
- Aktualisierte Beträge: Pflegegeld (§ 37), Pflegesachleistung (§ 36), Tages-/Nachtpflege (§ 41), vollstationäre Pflege (§ 43, jetzt auch Pflegegrad 1 mit 131 € — vorher fehlte Grad 1 im Katalog komplett,
pflegegrad_minvon 2 auf 1 korrigiert), Wohngruppen-Zuschlag (§ 38a, 214 €→224 €), Behinderteneinrichtungen-Ergänzungsleistung (§ 43a, 266 €→278 €). - Verhinderungspflege/Kurzzeitpflege neu formuliert: beide sind seit 07/2025 real in den bereits bestehenden
pk_jahresbetrag-Eintrag (gemeinsamer Jahresbetrag, 3.539 €) zusammengeführt — die beiden ursprünglichen Einzel-Einträge trugen aber noch ihre alten, eigenständigen Jahresbudgets (1.612 €/1.774 €) imbetrag-Feld. Jetzt korrekt als "Teil des gemeinsamen Jahresbetrags" mit den echten Unter-Grenzen (nahe Angehörige: 2-Faches des monatlichen Pflegegelds, Aufstockung bei Kostennachweis möglich) formuliert,pk_jahresbetragselbst von "neu ab 07.2025" auf "seit 07/2025" (jetzt geltendes Recht, nicht mehr Ankündigung) umformuliert. Tagneu2025entfernt (keine anderen Code-Referenzen darauf, geprüft). - Inhaltliche Präzisierungen ergänzt (nicht nur Zahlen, auch Nuancen aus den Quelldokumenten, die vorher fehlten): technische Pflegehilfsmittel haben tatsächlich einen 10 %-Eigenanteil (max. 25 € ab 18 Jahren), nicht "100 % Kostenübernahme" wie zuvor formuliert; DiPA sind eigentlich zwei getrennte Beträge (bis 40 €/Monat App + bis 30 €/Monat Unterstützungsleistung, nicht "bis 50 €" wie zuvor); Rentenversicherungsbeiträge und Arbeitslosenversicherungsbeiträge für Pflegende bekamen konkrete Beträge statt der bisherigen vagen "Abhängig von..."/"Beitragsübernahme"-Formulierung; die vierteljährliche Beratungsbesuch-Frequenz bei Pflegegrad 4–5 ist tatsächlich optional, nicht verpflichtend wie zuvor dargestellt.
- Bewusst nicht angefasst:
static_data/pflegegrad_regeln.py(die 5-Fragen-Schätzheuristik des Interview-Assistenten) — die Quelldokumente liefern keine Punkt-Schwellenwerte, nur die Gewichtung der 6 offiziellen Begutachtungsmodule in Prozent, und unsere Heuristik war ohnehin nie als exakte Nachbildung des amtlichen Begutachtungsinstruments gedacht (siehe Modul-Docstring: "bewusst grob"). pflegepilot_alpha_v2/pflegepilot/katalog.pyhat exakt dieselben veralteten 2025er-Werte (geprüft, gleiche Struktur) — auf ausdrücklichen Betreiber-Wunsch NICHT mit-aktualisiert, nurpflegepilot_v3betroffen.- Wichtige Nebenwirkung beim Deploy, vorab verifiziert:
katalog_watch.py(T14, siehe oben "Proaktiver Server-Mailversand erweitert") vergleichtKATALOGbeim App-Start gegen diekatalog_snapshots-Tabelle und verschickt automatisch eine E-Mail an jeden Nutzer (und ggf. dessen Patienten), der eine der geänderten Leistungen einem Patienten zugewiesen hat. Das wurde lokal gezielt nachgestellt (Snapshot-Zeilen auf die alten Werte zurückgesetzt,_sync_und_benachrichtigen()erneut angestoßen) — die Änderungserkennung griff korrekt für beide getesteten Einträge (pk_pflegegeld,pk_stationaer), inkl. korrektememail_log-Eintrag (typ="katalog_aenderung"). Das bedeutet: nach dem Deploy auf die NAS gehen beim ersten Start echte Benachrichtigungsmails an betroffene Bestandskunden raus — kein Bug, sondern genau der beabsichtigte Zweck dieses Features, aber ein bewusster Zeitpunkt für den Deploy sollte gewählt werden.
E-Mail-Bündelung + HTML-Design für alle proaktiven Mails (pflegepilot_v3 only, 2026-08-01)
Direktes Betreiber-Feedback nach dem Katalog-Update-Deploy: 9 Einzelmails für eine Änderungsrunde waren eine "Flut", und die Mails waren reiner Text ohne Wiedererkennungswert. Auf Rückfrage hat sich der Betreiber für die aufwendigere Variante entschieden: HTML-Design für alle 7 proaktiven Mailtypen, nicht nur die Katalog-Mail.
- Ursache der Flut gefunden und behoben:
katalog_watch.py: _sync_und_benachrichtigen()rief für jeden geändertenKATALOG-Eintrag sofort_benachrichtige_betroffene()auf, die unabhängig ihre eigene Empfängerliste aufbaute und verschickte — bei N geänderten, beim selben Nutzer hinterlegten Leistungen also N separate Mails an dieselbe Person. Umgebaut: erst werden alle Änderungen eines Durchlaufs gesammelt, danach genau eine Mail je Empfänger mit eineraenderungs_box()pro geänderter Leistung gebaut (neue Funktionen_benachrichtige_betroffene_gesammelt()/_sende_gesammelte_aenderungsmail()). Betreff passt sich an („{Name}“bei genau einer Änderung,"N Änderungen im Leistungskatalog"bei mehreren). Lokal verifiziert: 3 gleichzeitig geänderte, bei einem Testkonto hinterlegte Leistungen erzeugten nachweislich nur einen SMTP-Versandversuch und einenemail_log-Eintrag (vorher wären es 3 gewesen). - Neues Modul
app/email_templates.py: gemeinsame HTML-Hülle (render_email()) + Bausteine (absatz(),liste(),aenderungs_box()für Vorher/Nachher-Vergleiche,magic_link_reihe()für mehrere Button-Links nebeneinander). Tabellenbasiertes Layout mit durchgängig Inline-Styles (kein<style>-Block als Basis) für Kompatibilität mit Outlook Desktop, das externes/<style>-CSS größtenteils ignoriert. Farben an das bestehende App-Design angelehnt (#1e293b= dasselbe Slate-800 wiebuttonStyles.primaryim Frontend). Kein Bild-Logo (keins im Projekt vorhanden) — bewusst eine Text-Wortmarke "MeinPflegeFuchs", robuster gegen Bild-Blockierung in Mail-Clients. Kein Dark-Mode-Handling (E-Mail-Client-Unterstützung dafür ist unzuverlässig) — durchgehend helles Design mit hohem Kontrast. Sicherheitsregel: jeder Baustein escaped intern perhtml.escape()(esc()-Hilfsfunktion) — Pflicht, da u. a. Nutzernamen, Aufgaben-Titel und Patientennamen echte Nutzereingaben sind, die sonst HTML in die Mail injizieren könnten. email_util.py: send_email()bekam einen neuen optionalenhtml-Parameter — gesetzt, wirdMIMEMultipart("alternative")mit Text- und HTML-Teil verschickt (Text bleibt Pflicht-Fallback für Text-only-Clients/Spamfilter), sonst wie bisher reinesMIMEText.- Alle 7 Call-Sites umgestellt (
routers/auth.py,reminder_scheduler.py×4,katalog_watch.py,umkreis_favoriten_watch.py) — jeweils der bestehende Plain-Text-Body unverändert als Fallback, dazu ein HTML-Block aus denemail_templates-Bausteinen. Die Antragsstatus-Erinnerung (3 Magic-Links) nutztmagic_link_reihe()für drei nebeneinanderliegende Buttons statt eines einzelnen CTA. - Visuelle Abnahme über Artifact-Vorschauen statt Screenshot (kein Browser in dieser Umgebung verfügbar): drei repräsentative HTML-Bodies (Katalog-Änderung mit mehreren
aenderungs_box(), Antragsstatus mit 3 Buttons, Passwort-Reset als einfachster Fall) wurden direkt im laufenden Container gerendert und als private Artifacts veröffentlicht, damit der Betreiber sie tatsächlich sehen und freigeben kann, bevor deployt wird — Claude selbst kann HTML-Mails in dieser Umgebung nicht visuell prüfen, nur strukturell. - Bewusst nicht gebaut: Bulletproof-Button-VML-Hack für Outlook Desktop (Buttons bleiben dort optisch etwas schlichter, aber funktional), echtes Dark-Mode-Handling.
- Nachbesserung nach Sichtung der Vorschauen: Betreiber-Feedback "es fehlt die korrekte Anrede aus dem Vornamen, die Mail ist etwas plump, hier fehlt das freundliche Auftreten von uns". Zwei Probleme behoben: (1)
Nutzerhat anders alsPatientkein eigenesvorname-Feld, nur ein freiesname-Textfeld — neue Hilfsfunktionemail_templates.vorname_aus(name, fallback)extrahiert das erste Wort daraus (Fallback:benutzername), einheitlich an allen 7 Stellen verwendet; die Katalog-Änderungsmail und die Umkreis-Favoriten-Mail hatten zuvor gar keine Anrede ("Hallo," ohne Namen). (2) Die Begrüßung war als Satzanfang in den ersten Fließtext-Absatz eingequetscht ("Hallo Max, kurzer Check-in für heute: ...") — jetzt eine eigene Begrüßungszeile (email_templates.gruss(), "Hallo {Vorname}! 👋") vor dem eigentlichen Inhalt, plus wärmer formulierte Texte (z. B. "Nicht vergessen: die Aufgabe ... ist morgen fällig" statt "folgende Aufgabe ist morgen fällig"). Bei der Katalog-Änderungsmail ist der Empfänger nicht immer der Nutzer selbst (kann auch der Patient mit eigener hinterlegter E-Mail sein) — die Anrede wird deshalb korrekt je Empfänger-Adresse zugeordnet (empfaenger_vorname-Dict inkatalog_watch.py), nicht pauschal der Nutzername verwendet.