Changelog — BTV Portal
Alle Änderungen mit Bereichs-Präfix (core:, später <modul>:). SemVer;
app_version() liest die erste ## [x.y.z]-Überschrift.
[0.108.0] — 2026-07-31
Durchsicht des in dieser Runde Gebauten: Was mehr als ein Modul brauchen wird,
gehört in den Kern — und was doppelt dieselbe Datenbank fragt, gehört
zusammengefasst.
- core:
filter_klappfeld()undsuch_teile(). Das Markup der Klappfilter
stand schon zweimal im Anliegen-Modul (Board und Priorisierung); ein
drittes Modul hätte es ein drittes Mal abgeschrieben. Beides liegt jetzt in
app/helpers.php, beide digi-Ansichten benutzen es. Die Auswertung der Suche
bleibt beim Modul — nur es kennt seine Spalten.
- core:
content_registry()wird je Request einmal gebaut.content_get()
ruft sie über content_default() bei jedem Text auf; ohne
Zwischenspeicher lief das auf einer Seite mit zwanzig Texten zwanzigmal durch
alle Module.
- core:
portal_ansicht_rollen()gemessen von 3 ms auf 0,15 ms je Aufruf.
Zwei Ursachen: repo_roles_all() zählt je Rolle die Konten mit — eine
Unterabfrage, die hier niemand braucht —, und die Kopfzeile fragt zweimal,
apply_view_as() ein drittes Mal. Jetzt schlanke Abfrage plus
Zwischenspeicher.
- digi: Doppelte Abfragen in den Sprint-Ansichten entfernt. Die
Aufwandssumme holte den Sprintinhalt ein zweites Mal aus der Datenbank,
obwohl die Ansicht ihn längst geladen hatte — eine Abfrage je Spalte ohne
Erkenntnisgewinn. Neu digi_aufwand_summe($liste); digi_sprint_fortschritt()
nimmt den Inhalt optional entgegen.
- Konzept 18.9–18.13 (v1.21): Die gemeinsamen Bausteine sind jetzt
verbindlich dokumentiert — Arbeitslisten (Filter, Suche, Spaltenwahl,
gemerkte Ansichtszustände samt der data-*-Tabelle), Texte und Mailvorlagen
über <modul>_content_registry(), Einstellungen mit Unterreitern **samt der
Falle, dass Teilformulare die übrigen Bereiche leeren**, und die Übernahme aus
einem Altsystem („schon da" heißt nicht „vollständig"). Die Fertig-Checkliste
führt die Punkte auf.
- Drei Testfälle sichern die Bausteine modulübergreifend ab, darunter die
Prüfung, dass ein Modul die Suche nicht abweichend nachbaut und dass jeder
Modul-Text einen Präfix und eine Gruppe hat.
[0.107.0] — 2026-07-31
- core: Die Ansichts-Umschaltung kennt jetzt alle Rollen, nicht nur fünf.
Die Auswahl war fest verdrahtet (Referent, Ehrenamt, BTV Hauptberuf,
GS-Admin, Admin) und stammte aus der Zeit vor den Fachmodulen. Wer prüfen
wollte, was jemand mit „Anliegen: Bearbeitung" oder „Newsletter-Sync —
Ausführen" sieht, konnte das nicht. Die Liste kommt jetzt aus der
Rollentabelle; später angelegte Rollen sind automatisch dabei.
Die Rolle Admin konnte schon immer umschalten (is_real_admin() prüft
admin *oder* super_admin) — was fehlte, war die Auswahl.
- core: Zwei Ausschlüsse sichern das ab. Die Super-Admin-Sicht wird nie
angeboten, und angeboten wird überhaupt nur, was die fragende Person
rechtlich ohnehin darf. Das ist kein Beiwerk: Die Umschaltung setzt die
effektiven Rollen auf die gewählte, und admin wie super_admin tragen
bewusst keine Modulrechte. Ohne diese Regel verschaffte „als Anliegen:
Verwaltung" genau diese Rechte — samt Schreibzugriff auf fremde Fachdaten,
per Auswahlfeld und ohne Spur in der Rollenvergabe. Wer eine Modulansicht
prüfen will, gibt sich die Modulrolle: sichtbar, protokolliert und wieder
entziehbar.
Sechs Testfälle halten das fest, darunter die Gegenprobe, dass jede
angebotene Rolle eine Teilmenge der eigenen Rechte ist.
[0.106.2] — 2026-07-31
- digi: Die Reihenfolge im linken Menü folgt jetzt dem Weg eines Anliegens —
einreichen → sichten (Boards) → bewerten (Priorisierung) → einplanen
(Sprintplanung) → abarbeiten (Sprintboard) → verwalten.
Vorher stand das Sprintboard vor Priorisierung und Sprintplanung, also der
letzte Schritt vor den beiden, die ihn vorbereiten. Ein Menü, das den Ablauf
abbildet, muss man nicht erklären: Man liest es einmal von oben nach unten und
weiß, wie das Modul gedacht ist. Ein Testfall hält die Reihenfolge fest.
[0.106.1] — 2026-07-31
- digi: Sprint-Zuordnungen und Schätzungen kamen nur teilweise an. Die
Übernahme sprang bei einer bereits vorhandenen Planungszeile ganz heraus
(„schon da"). Dadurch blieben genau die Zeilen leer, die ein früherer Lauf
angelegt hatte, bevor die Sprints da waren: 10 von 11 Sprint-Zuordnungen
und der Großteil von Aufwand, Priorität und Zieldatum fehlten.
Dasselbe Muster wie zuvor bei den Anhängen — „schon da" heißt nicht
„vollständig". Der Abgleich füllt jetzt Leerstellen auf: Was im Portal
einen Wert hat, bleibt unangetastet (dort hat jemand bewusst etwas
eingetragen); was leer ist, wird nachgetragen — auch an alten Zeilen.
Auf btv-portal.de nachgezogen: 12 Zeilen ergänzt. Sprint 9 hat jetzt 3,
Sprint 10 fünf und Sprint 11 drei Anliegen — deckungsgleich mit dem
Altsystem. Eine Gegenprüfung über alle 70 Altzeilen meldet nichts Offenes
mehr bei Sprint, Aufwand, Priorität und Zieldatum.
[0.106.0] — 2026-07-31
- core: Der Inhalts-Editor hat Unterreiter. Bisher standen alle pflegbaren
Texte in einer einzigen, sehr langen Liste — Rechtstexte neben Mail-Signatur.
Jetzt gruppiert (Startseite, Registrierung, Hilfe, Rechtstexte, Konto &
Auskunft, Mails) und über die Adresse verlinkbar (?bereich=…), ohne
JavaScript. Nach dem Speichern landet man wieder im selben Reiter.
- core: Module dürfen eigene Texte beisteuern (Hook
<modul>_content_registry()). Sie erscheinen als eigene Gruppe im Editor und
können keine Kern-Schlüssel überschreiben — ein Modul soll die Rechtstexte
des Portals nicht umschreiben können.
- digi: Die Mailtexte des Moduls sind pflegbar — Eingangsbestätigung,
Statusänderung, neue Nachricht sowie die beiden internen Meldungen, je Betreff
und Text. Bewusst ein Satz mit Platzhaltern ({titel}, {nr}, {status},
{objekt}, {team}, {link} …) statt je Board eigener Fassungen: Das
Altportal hatte 24 Vorlagen, drei Kopien desselben Satzes laufen unweigerlich
auseinander — und was sie unterschied (Objektname, Team), leistet genau ein
Platzhalter. Unbekannte Platzhalter bleiben stehen, damit ein Tippfehler in
der Vorlage auffällt statt still eine Lücke zu hinterlassen.
- digi: Priorisierung — Spaltenköpfe standen versetzt. Priorität, Aufwand
und Ziel lagen in einer Zelle mit colspan="3", die Kopfzeile hatte dort
drei Spalten. Jetzt sitzt jedes Feld in seiner eigenen Spalte; das Formular
steht einmal je Zeile und die Felder verweisen über das form-Attribut
darauf.
- digi: Priorisierung filtert nach Board, Priorität (inklusive „noch nicht
priorisiert") und Volltext. **Das Verschieben funktioniert bei aktivem
Filter**: Getauscht wird mit dem vorherigen bzw. nächsten *sichtbaren*
Eintrag, die Reihenfolge aller übrigen bleibt unangetastet — ein blosses „eine
Position hoch" hätte die ausgeblendeten Einträge übersprungen und ihre
Reihenfolge stillschweigend verwürfelt. Nach dem Speichern kehrt die Ansicht
in dieselbe gefilterte Liste zurück.
- digi: Klappfelder liefen am rechten Rand aus dem Bild. Die Spaltenauswahl
klappt jetzt fest nach links auf; bei den übrigen entscheidet das beim Öffnen,
weil ihre Position von Fensterbreite und Zeilenumbruch abhängt — das lässt
sich in CSS allein nicht bestimmen.
[0.105.0] — 2026-07-31
- digi: Priorisierung, Sprintplanung und Sprintboard. Die drei Funktionen
aus dem Altportal sind zurück — samt Datenmodell (digi_planung,
digi_sprints, Migration 0034) und Übernahme der Altdaten aus
planning_items/sprints über die Altnummern.
- Priorisierung (
/digi/planung): Backlog in Arbeitsreihenfolge,
Priorität, Aufwand (XS–XL mit fibonacci-nahen Punkten) und Zieldatum je
Vorgang.
- Sprintplanung (
/digi/sprints): Sprints anlegen, Anliegen zuordnen,
Aufwandssumme je Sprint. Eine Kapazitätsgrenze gibt es bewusst nicht — sie
wäre eine Zahl, die niemand belastbar festlegen kann.
- Sprintboard (
/digi/sprintboard): der aktive Sprint nach
Arbeitsstufen (Geplant → In Umsetzung → In Test → In Review →
Erledigt), mit Fortschritt und Aufwand im Kopf.
Verschoben wird überall über Knöpfe und Auswahlfelder, nicht per Ziehen:
bedienbar mit der Tastatur (WCAG 2.1.1), benutzbar auf Touchgeräten und ohne
JavaScript — dieselbe Linie wie bei den Spalten des Boards.
- digi: Die Stufe im Sprint ist ein eigenes Feld, nicht der Status. Der
Status sagt der einreichenden Person, wo ihr Anliegen steht („In
Spezifikation"); die Stufe sagt dem Team, was zu tun ist („In Test"). Beides
in ein Feld zu pressen zwingt eine der beiden Seiten in die Sprache der
anderen. Ebenso ist „blockiert" ein Feld samt Grund statt eines Vermerks
im Titel.
- digi: Modul-Einstellungen haben Unterreiter — Boards & Status,
Auswahllisten, Planung, Board-Freigaben. Sie laufen über die Adresse
(?unter=…), sind also verlinkbar und funktionieren ohne JavaScript.
Wichtig dabei: Jedes Teilformular schickt seine Bereichs-Kennung mit.
Ohne die löschte ein Speichern im Reiter „Auswahllisten" die Status-Mails
aller Boards — deren Felder fehlten im Formular und sähen aus wie „bewusst
geleert". Ein Testfall hält das fest.
- digi: Fehlende Einstellungen aus dem Altportal nachgezogen —
Priorisierungs-Stufen, „Anfrage aus zuständigem Bereich" und
„Bereichsleitung informiert" (deren Funktionen gab es hier schon, nur ohne
Oberfläche: Die Werte ließen sich nicht mehr ändern, obwohl sie in jedem
Formular auftauchen), dazu neu die Boards mit Planung, die Status für die
Planung und die Stufen des Sprintboards.
- digi: Berechtigungen aus der alten Konfigurationsdatei. Das Altportal
kannte zwei Quellen: die pflegbaren Listen und feste Einträge in seiner
config.php (roles.admin, roles.pm, board_access[…]). Wer nur die
Listen übernimmt, verliert ausgerechnet die Personen, die das Altportal von
Anfang an verwaltet haben — sie standen nie in einer Liste, weil sie sie nicht
brauchten. Die Anker kommen jetzt aus digi.legacy.anker in der config.php
dieses Portals; drei Testfälle sichern die Zusammenführung.
[0.104.0] — 2026-07-31
- digi: Dieselbe Person stand in den Auswahllisten zweimal. Im Altportal
wurde das Feld „Einreichende Person" mal mit dem Namen, mal mit der
E-Mail-Adresse gefüllt. Folge: „Philipp Deininger" und
„philipp.deininger@…" nebeneinander in jedem Filter — und ein Filter auf den
Namen übersah die übrigen Vorgänge derselben Person.
Korrigiert wird jetzt der Wert selbst, nicht nur die Anzeige: Der Filter
vergleicht genau dieses Feld, eine Anzeigekosmetik hätte das eigentliche
Problem stehen lassen. Der Name kommt aus dem Portalkonto zur Adresse, sonst
aus einem anderen Vorgang derselben Adresse, der schon einen Namen trägt.
Ist keine der beiden Quellen ergiebig, bleibt die Adresse stehen — lieber
eine Adresse in der Liste als ein geratener Name an einem Vorgang. Sobald die
Person ein Portalkonto hat, räumt der nächste Abgleich den Rest von selbst auf.
updated_at bleibt dabei unangetastet, sonst hielte der nächste Abgleich
jeden berührten Vorgang für „im Portal bearbeitet" und meldete ihn als
Konflikt. Der Schritt läuft bei jedem Abgleich mit (der Probelauf zeigt ihn
nur an) und ist wiederholbar.
Auf btv-portal.de angewendet: 6 von 12 Adressen aufgelöst, 30 Zeilen
korrigiert; 6 Adressen ohne Portalkonto bleiben vorerst stehen.
[0.103.0] — 2026-07-31
- digi: Kurzformen in der Board-Spalte. „Digi", „IT", „SyBi" statt der
vollen Namen; der volle Name steht als Kurzinfo an der Zelle. Die Spalte
schrumpft dadurch von 7,5 auf 4,2 rem.
- digi: Die Spaltenreihenfolge lässt sich ändern. Im Menü „Spalten" stehen
jetzt alle Spalten mit Pfeilen zum Verschieben — oben in der Liste = links in
der Tabelle; dazu „Standard wiederherstellen". Die Reihenfolge liegt wie die
Sichtbarkeit im Browser, je Person und Gerät.
Bewusst Knöpfe statt Ziehen: Das ist mit der Tastatur bedienbar (WCAG 2.1.1)
und kollidiert auf Touchgeräten nicht mit dem Scrollen — dieselbe Begründung
wie beim Anordnen der Profilblöcke.
Dabei behoben: Der Tabellen-Sortierer merkte sich den **Spaltenindex beim
Start**. Nach einem Verschieben hätte er die falsche Spalte sortiert; er
bestimmt ihn jetzt bei jedem Klick neu. Eine gemerkte Reihenfolge verträgt
außerdem entfernte und neu hinzugekommene Spalten, statt die Tabelle
unbrauchbar zu machen, bis jemand den Browserspeicher leert.
- digi: Gesetzte Filter bleiben beim Boardwechsel erhalten. Suche, Status,
Dringlichkeit, Fachbereich, Personen und die Archivansicht reisen über die
Reiter mit. Nicht mit reist die Kategorie: Sie steht je Board in einer
anderen Spalte und ist mit ganz anderen Werten belegt — mitgenommen ergäbe sie
dort verlässlich null Treffer, und ein Filter, der beim Wechsel still alles
wegräumt, sieht wie ein Fehler aus.
Die Einträge der linken Navigation bleiben absichtlich filterfrei: Sie sind
Navigation, keine Umschaltung innerhalb derselben Ansicht.
- **core: Der Schlüssel der gemerkten Spaltenwahl enthält jetzt die Kennung der
Person** — wie schon beim Filterbereich, damit an einem gemeinsam genutzten
Rechner nicht der eine die Einstellung des anderen vorfindet. Bestehende
Spaltenwahlen beginnen dadurch einmalig von vorn.
[0.102.0] — 2026-07-31
- digi: Schlankere Tabelle. In der Gesamtsicht „Alle" starten
Fachbereich und Kategorie / Art ausgeblendet — beide sind je Board
anders belegt und stiften dort mehr Verwirrung als Nutzen. In einem einzelnen
Board bleiben sie an. Über „Spalten" jederzeit einblendbar, und die Wahl wird
gemerkt: Der gespeicherte Zustand kann eine Spalte jetzt auch einschalten,
nicht nur ausschalten — sonst wäre eine ab Werk versteckte Spalte nach dem
Neuladen wieder weg gewesen.
- digi: Die Spalte mit dem Knopf „öffnen" ist entfallen. Der Titel ist
bereits ein Link auf denselben Vorgang; zwei Wege nebeneinander kosteten nur
Breite. Wichtig dabei: Die Regel „letzte Spalte nur so breit wie ihr Knopf"
musste mit weg, sonst hätte sie nach dem Entfernen die Spalte
„Aktualisiert" auf 1 % gequetscht.
- digi: „Liste eingrenzen" über der Tabelle entfernt. Seit die
Volltextsuche mehrere Wörter, Phrasen, Ausschlüsse und Nummern versteht und
Vorschläge liefert, standen dort zwei Suchfelder mit unterschiedlicher
Reichweite nebeneinander — erklärungsbedürftig, ohne dass das zweite noch
etwas beigetragen hätte.
[0.101.1] — 2026-07-31
- digi: Spaltenbreiten im Board. Die Nummernspalte war so breit wie eine
Textspalte, dem Titel fehlte der Platz. Ursache war die allgemeine Regel
.check-table td,th{min-width:5rem}: Sie gibt jeder Zelle dieselbe
Untergrenze — auch der, in der eine drei- bis vierstellige Zahl steht. Der
Titel, die einzige Spalte, die wirklich Platz braucht, bekam den Rest.
Jetzt haben die schmalen Spalten feste Anhaltswerte (Nr. 3,2 rem,
Dringlichkeit 5,5 rem, Prozessschritt 10,5 rem, Board 7,5 rem, Aktualisiert
8,5 rem), der Titel bekommt den größten Anteil und fällt nie unter 13 rem.
Die übrigen Textspalten haben bewusst keine Breite, nur eine Untergrenze:
Feste Werte und Prozente zusammen überbestimmen die Tabelle, und der Browser
löst das dann mit Seitwärts-Scroll auf, statt sinnvoll zu verteilen. So liegt
der Mindestbedarf bei rund 1120 px in der Gesamtsicht und 1000 px in einem
einzelnen Board — darunter greift wie bisher der Scroll-Rahmen, und Spalten
lassen sich weiterhin ausblenden.
Gilt nur in der Tabellenansicht; sobald gestapelt wird, sind Breiten
gegenstandslos.
[0.101.0] — 2026-07-31
- digi: Das Suchfeld sitzt in derselben Zeile wie die Filter. Beschriftung
und Syntax-Hinweis standen darüber und darunter und machten das Feld höher als
die Knöpfe daneben — dadurch brach die Zeile um. Die Beschriftung steht jetzt
nur noch vorlesbar im <label> (sichtbar sagt es der Platzhalter), die
Erklärung im Hilfeblock: „Mehrere Wörter grenzen ein. `"in
Anführungszeichen" sucht die Wortfolge, -wort` schließt aus. Die Nummer
eines Anliegens findet es direkt." Damit lässt sie sich über die Inline-Hilfe
abschalten — ein Hinweis direkt am Feld (feld_hilfe()) ist laut Konvention
immer sichtbar und wäre dafür das falsche Mittel gewesen.
- digi: Ein Klick auf das Modul führt dorthin, wo man arbeitet. Mit dem
Recht digi.bearbeiten geht /digi direkt ins Board (bei genau einem
freigegebenen Board gleich in dieses), sonst wie bisher zur Auswahl der
Anliegen-Arten.
Die Einreichen-Auswahl hat dafür eine eigene Adresse bekommen
(/digi/einreichen), auf die Navigation und Dashboard-Kachel zeigen — sonst
käme ein Konto mit beiden Rechten gar nicht mehr zum Formular, weil /digi
es ins Board schickt. Zwei Tests halten die Verdrahtung fest.
Nebeneffekt: Die Krume des Formulars nennt jetzt die Zwischenstufe
(„Anliegen › Einreichen › Digitalisierungs-Anliegen einreichen").
[0.100.0] — 2026-07-31
- digi: Einträge in den Klappfiltern überlagerten sich. Zweizeilige Werte
(„Fachbereichliche Begutachtung") liefen über den nächsten Eintrag. Ursache:
Die Optionsliste ist eine Flex-Spalte mit max-height — darin schrumpfen die
Kinder unter ihre Inhaltshöhe, sobald die Liste länger ist als der Platz.
Mit flex:0 0 auto behalten sie ihre Höhe; Ankreuzfeld und Text stehen jetzt
oben bündig, die Zahl rechts in Ziffernbreite.
- digi: Lange Auswahllisten lassen sich eingrenzen. Ab acht Einträgen steht
über der Liste ein Suchfeld, das ab drei Zeichen filtert — darunter bliebe
ohnehin fast alles stehen und die Liste zappelte bei jedem Anschlag. Bereits
Angekreuztes bleibt sichtbar, auch wenn es nicht zur Eingabe passt, sonst
ginge eine getroffene Auswahl beim Weitertippen still verloren.
- digi: Die Volltextsuche versteht jetzt, was Leute eingeben. Bisher wurde
der ganze Eingabetext als ein Stück in den Feldern gesucht: „urlaub
antrag" fand „Urlaubsantrag digitalisieren" nicht, und nach der Nummer eines
Anliegens ließ sich gar nicht suchen — obwohl in Mails und Besprechungen genau
darüber geredet wird. Neu:
- Mehrere Wörter grenzen ein; jedes muss vorkommen, in beliebiger
Reihenfolge und beliebigem Feld.
"in Anführungszeichen"sucht die Wortfolge,-wortschließt aus.- Nummer und Kennung finden direkt (
legacy_id,public_id). - Rangfolge statt Datum: Treffer im Titel wiegen schwerer als im
Fließtext, ein Titelanfang schwerer als ein Treffer mitten im Text, die
Nummer schlägt alles. Ohne Sucheingabe bleibt es bei der bisherigen
Reihenfolge.
- digi: Vorschläge ab dem dritten Zeichen. Die Suche schlägt passende
Anliegen vor und springt auf Auswahl direkt hin, statt erst eine Trefferliste
zu bauen. Bedienbar mit der Tastatur (Pfeile, Enter, Esc) und über
aria-expanded/aria-activedescendant auch für die Vorlesefunktion
nachvollziehbar. Der Endpunkt liegt hinter demselben Recht **und derselben
Board-Freigabe** wie die Liste — ein Vorschlag darf nichts verraten, was die
Person im Board nicht sähe; die Antwort ist no-store.
Zugabe, kein Ersatz: Ohne JavaScript bleibt es ein gewöhnliches Suchfeld.
Nicht enthalten ist Tippfehler-Toleranz — dafür bräuchte es einen Index
(Trigramm o. ä.), das wäre ein eigener Schritt.
[0.99.0] — 2026-07-31
- digi: Der Status ist ein Klappfeld wie die übrigen Filter. Vorher stand er
als Leiste aus Link-Chips darüber — eine zweite Bedienart für dieselbe Sache,
die eine ganze Zeile belegte. Die Zahlen je Status gehen nicht verloren: Sie
stehen jetzt hinter den Werten in der Auswahl, auch die 0 („da liegt
nichts" ist eine Aussage; ein fehlender Wert sähe nach Fehler aus).
„Einschließen / ausschließen" kommt damit aus demselben Baukasten wie bei den
anderen Feldern.
- digi: Such- und Auswahlfelder des Filters sind gestaltet. Sie sahen aus
wie unformatierte Browser-Felder: Das Formular trägt bewusst nicht die Klasse
.form, weil deren Blocklayout (Label über Feld, volle Breite) die einzeilige
Filterreihe aufbräche — damit griffen aber auch deren Feldstile nicht. Jetzt
eigene Regeln für genau diese Felder, samt sichtbarem Fokusrahmen.
- core: Versehentliche Umdefinition von
.chipzurückgenommen. Mit der
Board-Statusleiste (v0.97.0) kam eine eigene .chip-Regel dazu — die Klasse
gibt es im Portal aber schon für Statusangaben in Profil und
Benutzerverwaltung, und die Regel überschrieb sie portalweit. Die
board-eigenen Angaben heißen jetzt nach ihrem Ort.
- core: Die Krume übersprang Zwischenebenen. Gleich zwei Ursachen:
Erstens kennt die Navigation nur „Modul → Kind", also genau eine Stufe —
seit die Boards einzeln in der Navigation stehen, fiel /digi/board
zwischen /digi und /digi/board/<zweig> heraus. Das Elternteil ist jetzt
die längste bekannte Route, die echter Präfix ist, und gilt damit für jede
Modultiefe.
Zweitens übergab das Modul „digi" seine Zwischenstufen als
['Beschriftung', '/pfad'] statt ['label' => …, 'href' => …]; die Krume las
nur die zweite Form und verwarf die erste stillschweigend. Vom einzelnen
Vorgang führte deshalb kein Weg zurück aufs Board. Beide Schreibweisen gelten
jetzt, und digi benutzt die dokumentierte.
Drittens prüft die Entdopplung nun gegen die ganze Kette statt nur gegen die
letzte Stufe — sonst stünde „Anliegen › Alle Boards › Anliegen › Boards" da.
Drei Testfälle halten das fest.
[0.98.0] — 2026-07-31
- digi: Kopfzeile des Boards zusammengezogen. Boardauswahl links, rechts auf
gleicher Höhe der Schalter „Filter & Ansicht". Alles, was die Ansicht
steuert — Aktiv/Archiv, Excel-Export, Statusleiste und Filter —, liegt jetzt
hinter diesem einen Schalter. Zugeklappt bleiben genau zwei Zeilen stehen
(Überschrift und Boardauswahl), darunter beginnt sofort die Liste; vorher
belegten Umschalter, Statusleiste und Filterkarte drei eigene Blöcke.
Der Schalter liegt absolut in der Reiterzeile, damit er beim Auf- und
Zuklappen nicht die Zeile wechselt. Die Zahl an ihm nennt, wie viele Filter
greifen — der Status zählt jetzt mit, weil auch er dort drin liegt.
- digi: Bedienelemente kompakter. Chips, Umschalter und Klappfelder wurden
in Höhe und Innenabstand verkleinert. Nur für Maus und Trackpad: Wo mit
dem Finger getippt wird (pointer:coarse), wachsen die Ziele wieder auf
Fingergröße — Treffsicherheit geht dort vor Platzersparnis.
- core: Vorschau-Kennzeichnung ist ein freischwebendes Label. Bisher stand
auf jeder Seite eines Vorschau-Moduls ein Hinweisblock über die volle
Breite und schob den Inhalt nach unten. Jetzt dasselbe kompakte „Preview"
wie auf den Dashboard-Kacheln, freischwebend unter der Kopfzeile: dauerhaft
sichtbar, ohne Platz zu nehmen, und beim Scrollen mitlaufend — der Hinweis
gilt für die ganze Seite, nicht nur für ihren Anfang.
Die Erklärung geht nicht verloren: Sie steckt als Kurzinfo am Zeiger und im
vorlesbaren Text, denn ein blosses „Preview" sagt nicht, warum die Seite für
andere unsichtbar ist. Beim Drucken läuft das Label wieder im Text mit.
[0.97.2] — 2026-07-31
- digi: Der Filterbereich klappt zu und merkt sich das. Standard ist
eingeklappt — das Board beginnt jetzt mit der Liste statt mit
Bedienelementen. Der Zustand gilt je Person und je Gerät: am
Büro-Rechner aufgeklappt, am Handy zu.
Deshalb im Browser gespeichert und nicht in der Datenbank — die gälte für
alle Geräte. Die Kennung der Person steckt im Schlüssel, damit an einem
gemeinsam genutzten Rechner nicht der eine den Zustand des anderen vorfindet.
Ein Zustand für alle drei Boards, nicht je Board: Zwischen den Reitern wird
ständig gewechselt, ein dabei mal auf- und mal zuklappender Filter wirkte
wie ein Fehler.
Ist etwas gefiltert, nennt der zugeklappte Titel die Anzahl („2 aktiv"),
damit ein aktiver Filter nicht unbemerkt bleibt. Der Status zählt dort nicht
mit — er wird oben in der Statusleiste gesetzt und ist auch dort sichtbar.
Ohne JavaScript bleibt es ein gewöhnliches <details>: zugeklappt startend,
per Klick zu öffnen. Das Merken läuft über data-klapp-merken und steht
damit auch künftigen Modulen zur Verfügung.
[0.97.1] — 2026-07-30
- digi: Aufgeklappte Filter verschwanden hinter der Tabelle. Ursache war
nicht die Reihenfolge, sondern .card:hover{transform:…}: Ein transform
erzeugt einen eigenen Stapelkontext, und darin blieb das z-index des
Klappfelds *innerhalb* der Filterkarte gefangen — die spätere Tabellenkarte
zeichnete darüber. Sichtbar wurde das genau dann, wenn der Zeiger über der
Filterkarte stand, also immer bei geöffnetem Filter. Beide Karten haben jetzt
einen ausdrücklichen z-index (Filter 40, Tabelle 1) statt sich auf die
Zeichenreihenfolge zu verlassen.
- digi: Klappfilter starten geschlossen und schließen sich gegenseitig.
Vorher öffnete sich jedes Feld mit aktiver Auswahl beim Laden von selbst;
mehrere offene Felder lagen dann übereinander. Dass gefiltert wird, zeigt
weiterhin die Zahl im Titel. Ein Klick daneben oder Esc schließt (Esc setzt
den Fokus zurück auf das Feld); ohne JavaScript bleiben es normale <details>.
[0.97.0] — 2026-07-30
- digi: Board-Ansicht auf den Stand des Altportals gebracht. Die Übersicht
war gegenüber der Vorgängeranwendung deutlich karger — das fiel beim ersten
Arbeiten mit den übernommenen Anliegen sofort auf. Neu bzw. zurück:
Statusleiste, die mit einem Klick filtert und je Status die Anzahl nennt;
Aktiv/Archiv als Umschalter mit beiden Zahlen, statt eines Kästchens, das
erst abgeschickt werden musste; Klappfilter mit Ankreuzkästchen statt
Mehrfachauswahl-Listen (ohne Strg-Taste bedienbar, also auch mobil); Spalte
Prozessschritt mit Punktkette und „Schritt 3/9"; Excel-Export jetzt
auch in der Gesamtsicht.
Die Statusleiste sind bewusst **Links, keine sich selbst absendenden
Ankreuzfelder**: Ein Bedienelement, das beim Ändern die Seite wechselt,
verstößt gegen WCAG 3.2.2 — genau dafür wurde data-autosubmit im Portal
schon einmal entfernt. Als Link ist der Wechsel erwartbar, und er
funktioniert ohne JavaScript.
- digi: Filter in der Gesamtsicht „Alle" nachgerüstet. Sie bekam weder
Zählung noch Auswahlwerte übergeben — der Filterbereich blieb deshalb leer,
während er in den Einzelboards funktionierte. Werte werden jetzt über alle
sichtbaren Boards vereinigt.
- digi: Boards einzeln in der Navigation. Bisher führte bei mehreren
sichtbaren Boards nur ein Sammelpunkt „Boards" auf die Gesamtsicht. Wer täglich
in einem bestimmten Board arbeitet, kommt jetzt direkt hin.
- digi: Sofortsuche und Spaltenauswahl an der Tabelle. Die Suche grenzt die
angezeigten Zeilen beim Tippen ein (die Volltextsuche im Filter durchsucht
weiterhin den ganzen Bestand), die Spaltenwahl merkt sich je Board im Browser.
Beides bleibt ohne JavaScript verborgen statt wirkungslos.
- core/digi: Board-Freigaben sind jetzt über die Oberfläche verwaltbar.
Aufgefallen, weil migrierte SyBi-Anliegen für ein Konto unsichtbar blieben:
Die Einschränkung über user_scopes war zwar dokumentiert, ließ sich aber
nur per Datenbank setzen — der Einstellungstext beschrieb eine Verwaltung,
die es nicht gab. Neu im Reiter „Anliegen" eine Freigabe-Tabelle je Person.
Bewusst mit Auswahlknopf „alle Boards / nur diese" statt bloßer Kästchen: Kein
Eintrag bedeutet im Datenmodell „keine Einschränkung", ein leeres Formular
hätte also das Gegenteil dessen bewirkt, was es anzeigt. Eine leere Auswahl
wird abgelehnt und gemeldet, statt still alles zu öffnen.
- core:
repo_user_scopes_setzen()undrepo_users_mit_recht(). Module
schreiben Kerntabellen nur über Core-Funktionen (4.5); für Scopes gab es
bisher nur den Leser.
- **digi: Anhänge werden beim Abgleich auch dann nachgezogen, wenn der Vorgang
unverändert ist.** digi_migration_vorgang() stieg bei „unverändert" aus,
bevor die Anhänge an der Reihe waren. Wird das Verzeichnis der Altdateien
— wie in docs/DIGI-UMZUG.md vorgesehen — erst später eingetragen, hat sich
im Altportal nichts geändert: Jeder Vorgang galt als unverändert, und das
zugesagte Nachziehen der Dateien konnte nie greifen. Die Übernahme der Anhänge
läuft jetzt bei jedem Lauf; doppelte Anlagen verhindert weiterhin external_id.
- digi: Sortierung nach
data-sortwert. Die Spalte Prozessschritt sortiert
nach Fortschritt statt nach Text — „Schritt 2/9" landete sonst vor
„Schritt 10/11".
[0.96.0] — 2026-07-30
- core: Hinweis, wenn ein Modul zwar freigegeben ist, aber nicht erscheint.
Im Betrieb aufgefallen: Ein Super-Admin stellt ein Modul auf „Vorschau", sieht
es in den Einstellungen — und danach passiert nichts. Weder Menü noch
Dashboard zeigen es. Der Grund ist nicht der Zustand, sondern ein fehlendes
Fachrecht: <modul>_nav() und <modul>_dashboard_tiles() fragen danach,
und die Rolle super_admin trägt bewusst keine Modul-Rechte — sonst hätte sie
automatisch Zugriff auf die Daten aller Module.
Die Modul-Karte in den Einstellungen sagt das jetzt: was fehlt, warum die
Trennung so gewollt ist, und wo man es behebt. Der Hinweis steht **dort, wo
der Zustand umgestellt wird** — in einer Anleitung würde ihn niemand lesen.
Zwei Tests halten die Regel fest, dazu ein Absatz in Konzept 18.1 (v1.20),
damit künftige Module es nicht neu herausfinden müssen.
[0.95.0] — 2026-07-30
- akkreditierung: Modul-eigene Testreihen nachgezogen (27 Fälle). Die
Konvention „neue Sicherheitslogik ohne Testfall gilt als unfertig" (14.8) war
beim Modul noch offen. akkreditierung_fachlogik.test.php prüft die
SAFE-SPORT-Pflichtansicht (Haken ohne Sitzungsvermerk zählt nicht), die
serverseitige Übernahme der Veranstaltungsdaten gegen manipulierte Felder,
die Abweisung deaktivierter/abgelaufener Veranstaltungen, Pflichtfelder,
Zeitraumregeln, den jahresweisen Nummernzähler, Statuswechsel samt Verlauf,
die Einteilung der Sammelaktionen und die **Datensparsamkeit der
Wettkampfleitungs-Ansicht** (keine E-Mail, keine Telefonnummer, keine internen
Bemerkungen nach außen). akkreditierung_aufbewahrung.test.php deckt Badge,
Prüf- und Nachreichen-Token sowie beide Löschstufen ab — inklusive der
Gegenproben, dass laufende Veranstaltungen unangetastet bleiben und die
anonymen Kennzahlen die Löschung überleben.
- akkreditierung: Fehler behoben, den erst die Tests sichtbar gemacht haben.
Das Modul rief start_session() — eine Funktion aus der Standalone-App, die es
im Portal gar nicht gibt. Im Web fiel das nie auf, weil die Sitzung dort längst
läuft und der Zweig nie erreicht wurde; auf der CLI (Cron, Tests) wäre es ein
Fatal gewesen. Jetzt die zentrale, gehärtete session_boot(). Die reine
Leseprüfung „Merkblatt gesehen?" startet die Sitzung nur noch, wenn das
überhaupt möglich ist, und zerstört nie eine laufende Ausgabe.
[0.94.0] — 2026-07-30
- digi: Anhänge liegen jetzt in der zentralen Ablage (4.6/O057). Bei der
ursprünglichen Planung gab es documents noch nicht; die Entscheidung lautete
darum ausdrücklich „vorerst eine eigene Tabelle". Mit v0.91.0 ist die zentrale
Ablage da und für alle Module verbindlich — also umgestellt, solange es nichts
zu übernehmen gab (digi_attachments war nie in Betrieb). Das Modul gewinnt
damit, was es vorher selbst nicht mitbrachte: MIME-Prüfung am Dateiinhalt
statt an der Endung, sha256 je Datei, Ablage außerhalb des Webroots und die
zentrale Auslieferung mit nosniff und no-store.
- Die Download-Route prüft jetzt, dass die Datei zu einem Anliegen gehört.
Ohne diese Prüfung wäre /digi/anhang/{id} ein Zugang zu jeder Datei im
Portal gewesen, auch denen anderer Module: die Rechteprüfung dahinter fragt
„darf diese Person dieses Anliegen sehen" und liefe bei einer fremden Datei
ins Leere. Testfall vorhanden.
- Abgelehnte Anhänge reißen den Vorgang nicht mehr mit. Das Anliegen ist
beim Ablegen der Dateien bereits gespeichert; scheitert eine Datei, wird sie
einzeln gemeldet, statt die Einreichung zu verlieren.
migrate_0033_digi_anhaenge_zentral.sqlentferntdigi_attachments— aber
nur, wenn sie leer ist. Die Datei läuft auch dort, wo womöglich schon
Anhänge erfasst wurden; ein bedingungsloses DROP löschte dort Dateiverweise,
ohne dass es jemand merkt.
- digi: Weiterleitung alter Lesezeichen zeigte auf die falschen Pfade. Die
Zuordnung nahm /it-anliegen/12 und /sybi-anliegen/12 an — so heißen im
Altportal aber die Infoseiten. Die Vorgänge liegen unter …-anfrage (Sicht
der einreichenden Person) und …-board (Sicht des Teams). Zwei von drei Boards
hätten ins Leere geführt. Alle sechs Pfade sind jetzt abgedeckt und einzeln
geprüft.
- digi: Job
digi_owner_nachziehen. Vorgänge, die vor dem ersten Login ihrer
einreichenden Person entstanden sind (Übernahme, Einreichung für Dritte),
bekommen ihre Kontoverknüpfung automatisch, sobald das Konto existiert.
- tests: der Testlauf schaltete alle Module ab.
module_fundament.test.php
setzte die Modulzustände um und löschte module_status beim Aufräumen,
statt den Vorzustand wiederherzustellen. Auf einer Installation mit echten
Modulen war danach jedes davon „aus" — keine Routen, keine Kacheln, keine
Jobs, dauerhaft und lautlos. Unentdeckt, weil module_status in der
Dev-Datenbank bisher gar nicht existierte; aufgefallen, als mit digi ein
zweites Modul dazukam. Jetzt wird der Vorzustand gesichert und
zurückgeschrieben, samt Testfall, der genau das prüft.
- digi: die Übernahme ist gegen echte Altdaten durchgespielt — Probelauf,
Übernahme, Wiederholung, Delta und Konflikt. Bestätigt: Der Probelauf schreibt
keine Zeile; ein zweiter Lauf legt nichts doppelt an; Eingangsdatum, Verlauf,
Nachrichten, Bearbeitungsverlauf, Nutzen, Links und Anhänge kommen vollständig
mit; ein Status, den das Portal nicht kennt, wird in die Liste aufgenommen statt
verbogen; und Arbeit, die inzwischen im Portal passiert ist, wird als Konflikt
gemeldet statt überschrieben.
- Vier dieser Fälle laufen jetzt automatisch mit — aber **nur gegen eine
erkennbare Test-Altdatenbank**. Auf dem Server steht in digi.legacy die
echte Datenbank des Altportals; ohne diesen Riegel veränderte ein Testlauf
auf Staging deren Produktivdaten.
- digi: rief durchgehend eine Funktion auf, die es nicht gibt. Das Modul
benutzte audit_log() — die Portal-Funktion heißt audit(). Im Betrieb hätte
das beim Einreichen, bei jedem Statuswechsel, beim Verschieben, beim Löschen und
beim Speichern der Einstellungen einen Fatal Error geworfen. Kein bestehender
Test konnte das finden: Lint prüft nur Syntax, und die Handler lassen sich ohne
Request-Kontext nicht aufrufen. Behoben — und ein Testfall geht jetzt mit dem
PHP-Tokenizer durch alle Modul- und Ansichtsdateien und prüft, dass jede
aufgerufene Funktion auch existiert.
- digi: die Board-Zugriffe kommen bei der Übernahme mit. Bisher migrierte der
Abgleich nur die Vorgänge — wer welches Board sehen darf, hätte danach von Hand
nachgebaut werden müssen. Jetzt wird das Zugriffsmodell des Altportals
mitübersetzt: „Admin" und „Geschäftsführung" sehen alle Boards, Einzelfreigaben
(board_access_<zweig>) behalten genau ihr Board — abgebildet auf die Rollen
digi.admin/digi.bearbeit plus den Scope digi_board.
- Wer alle Boards sehen soll, bekommt bewusst keinen Scope-Eintrag: ein
Eintrag schränkt ein, statt zu berechtigen. Ein Testfall hält das fest.
- Super-Admins werden nicht übernommen. Sie standen im Altportal bewusst nur
in der Konfigurationsdatei; Adminrechte gehören in die Benutzerverwaltung des
Portals und nicht in einen Datenabgleich.
- Adressen ohne Portalkonto werden namentlich gemeldet und beim nächsten
Abgleich nachgezogen — still übergehen wäre die schlechtere Variante.
- docs:
DIGI-UMZUG.md— die Reihenfolge für die Ablösung des Altportals,
von den Zugangsdaten über Probelauf und Übernahme bis zum Umlegen der
Weiterleitung. Wichtig ist die Reihenfolge: Bis auf den letzten Schritt ist
alles umkehrbar, und niemand steht zwischendurch ohne Zugriff da.
- Tests: 511 grün — 44 im digi-Modul, 27 in der Akkreditierung.
[0.93.0] — 2026-07-30
- akkreditierung: Neues Fachmodul „Medienakkreditierung" (Vorschau). Portierung
der Standalone-App presse.btv-intern.de (v1.4.0) als Modul nach Konvention 4.5.
Externe Medienvertretende beantragen ohne Portalkonto unter
/akkreditierung/beantragen; der Fachbereich prüft, entscheidet und gibt Badges
aus. Startet im Zustand vorschau — Freigabe nur durch den Super-Admin.
- Öffentlich: Antragsformular mit durchsuchbarer Veranstaltungsauswahl,
Spam-Schutz über die Core-API (Honeypot, Zeitfalle, Rate-Limit),
Unterlagen-Nachreichen per Einmal-Link, Badge-Prüfung per QR und die
Live-Seite der Wettkampfleitung (Token, kein Konto nötig).
- SAFE-SPORT-Merkblatt ist Pflichtlektüre: Das PDF liegt außerhalb des
Webroots und wird nur über eine Route ausgeliefert, die den Aufruf in der
Sitzung vermerkt. Die Kenntnisnahme akzeptiert der Server nur mit diesem
Vermerk — ein gesetzter Haken ohne Öffnen wird abgewiesen, auch ohne JavaScript.
- Datenschutz (DSB-Vorgabe): keine Einwilligungs-Checkbox mehr, sondern ein
sichtbarer Hinweis mit Direktlink vor dem Absenden; Hinweis „nicht benötigte
Angaben dürfen geschwärzt werden" am Upload.
- Intern: Steuerungs-Board mit Filtern, Mehrfachauswahl und Sammelversand
(Genehmigung, Badge-Mail) — jeweils mit Vorschau, wer was bekommt. Vorbefüllter
Mail-Editor; automatisch gehen nur Eingangsbestätigung und Eingangs-Info raus.
Badge-PDF (tFPDF) mit Verify-QR, Sammel-Badges und Druckliste je Veranstaltung.
- Dateien liegen ausnahmslos in der zentralen
documents-Ablage (4.6) —
das Modul hat keine eigene Upload-Tabelle.
- Aufbewahrung in zwei Stufen (12.5.4) über
cleanup_job(): Nachweise,
Badges und die Links darauf 7 Tage nach Veranstaltungsende, der ganze Antrag
nach 6 Monaten. Anonyme Aggregatzahlen wandern vorher in metrics; einzeln
löschen lässt sich außerdem direkt im Antrag.
- Rollen
akkreditierung.kommunikation/akkreditierung.admin, Rechte
akkreditierung.bearbeiten / akkreditierung.verwalten.
- Migration
0032_akkreditierung_fundament.sql(idempotent). - core: Modul-JavaScript nach Konvention. Legt ein Modul
public/assets/<modul>.js ab, bindet das Layout es **nur auf den Seiten dieses
Moduls** ein — kein globaler Ballast, keine Registrierung nötig.
[0.92.0] — 2026-07-30
- core: Zentrale Dateiablage
app/documents.php(Konzept 4.6, O057). Die
documents-Tabelle stand seit dem MVP im Schema, die API dazu fehlte — sie
entsteht laut Konvention mit dem ersten Modul, das Dateien braucht (jetzt:
Medienakkreditierung). Enthält Ablegen von Uploads und erzeugten Artefakten
(Badge-PDF), Zufallsname außerhalb des Webroots, MIME-Prüfung am Inhalt
statt an der Endung, sha256 je Datei mit Integritätsprüfung, Ausliefern nur
über eine rechteprüfende Route und Löschen in zwei Stufen (Datei weg, Zeile
bleibt als Nachweis mit deleted_at).
- core: Mail-Anhänge (Anforderung 2026-07-30).
queue_mail()nimmt
['anhaenge' => [<documents.id|public_id>, …]]. Anhänge sind **Verweise auf
documents, keine Kopien** — kein DB-Wachstum, und die Aufbewahrung behält
eine einzige Löschgeschichte. Geprüft wird beim Einreihen (Existenz,
Lesbarkeit, sha256, Anzahl ≤ 5, Summe ≤ mail.attachment_max_bytes = 3 MB);
schlägt etwas fehl, wird die Mail nicht eingereiht. Fehlt eine Datei später,
scheitert der Versandversuch mit sprechendem Grund — **es geht nie eine Mail
ohne ihren Anhang raus** (eine Badge-Mail ohne Badge ist schlimmer als keine).
Graph nutzt fileAttachment, SMTP wurde auf multipart/mixed umgebaut (konnte
vorher keine Anhänge), simulate protokolliert Name, Größe und MIME.
- core: Absender je Mail.
queue_mail(..., ['from' => '…'])für Module mit
eigener Fachadresse (Akkreditierung: presse@btv-turnen.de, damit Antworten
beim Fachbereich landen). Neue Spalte mails.mail_from (NULL = Portal-Absender).
Verbindlich mit Allowlist mail.absender_erlaubt: Ein frei setzbarer
Absender wäre eine Spoofing-Schnittstelle; nicht freigegebene oder ungültige
Adressen werden abgewiesen (kein stiller Rückfall auf den Portal-Absender) und
landen im Audit. Der Versand-Deckel (B9) bleibt unverändert je Empfänger.
- core: Mail-Log zeigt Anhänge (Name, Größe, „nicht mehr vorhanden") und einen
abweichenden Absender — ohne Download, Dateien nur hinter der Dokument-Route.
- Migration
0031_core_mail_anhaenge_absender.sql(idempotent). - 26 neue Testfälle (
documents.test.php,mailer_anhaenge.test.php); 440 grün.
[0.91.0] — 2026-07-30
- digi: neues Fachmodul „Anliegen". Das bisher eigenständige
Digitalisierungsportal (digitalportal.btv-intern.de) zieht als Modul ins
Portal — mit allen drei Boards (Digitalisierung, IT-Anliegen, SyBi-Anliegen),
Einreichformularen, Steuerungs-Boards, Nachrichten, Anhängen, CSV-Ausgabe und
dem Verschieben zwischen Boards. **Eine Tabelle mit Zweig-Spalte statt dreier
Tabellen:** Im Altsystem existierte jede Board-Funktion dreimal, und genau
daraus sind dort die meisten Fehler entstanden. Hier steht der Unterschied
zwischen den Zweigen an einer Stelle (digi_zweig()), der Rest ist einmal
vorhanden — und das Verschieben eines Vorgangs ist ein Feld-Update statt einer
Datenwanderung.
- digi: Übernahme aus dem Altportal auf Knopfdruck (
/digi/migration, Recht
digi.verwalten). Weil das Altportal noch einige Tage parallel läuft, ist die
Übernahme kein einmaliger Umzug, sondern ein wiederholbarer Abgleich:
Probelauf zuerst, dann echte Übernahme; schon Übernommenes wird an der alten
Nummer wiedererkannt (uq_digi_legacy); Vorgänge, die inzwischen im Portal
bearbeitet wurden, bleiben unangetastet und werden als Konflikt gemeldet
(legacy_synced_at). Die alten Nummern bleiben erhalten, alte Lesezeichen
führen über /digi/alt/<board>/<nr> auf den neuen Vorgang. Planner-Verweise,
die das Portal nicht kennt, landen in den internen Notizen statt im Nichts.
- digi: keine Rumpfkonten bei der Übernahme. Ursprünglich vorgesehen, aber
beim Abgleich mit app/oidc.php verworfen: Der SSO-Login löscht
unbestätigte Rumpfkonten und legt ein frisches an — die Verknüpfung hätte
danach ins Leere gezeigt. Stattdessen findet jede Person ihre Altvorgänge über
die E-Mail-Adresse, und digi_owner_nachziehen() setzt die Verknüpfung, sobald
das Konto wirklich existiert.
- core: Migrationsdateien vertragen Semikolons in Kommentaren.
repo_sql_run_file() trennte an ; bevor es Kommentarzeilen entfernte.
Ein Semikolon im Fließtext eines Kommentars zerlegte damit das folgende
CREATE TABLE mitten im Spaltenblock — mit einem Syntaxfehler, dessen Ursache
im Statement selbst nirgends zu sehen war. Aufgefallen beim Einspielen von
migrate_0029. Jetzt fliegen Kommentare zuerst raus; Testfall vorhanden.
- digi: zwei Fehler beim Bau gefunden und behoben. Leere Pflichtfelder
wurden als NULL in NOT NULL-Spalten geschrieben (jedes Anliegen ohne
Angabe zu „Führungskraft informiert" wäre am Speichern gescheitert), und eine
in den Einstellungen bewusst geleerte Liste fiel still auf den Code-Standard
zurück — Status-Mails ließen sich dadurch nicht abschalten.
- digi: Kategoriefilter griff in der Gesamtsicht nicht. Die Kategorie steht
je Zweig in einer anderen Spalte (it_type bei IT, sonst category). In der
Sicht „Alle" wurde nur category geprüft — jedes IT-Anliegen fiel damit still
durch den Filter, obwohl sein Wert in der Auswahl stand. Zusätzlich riss ein
Ausschlussfilter Zeilen mit, die in der geprüften Spalte gar nichts stehen
hatten (NOT IN ist bei NULL weder wahr noch falsch). Beides behoben, Testfall
über alle drei Boards und die Gesamtsicht vorhanden.
- Tests: 34 neue Fälle in
tests/digi_modul.test.php(Zweig-Registry,
Statuskette, Eingabeprüfung, CSP-Sauberkeit der Ansichten, Rendering aller
Ansichten, Wiederholbarkeit der Übernahme). Gesamt 475 grün.
- Migrationen:
migrate_0029_digi_anliegen.sql(Tabellen, Rollen, Rechte),
migrate_0030_digi_migration_marker.sql (Marker für den Abgleich).
[0.90.0] — 2026-07-30
- core: Passwortänderung ist eine sensible Aktion (Konzept 7.6). Sie lag
bisher nur hinter „angemeldet + aktuelles Passwort". Das reicht nicht: Wer eine
offene Sitzung übernimmt und das Passwort kennt (geteilt, geleakt), hätte den
Zugang an sich ziehen können, ohne den zweiten Faktor je gesehen zu haben.
Jetzt hinter require_twofa() — für Konten mit eingerichteter 2FA folgenlos
(sie haben ihn beim Anmelden erbracht), wirksam nur bei aufgeschobener 2FA.
Aufgefallen beim Abgleich mit der Datenschutz-Rückmeldung von Projekt 29.
- Konzept 7.6: Katalog sensibler Aktionen präzisiert. Neu benannt: die
Passwortänderung und — als eigene Zeile — die Stammdaten. Dort steht jetzt
ausdrücklich, dass das Ändern das Gate trägt, das Ansehen bewusst
nicht, samt Begründung: Name und Anschrift sind keine besonderen Kategorien
nach Art. 9 DSGVO, und ein Gate auf das Ansehen höbe den 2FA-Aufschub
(Entscheidung 2026-07-16) praktisch auf. Ebenso festgehalten: Die erstmalige
Eingabe in Etappe 5 der Registrierung trägt kein Gate.
- docs:
DATENSCHUTZ-ABGLEICH-P29.md— Abgleich der Projekt-29-Rückmeldung
gegen den Codestand, jetzt mit fertigen Textbausteinen für den
Ergänzungstext der Datenschutzerklärung (2FA-Absatz als Ersatz, Newsletter-
Absatz als Ergänzung).
[0.89.0] — 2026-07-29
- newsletter: ein Formular für An- UND Abmeldung. Statt zweier Seiten gibt
es jetzt eine: ankreuzen, was man bekommen möchte, abschicken — **was nicht
angekreuzt ist, wird abbestellt**. Der Unterschied bleibt sichtbar:
Anmeldungen laufen über die Bestätigungsmail, Abmeldungen wirken sofort.
Dazu ein ausdrücklicher Knopf „Von allem abmelden"; nichts anzukreuzen führt
nicht stillschweigend dahin — das wäre zu folgenreich für ein Versehen.
/newsletter/abmelden/<slug> bleibt als reine Abmeldeseite bestehen, weil
dieser Link in bereits verschickten Newslettern steht.
Ein Formular, mehrere Absende-Knöpfe (name="modus") — reines HTML, ohne
JavaScript bedienbar.
- newsletter: „Meine Newsletter" — Stand sehen und ändern (Migration 0028).
Wer wissen will, wofür er angemeldet ist, lässt sich einen Link an seine
Adresse schicken. Erst dieser Nachweis erlaubt die Anzeige: Stünde der Stand
direkt nach Eingabe einer E-Mail da, könnte **jeder mit einer fremden Adresse
nachsehen, wo diese Person eingetragen ist**. Die Antwort auf die Anforderung
ist deshalb immer dieselbe, egal ob die Adresse bekannt ist.
Auf der verlinkten Seite sind die Kästchen mit dem echten Stand vorbelegt;
Änderungen wirken sofort, auch Anmeldungen — der Klick auf den Link aus
dem eigenen Postfach ist derselbe Nachweis, den eine Bestätigungsmail
einholen würde.
Token nur als Hash, Adresse verschlüsselt (sie wird gebraucht, um den Stand
zu lesen), Frist 2 Stunden, mehrfach nutzbar bis dahin, Aufräum-Job.
- newsletter: Einzelabruf beim Newsletter-Tool (
newsletter_ziel_empfaenger())
— GET /groups.json/<Liste>/receivers/<E-Mail> statt bis zu 25.000 Datensätze
zu lesen, nur um eine Adresse nachzusehen. Unterscheidet **404 („steht nicht
drin", eine Auskunft) von jedem anderen Fehler** (ein Irrtum bei uns).
- newsletter: keine falsche Auskunft ohne Anbindung. Ohne echte Verbindung
(Betriebsart simulate) kennen wir den Stand nicht — die Seite sagt das jetzt
(„Stand konnte nicht gelesen werden") statt „nicht angemeldet" zu behaupten.
- tests: 9 neue Fälle für die Plan-Logik (
newsletter_verwaltung.test.php),
darunter der Rundlauf „Stand einlesen, unverändert speichern, nichts geht
verloren" und der Negativtest, dass ein manipuliertes Formular weder in eine
fremde Liste einträgt noch aus einer nicht angebotenen austrägt. 419 grün.
[0.88.0] — 2026-07-29
- Konzept: neues Kapitel 18 „Modul-Integration (verbindliche Vorgaben)"
(TECHNISCHES-KONZEPT.md v1.19). Es bündelt, was beim Bau des ersten
Fachmoduls gelernt wurde, damit jedes weitere Modul es von Anfang an erfüllt:
Modul-Zustand aus/vorschau/freigegeben (Freigabe nur durch Super-Admin) ·
Navigationspfad bis in die Detailebene · Einstellungen als Reiter · Modulbild ·
Inline-Hilfe als Pflicht mit der Frage „woher kommen die Daten" ·
Knopf-Sprache · Mobil-Vorgaben · öffentliche Modul-Routen · vier Regeln für
Fremdsysteme · Test- und Abnahmepflichten · Fertig-Checkliste. Jede Vorgabe
stammt aus einem konkreten Fehler, nicht aus der Theorie.
Nachgezogen: 4.5 (module_status statt modules_enabled), Routen-Tabelle 4.4
(/system mit vier Reitern, Alt-Routen leiten um), Anhang A, Querverweise aus
13.1 und 14.8.
app/modules/README.mdnachgezogen — die praktische Kurzfassung für
Modul-Autoren, mit Codebeispielen für Inline-Hilfe, Navigationspfad,
Einstellungs-Reiter, gestapelte Tabellen und öffentliche Routen.
- tests:
views_mobil.test.phperzwingt die Mobil-Vorgaben (3 Fälle). Er
prüft den Quelltext aller Views — Kern und Module —, sodass ein neues
Fachmodul automatisch miterfasst wird. Beim ersten Lauf hat er 17 Views
gefunden, die der Rendering-Durchgang nicht erwischt hatte, weil dort mangels
Daten gar keine Tabelle erschien. Alle nachgestapelt.
Der Test unterscheidet dabei bewusst: Wertepaar-Listen ohne Kopfzeile stehen
schon wie eine Karte da und brauchen nichts; Aktions- und Auswahlspalten haben
keinen Spaltennamen und bekommen keinen erfundenen.
- docs:
TESTS-UEBERSICHT.mdundTESTPLAN-LUECKEN.md— alle 410
automatisierten Fälle nach Bereichen, und was sie nicht abdecken (sechs
Befunde, die live waren, während alle Tests grün standen; vier Prüfrezepte).
[0.87.0] — 2026-07-29
Vollständiger Durchgang über alle 29 Portalseiten (angemeldet als Admin, jede
Seite einzeln gemessen bei 390 px und 1280 px).
- Ergebnis vorweg: Keine einzige PHP-Meldung auf irgendeiner Seite; alle
Umleitungen gewollt (Re-Auth bei E-Mail/Passwort, /verwaltung/rollen →
Einstellungen, /selfcheck → /system, Impressum extern).
- core: abgeschnittener Inhalt behoben.
html,body{overflow-x:hidden}ist
ein Sicherheitsnetz, das den Überlauf nicht verhindert, sondern abschneidet
— zu breiter Inhalt verschwand lautlos nach rechts. Im Changelog betraf das
200 px: technische Ketten wie Auth/2FA/Krypto/Tokens/OIDC/Web/DSGVO brechen
in keinem Browser um. Jetzt overflow-wrap:break-word auf Inhaltsflächen
(bricht nur, wenn es sonst nicht passt) und anywhere in code/kbd/samp.
Gilt portalweit, also auch für jedes künftige Modul.
- core: alle verbliebenen Tabellen stapeln mobil. Benutzerliste, Protokoll,
Mail-Versand, Kennzahlen (3), Rollenverwaltung, Konto-Verlauf, Nachrichten und
Prüfung — Spaltennamen mechanisch aus dem Tabellenkopf übernommen. Damit
scrollt keine Tabelle des Portals mehr seitwärts.
- core: gestapelte Tabellen ausgebessert. Der Tabellenkopf wurde bisher nur
„unsichtbar" gestellt, behielt aber seine Breite und ragte gemessen 492 px
hinaus → jetzt ausgeblendet (die Spaltennamen stehen ohnehin vor jedem Wert).
Lange Schlüssel in Zellen brechen um.
- core+verwaltung: Inline-Hilfe je Block mit Datenherkunft. Profil (Person &
Anschrift, Beim BTV, Zugang & Sicherheit, Bankverbindung, Verknüpfung) und
Benutzerverwaltung (Kontostatus, Stammdaten, Rollen, Mitgliederverwaltung,
Eingriffe, Nachrichten, Verlauf, Löschen, Prüfung) erklären jetzt in jedem
Block, woher die Daten kommen und was das für Änderungen bedeutet — etwa
dass Bankdaten nie aus der Mitgliederverwaltung stammen, dass eine angefragte
Rolle keinerlei Rechte verschafft, dass eine Korrektur an übernommenen Werten
beim nächsten Abgleich wieder auseinanderläuft, und dass das Protokoll
festhält *dass* etwas geändert wurde, nie *welcher Wert*.
[0.86.0] — 2026-07-29
Nachbesserung der Mobil-Durchsicht anhand eines echten Geräts — die
Kopfzeile war zwar überlauffrei, aber immer noch unbrauchbar dreizeilig, und
das Benutzermenü klappte aus dem Bildschirm.
- core: Funktionsmenü als zweites Klappmenü in der Kopfzeile. Alle Symbole,
der Erklärungen-Schalter, die Admin-Einstellungen und der Rollen-Umschalter
liegen jetzt hinter einem Menü. Oben bleiben **Logo, dieses Menü und das
Profilbild** — beide Klappelemente am rechten Rand. Die Kopfzeile ist damit
auf schmalen Geräten wieder einzeilig: 61 px statt 198. Am Desktop
unverändert (Zusammenfassung per CSS unsichtbar, Inhalt als Reihe). Ungelesene
Nachrichten werden am geschlossenen Menü angezeigt — sonst wäre die Anzahl
hinter einem Klick verschwunden.
- core: Klappfenster laufen nicht mehr aus dem Bildschirm. Genau der Fehler
vom Gerät: Das Benutzermenü war links verankert und ging nach links auf. Beide
Fenster hängen jetzt am rechten Rand der Kopfzeile — nicht am jeweiligen
Knopf, der bei einem Nachbarn rechts von sich mitten im Bild steht (gemessen
ragte das Funktionsfenster so 16 px hinaus) — und bekommen die Bildschirm-
breite als Höchstmaß. Ein Tipp daneben schließt sie.
- core: Tabellen stapeln auf schmalen Geräten statt seitwärts zu scrollen.
Waagrechtes Scrollen in einer Tabelle ist auf dem Handy die schlechteste
Lösung — man sieht nie Zeile und Spaltenkopf gleichzeitig. Jede Zeile wird
deshalb zu einer Karte, in der vor jedem Wert sein Spaltenname steht.
Allgemeines Muster für Kern und Module: data-stapeln an der Tabelle,
data-spalte an den Zellen (CSS kann den Tabellenkopf nicht auslesen). Ohne
data-spalte bleibt die Zelle lesbar, nur ohne Beschriftung.
- newsletter: „Listen & Segmente" gestapelt — alle drei Tabellen. Dabei zwei
Regeln zurückgenommen, die aus der Tabellenansicht stammen und in der Karte
schadeten: width:1px der Aktionsspalte (machte die ganze Karte 80 px breit
und quetschte Auswahl- und Notizfeld auf 80 px) und die starre Eingabereihe
(ragte 10 px heraus).
- core: Klapp-Logik korrigiert. Das programmatische Zuklappen löst selbst
ein toggle-Ereignis aus — und zwar verzögert. Ohne Unterscheidung hätte sich
jedes Menü beim ersten Zuklappen als „vom Nutzer bedient" markiert und danach
nie wieder auf eine geänderte Bildschirmbreite reagiert (Gerät drehen,
Fenster ziehen). Betraf auch die Seitenleiste aus v0.85.0.
- Ergebnis (390 px): Kopfzeile 198 → 61 px, erste Überschrift auf der
Startseite 285 → 147, in „Listen & Segmente" 322 → 185; Überlauf 0 px.
Desktop nachgemessen unverändert: Kopfzeile 63 px klebend, Seitenleiste
250 px offen, Burger unsichtbar, Tabellen als echte Tabellen.
[0.85.0] — 2026-07-29
Mobil-Durchsicht des ganzen Portals, gemessen bei 390 px Gerätebreite (Chrome
im Kopflos-Betrieb, jede Seite einzeln vermessen — nicht geschätzt).
- core: waagrechter Überlauf auf JEDER angemeldeten Seite behoben. Die
rechte Kopfzeilen-Gruppe stand auf flex:0 0 auto — sie durfte also nie
schrumpfen und nahm immer ihre volle Inhaltsbreite (gemessen 582 px). Auf
einem 390-px-Display lief das Portal damit um rund 500 px seitlich über:
Kacheln, Tabellen und Überschriften waren rechts abgeschnitten, die ganze
Seite ließ sich seitwärts schieben. Das flex-wrap in der Mobil-Regel lief
ins Leere — umbrechen kann nur, was vorher schmaler werden darf. Jetzt
flex:1 1 auto + min-width:0; Überlauf auf allen geprüften Seiten: 0 px.
Die Tabellen waren nie das Problem, die lagen schon korrekt in ihrem
Scroll-Rahmen.
- core: Seitenleiste auf schmalen Geräten einklappbar. Sie schob den Inhalt
weit nach unten — in einem geöffneten Modul begann die erste Überschrift erst
bei y=555 von 900 sichtbaren Pixeln, zwei Drittel des ersten Bildschirms
waren Navigation. Jetzt ein natives <details> („Bereiche"), am Desktop per
CSS unsichtbar. Standard ist offen: Der Desktop bleibt unverändert und
ein Gerät ohne JavaScript verhält sich wie bisher; app.js klappt sie auf
schmalen Bildschirmen zu und hält sich raus, sobald jemand selbst klappt.
- core: Kopfzeile klebt auf schmalen Geräten nicht mehr. Umgebrochen ist sie
rund 200 px hoch — als Dauergast wäre das ein Fünftel des Bildschirms, auf
jeder Seite. Oben steht sie vollständig da, beim Lesen macht sie Platz. Die
Symbole bleiben auf jeder Seite erreichbar. Der Rollen-Umschalter (mit 283 px
der breiteste Teil, nur für Admins) bekommt die letzte Zeile für sich, statt
die Symbolgruppe zu zerreißen.
- core: Tippziele ≥ 44 px (WCAG 2.5.8) für Kopfzeilen-Symbole,
Untermenü-Einträge, Auswahlfelder und Schalter. Fließtext-Links bleiben außen
vor — die nimmt die Richtlinie ausdrücklich aus, und 44 px hohe Zeilen mitten
im Satz würden den Text zerreißen. Zu klein waren vorher 25–30 Elemente je
Seite, jetzt 10–14 (und das sind Fließtext-nahe Links).
- Ergebnis in Zahlen (390 px, erste Überschrift auf dem Bildschirm):
Startseite 432 → 285, Listen & Segmente 555 → 322, Verbindung anlegen
584 → 351. Desktop unverändert nachgemessen: Kopfzeile 63 px und klebend,
Seitenleiste 250 px offen, kein Überlauf.
- core: PHP-Warnung auf
/start.$u['id']wurde auch für den Dev-Login
gelesen, der — zwei Zeilen tiefer selbst kommentiert — gar kein DB-Konto hat.
In Produktion folgenlos (display_errors aus), im Dev-Modus stand die Warnung
über jeder Seite.
- tests: Modul-Tests prüften ins Leere. Sie setzten den Alt-Schlüssel
modules_enabled; seit Migration 0022 ist module_status die Wahrheit und
der Alt-Schlüssel nur noch Rückfall. Grün waren sie nur, weil in der
Entwicklungs-Datenbank module_status fehlte — mit eingespielter Migration
(auf prod längst) schlugen vier fehl. Jetzt am aktuellen Mechanismus:
407 Tests grün, 0 rot (der lange mitgeschleppte Fehlschlag war genau das).
[0.84.0] — 2026-07-29
- core: Navigationspfad reicht bis in die Tiefe. Der Pfad entstand allein
aus der Seitenleiste — Detailseiten stehen dort nicht und fielen deshalb
heraus: „Lauf #12" hing direkt unter „Newsletter-Sync", obwohl drei Ebenen
dazwischen liegen. Eine Seite kann ihre Zwischenstufen jetzt selbst
beisteuern ($pfad an view()), portal_breadcrumb() hängt sie zwischen
Sektion und Seitentitel ein. Doppelungen fallen weg, ein Link auf die
eigene Seite wird zu Text, Stufen ohne Beschriftung werden übersprungen.
Gilt für jedes Modul, ohne Nachpflege im Core.
- core: kurze Beschriftung im Pfad (
$krume). Der Seitentitel darf
ausführlich bleiben („Läufe: Trainer mit Lizenz") — im Pfad steht der
Verbindungsname schon eine Stufe höher, und die Wiederholung liest sich
schlecht. Ohne Angabe bleibt es beim Titel.
- newsletter: vollständige Pfade. Verbindung bearbeiten · Vorschau · Läufe
· Lauf-Detail · Formular bearbeiten führen jetzt über ihre echten Ebenen
(z. B. Start › Newsletter-Sync › Verbindungen › *Trainer mit Lizenz* ›
Läufe › Lauf #12) — jede Zwischenstufe anklickbar. Dafür liest
newsletter_repo_lauf() die zugehörige Verbindung mit; vorher kannte die
Detailseite ihre Herkunft nicht und man kam von dort nicht zurück.
[0.83.0] — 2026-07-29
- newsletter: Segmente der gewählten Liste zum Ankreuzen. Die Segmente
standen bisher nur als Vorschlagsliste am Textfeld und hingen nicht an der
oben gewählten Liste — auswählbar waren sie damit nicht. Jetzt ein Kästchen
je Segment, gebündelt nach Liste; die Auswahl oben blendet auf die passende
Gruppe um (data-gruppen-wahl → app.js, kein Inline-JS). **Ohne
JavaScript** stehen alle Blöcke da, jeder unter dem Namen seiner Liste, und
bleiben bedienbar; der Server nimmt ohnehin nur die Kästchen der gewählten
Liste an — die Kästchen sind dafür nach Liste geschachtelt
(segment_wahl[<Liste>][]). Hat eine Liste keine Segmente, sagt die Seite
das, statt leer zu bleiben.
Je Segment steht dabei, ob der Abgleich es überhaupt füllen kann: Er
schreibt in phx_segmente, ein Segment mit Regel auf btv_kennung bleibt
für ihn unerreichbar — das wäre sonst die Falle, in die man beim Ankreuzen
als Erstes läuft.
Das freie Feld bleibt (jetzt „Eigene Kennzeichen"): Ein Abgleich darf
Kennzeichen setzen, für die es noch gar kein Segment gibt — beim Einrichten
ist das der Normalfall. Gespeicherte Kennzeichen werden auf beide Wege
aufgeteilt, damit keines doppelt dasteht.
[0.82.0] — 2026-07-29
- newsletter: Ziel-Verteiler auswählen statt Gruppen-ID abtippen. Das
Verbindungs-Formular zeigt die Empfängerlisten aus dem Katalog zur Auswahl
(Name + Kennung). Für eine Liste, die es dort noch nicht gibt, bleibt ein
klar beschriftetes Handfeld daneben stehen — ausgefüllt sticht es die
Auswahl, damit eine frisch angelegte Liste niemanden blockiert. Ohne
aufgebauten Katalog (Betriebsart simulate) bleibt es beim bisherigen
Eingabefeld. Die Segment-Kennzeichen bekommen eine Vorschlagsliste aus den
Werten, auf die die Segmente der Zielliste tatsächlich prüfen.
- newsletter: „Kommen die Empfänger in einem Segment an?" (Migration 0027).
Neuer Befund im Verbindungs-Formular. Er beantwortet die Frage, die sonst
erst Wochen später auffällt: Der Abgleich läuft fehlerfrei und schreibt
seine Kennzeichen — aber wenn kein Segment darauf prüft, ist in der Liste
trotzdem niemand gezielt anschreibbar. Genau das ist der reale Ausgangsfall:
Alle 10 vorhandenen Segmente filtern auf btv_kennung, der Abgleich
schreibt phx_segmente.
Geprüft wird nicht nach dem Feldnamen — ein Segment darf mehrere
Bedingungen über verschiedene Felder mit UND/ODER verknüpfen. Stattdessen
wird ein Empfänger mit dem Kennzeichen simuliert und durch die **echten
Regeln** geschickt. Zwei Durchgänge, weil es einen Unterschied macht:
allein und mit einem zweiten Kennzeichen. Eine Regel mit *ist* trifft dann
nicht mehr — und der Abgleich setzt bei „Trainer mit Lizenz" je Lizenz ein
weiteres. Dieser Fall wird als „nur allein" ausgewiesen statt als grün.
Dafür speichert der Katalog die Regeln jetzt mit aufgelösten Feldnamen mit.
- core:
db_column_exists(). Gegenstück zudb_table_exists()— Code
überlebt eine noch nicht eingespielte Migration, statt am SQL-Fehler zu
scheitern.
[0.81.0] — 2026-07-29
- newsletter: Segmente werden gelesen, nicht mehr geraten (Migration 0026).
Der Katalog stand auf einer falschen Annahme: „Das Newsletter-Tool kennt
unsere Segmente nicht als Objekte." Geprüft worden war dafür eine Liste, die
schlicht keine Segmente hat. Richtig ist:
GET /groups.json/<Liste>/filters.json liefert je Liste genau die Segmente,
die dort im Reiter „Segmente" stehen — real 10 Stück in 2 von 20 Listen
(GYMWELT 2, wettkampfsport 8), jeweils mit Kennung, Name und Regel.
Damit ist „welche Segmente gehören zu dieser Liste?" keine Ableitung mehr,
sondern eine Ablesung; die vorherige Herleitung aus dem Vorkommen von
Kennzeichen entfällt (sie musste scheitern, weil Kennzeichen im
Newsletter-Tool GLOBAL gelten und in jede Liste mitwandern).
Neue Tabelle newsletter_katalog_segmente; die Zahlen entstehen weiter im
nächtlichen Durchgang, indem jede Segment-Regel gegen die Empfänger
ausgewertet wird (Auswertung für EQ/CONTAINS/… inkl. Klammern und AND/OR;
unbekannte Logik zählt bewusst nichts, statt zu raten). Ergebnis stimmt
exakt mit dem Newsletter-Tool überein.
- newsletter: Abgemeldete galten als aktiv — Reaktivierungs-Risiko behoben.
active wurde mit dem Zeitstempel activated oder-verknüpft. Der bleibt
nach einer Abmeldung stehen, also galten Abgemeldete als aktiv — und
newsletter_ziel_http_upsert() setzt für Aktive activated, hätte sie also
reaktiviert. Genau das darf nie passieren. Portalweit betraf die
Fehleinstufung 1.057 von 25.616 Empfängern (Zielliste des Abgleichs:
284). Ein tatsächlicher Wiedereintrag ist nicht nachweisbar — die 284 sind
nach dem letzten Live-Lauf unverändert inaktiv. Kriterium jetzt in
newsletter_cr_ist_aktiv() gebündelt, mit Test.
- newsletter: Katalog-Ansicht folgt der neuen Ordnung. Je Liste zuerst ihre
Segmente (Name, Regel, Empfänger, davon aktiv, Herkunft), darunter
aufklappbar die Kennzeichen, die dort vorkommen, aber von keinem Segment
aufgegriffen werden — die sind nicht gezielt anschreibbar. Die Auswertung
über alle Listen („Kennzeichen ohne Zuordnung") steht jetzt unter den
Listen statt darüber. Inline-Hilfe erklärt Liste/Segment/Kennzeichen neu.
[0.80.2] — 2026-07-29
- newsletter: leere Felder sind keine Kennzeichen. In jeder Liste standen
zwei Zeilen „— ohne Kennzeichen —" und zählten sogar in den Badge. Der
Scanner legt dafür keine Zeile mehr an, sondern zwei Kennzahlen je Liste
(Empfänger gesamt/aktiv und „ohne jedes Kennzeichen"), die in der Kopfzeile
stehen.
[0.80.1] — 2026-07-29
- newsletter: Katalog zeigt nur Kennzeichen, die zur Liste gehören. Zuvor
erschienen in jeder Liste alle Kennzeichen — Ursache: Sie sind im
Newsletter-Tool globale Attribute. Zugehörigkeit nur noch aus einer
Erklärung (Abgleich, Formular, Vermerk); Listen auf- und zuklappbar,
Tabellen per Klick auf die Überschrift sortierbar.
[0.80.0] — 2026-07-29
- newsletter: Katalog der Listen und Segmente (Migration 0025). Übersicht
aller Empfängerlisten mit den darin vorkommenden Kennzeichen und ihrer
Herkunft. Der Durchgang ist teuer (25.616 Empfänger, ~90 s) und läuft
deshalb als Job; die Seite liest nur. Herkunft wird abgeleitet, wo das
Portal sie kennt, von Hand vermerkt wird nur der Rest.
[0.79.2] — 2026-07-29
- newsletter: Bestätigungsformular gehört zur Liste, nicht zum Formular.
Die Bestätigungsmail von CleverReach aktiviert immer in der Liste, zu der
ihr Formular gehört — eine Kennung je Portal-Formular reicht also nicht.
Die Formular-Kennung wird jetzt je Liste hinterlegt (4. Feld der
Optionszeile).
[0.79.1] — 2026-07-29
- newsletter: Verwaltung der Anmeldeformulare. Anlegen, bearbeiten,
löschen, aktiv schalten; Optionen als lesbare Textzeilen
(Liste | Beschriftung | token=Beschriftung; … | Formular-Kennung).
Adress-Kürzel (slug) wird aus dem Namen gebildet — Kleinschreibung vor
der Umlaut-Ersetzung (sonst wurde aus „Übungsleiter" „bungsleiter", E093).
[0.79.0] — 2026-07-29
- newsletter: öffentliche An- und Abmeldeformulare (Migration 0024).
Ohne Anmeldung erreichbare Seiten (/newsletter/anmelden/<slug>,
/newsletter/abmelden/<slug>) zum Ein- und Austragen für einzelne Listen
und Segmente — einbettbar auf btv-turnen.de. Die Anmeldung wird erst mit
dem Klick in der Bestätigungsmail gültig (Double-Opt-In über CleverReach),
die Abmeldung wirkt sofort. Angenommen werden nur die Kennzeichen, die
das jeweilige Formular selbst anbietet. Formulare fassen ausschließlich
btv_kennung an; die Kennzeichen des Abgleichs bleiben unberührt.
Protokoll nur als E-Mail-Hash. Neue öffentliche Routen-Kennzeichnung
('public') im Front-Controller.
[0.78.0] — 2026-07-29
- core: Mail-Versand, Systemstatus, Protokoll & Kennzahlen auf einer Seite.
Drei getrennte Menüpunkte wurden zu /system mit vier Reitern
(Protokoll · Kennzahlen · Mail-Versand · Status); die Rechte werden je
Reiter geprüft, nicht pauschal.
[0.77.2] — 2026-07-29
- core: Inline-Hilfe auf allen Portalseiten.
hilfe_block()durchgehend
eingebaut (Profil, Verwaltung, Einstellungen, Freigaben …); der Schalter
sitzt jetzt direkt neben dem Hilfe-Symbol in der Kopfzeile. Gilt als
Standard für jedes künftige Modul.
[0.77.1] — 2026-07-29
- ui: Hilfe-Schalter beschriften statt nur bebildern. Ein Symbol allein
wurde nicht gefunden.
[0.77.0] — 2026-07-29
- core+newsletter: abschaltbare Inline-Hilfe (Migration 0023). Erklärtexte
direkt an Ort und Stelle, ein- und ausschaltbar und je Konto gemerkt
(user_prefs): feld_hilfe() steht immer, hilfe_block() folgt dem
Schalter. Ziel ist eine Plattform, die ohne Anleitung auskommt.
[0.76.0] — 2026-07-29
- ui: Dashboard-Kacheln mit Modulbild. Kacheln zeigen ein Vorschaubild des
Moduls (public/assets/module/<modul>.svg, mit Rückfall auf ein Emblem) —
als Funktion für alle künftigen Module. Das Hintergrundbild wird neben der
Seitenleiste vollständig angezeigt.
[0.75.0] — 2026-07-29
- core: Shell auf JEDER angemeldeten Seite.
views/layout.phpprüfte gegen
eine Whitelist der Kernbereiche — Fachmodule fielen durch und wurden ohne
Kopfzeilen-Icons, ohne Seitenleiste und ohne Breadcrumb gerendert; genauso
/nachrichten, /hilfe, /mail-log und /kennzahlen. Jetzt umgekehrt: die
Shell ist der Normalfall, nur die öffentlichen Strecken (Anmeldung,
Registrierung, Token-Einlösung, 2FA-Onboarding) bleiben bewusst nackt. Damit
bleiben Einstellungen/Hilfe/Nachrichten/Freigaben/Profil durchgehend stehen —
auch in jedem künftigen Modul, ohne Nachpflege einer Liste.
- core: Super-Admin-Ebene + Modul-Freigabe mit Vorschau (Migration 0022).
Neue Systemrolle super_admin (alle Admin-Rechte + neues Recht
system.modules). Jedes Fachmodul hat genau einen Zustand: aus ·
Vorschau (läuft vollständig inkl. Cron-Jobs, ist aber nur für die
ausgewählten Rollen sichtbar und erreichbar, Kennzeichnung „(Vorschau)") ·
freigegeben. Gesteuert im neuen Reiter „Module & Freigabe" der zentralen
Einstellungen — nur mit system.modules. Der Routen-Zugang wird zentral in
public/index.php geprüft (nicht im Modul), Seitenleiste/Kacheln/Reiter über
modul_sichtbar_fuer(). Das frühere Freitextfeld modules_enabled entfällt;
bestehende Module werden als „freigegeben" übernommen. system.* gilt jetzt
durchgängig als sensibel (2FA-Gate), is_real_admin() schließt Super-Admins ein.
- core: Einstellungen trennen Portal und Modul sichtbar. Der Allgemein-Reiter
weist aus, dass seine Werte portalweit gelten; Modul-Reiter tragen ein
„Modul"-Kennzeichen und den Hinweis, dass sie nur dieses Modul betreffen.
- newsletter: Modul-Oberfläche auf das Design-System. Alle Views nutzen jetzt
die Plattform-Klassen (.form mit Feld-im-Label, .check-table in
.tabelle-scroll, .btn-primary/-secondary/-ghost/-danger, .inline-form,
.badge, .note) statt erfundener Klassen; Flash-Meldungen laufen über
flash_render() (vorher wurden sie schlicht nie angezeigt). Seitenleiste mit
Untermenü (Verbindungen · Auskunft · Einstellungen) → korrekte Breadcrumbs auf
allen /newsletter/…-Seiten. Modul-Einstellungen hängen über
newsletter_settings_tab() in den zentralen Einstellungen; die Lauf-Detailseite
zeigt die ersten 200 Einzelaktionen und sagt, wenn gekürzt wurde.
[0.74.0] — 2026-07-28
- newsletter: dynamische Segment-Tokens je Lizenz (licenseholder). Jeder
Trainer wird nach E-Mail dedupliziert (Mehrfach-Lizenzen = ein Empfänger, alle
Lizenzen zusammengeführt). phx_segmente = Basis-Token Lizenzinhabende
+ je Lizenz ein Lizenzinhabende_<Kürzel>. Die Zuordnung license_type→Kürzel
liegt in der editierbaren Einstellung newsletter_lizenz_kuerzel. Der Präfix
wird bei licenseholder automatisch aus dem Basis-Token abgeleitet
(<Basis>_), per attribut_mapping.dyn_prefix überschreibbar. Orphan entfernt
den ganzen Lizenzinhabende*-Namensraum; Tokens anderer Verbindungen bleiben.
[0.73.0] — 2026-07-28
- newsletter: Quelle nach E-Mail deduplizieren. Mehrfach-Lizenzen liefern
mehrere license-holder-Datensätze mit derselben Adresse (real: 9.863 Datensätze
→ 4.709 mit E-Mail → nur 3.386 eindeutige Empfänger). Bisher zählte der Lauf
pro Datensatz („4.709 neu"), obwohl CleverReach korrekt auf die eindeutige
Adresse dedupliziert. Die Engine dedupliziert die Quelle jetzt vor dem Diff
(newsletter_src_dedup) → Kennzahlen entsprechen den echten Empfängern. Kein
Datenverlust, reine Zähl-Korrektur.
[0.72.0] — 2026-07-28
- newsletter: eigenes CleverReach-Sync-Feld
phx_segmente.btv_kennung
ist in CleverReach ein GLOBALES Attribut und wird beim BTV manuell gepflegt —
der Sync darf es nicht überschreiben. Deshalb schreibt der Sync die Segmente
jetzt in ein eigenes globales Feld phx_segmente (per API angelegt); intern
ist das ohnehin der Diff-Name, die Abbildung ist damit die Identität.
btv_kennung wird vom Sync nicht mehr angefasst. End-to-end verifiziert
(phx_segmente gesetzt, btv_kennung leer). Merge/Orphan/Idempotenz unverändert.
[0.71.0] — 2026-07-28
- newsletter: CleverReach-Feldmapping an das reale Konto. Die Segmente
landen jetzt im echten globalen Attribut btv_kennung (nicht dem
angenommenen phx_segmente); intern bleibt phx_segmente der Diff-Feldname,
die Abbildung passiert an der CleverReach-Grenze. Zusätzlich werden beim
Upsert Vorname/Nachname (firstname/lastname) und Anrede
(salutation, Herr→m/Frau→f) als globale Attribute geschrieben; Adresse/
Geschlecht/Geburtstag bleiben bewusst leer (Datensparsamkeit). Rücklesen holt
btv_kennung wieder ins interne Format (Merge/Idempotenz bleiben intakt).
End-to-end gegen das BTV-Konto verifiziert (synthetischer Empfänger,
geschrieben/gelesen/gelöscht). Real gemessen: clubs 0 % E-Mail (nicht nutzbar),
licenseholder 46,7 %, course-speakers 44,8 % gültig.
[0.70.0] — 2026-07-28
- newsletter: Einstellungen + Status/Selbsttest (NL-8). Eigene
Einstellungsseite (/newsletter/einstellungen, Recht newsletter.verwalten):
Statusmail-Empfänger, Aufbewahrung des Aktionen-Logs, sowie ein
Status-Überblick (Modus, CleverReach-/Phönix-/Pepper-Zugang „gesetzt/FEHLT" —
nie der Wert) und ein gedrosselter „CleverReach-Verbindung testen"-Button
(read-only Gruppenzahl, 1×/15 min). Damit ist das Modul funktional komplett
(NL-1…NL-8): Fundament, Quell-/Ziel-Client, Engine, Betrieb, Verwaltung,
Historie/DSGVO-Auskunft, Einstellungen. 351 Tests grün.
[0.69.0] — 2026-07-28
- newsletter: Lauf-Historie, Drill-down & DSGVO-Auskunft (NL-7). Historie je
Verbindung mit Kennzahlen (/verbindung/{id}/laeufe), Drill-down bis zur
Einzelaktion (/lauf/{id}, Empfänger nur als Hash), und „Was ist mit einer
E-Mail passiert?" (/auskunft) — Suche ausschließlich über den E-Mail-Hash,
die eingegebene Adresse wird nicht gespeichert. Zugriff mit
newsletter.ausfuehren.
[0.68.0] — 2026-07-28
- newsletter: Verbindungen-Verwaltung (NL-6). Anlegen/Bearbeiten/Löschen,
Pausieren/Aktivieren, Quell-Vorschau (erste ~20 Datensätze) und Dry-Run/Live-
Start direkt aus der Übersicht. Formular mit Validierung (Name, Quelle,
Ziel-Gruppe, mind. ein Segment-Token, Ersatz-Token-Regel, Zeitplan). Rechte
greifen: Ansehen/Vorschau/Dry-Run mit newsletter.ausfuehren, CRUD + Live-Lauf
nur mit newsletter.verwalten. Alle schreibenden Aktionen als CSRF-POST in je
einer db_tx(), kein Inline-JS (CSP), Formulare ohne JS bedienbar.
[0.67.0] — 2026-07-28
- newsletter: CleverReach-Live-Client (REST v3). Die Stubs sind ersetzt:
newsletter_ziel_http_lesen liest den Gruppenbestand paginiert (aktiv/
deaktiviert) und überlagert die Blacklist; newsletter_ziel_http_upsert
schreibt Empfänger im Batch, setzt activated NUR für Aktive (nie
Reaktivierung Deaktivierter). OAuth2 client_credentials mit Token-Cache; neue
newsletter_ziel_gruppen() listet die Verteiler (für Einrichtung/Selfcheck).
Read-only gegen das BTV-Konto verifiziert (Token + Gruppenliste, u. a.
„Lizenzinhabende", „Vereine"). Der simulate-/Fake-Pfad und alle Tests bleiben
unberührt (342/345 grün).
[0.66.0] — 2026-07-28
- newsletter: Betrieb — Job, Persistenz, manueller Start, Statusmail (NL-5).
app/modules/newsletter/lauf.php führt einen Lauf aus und protokolliert ihn
in newsletter_laeufe (Kennzahlen) + newsletter_aktionen (nur E-Mail-Hash,
Pepper aus config.php — der gespeicherte Bericht enthält keine Klartext-
adressen). Job newsletter_sync läuft fällige, aktive Verbindungen (Zeitplan
taeglich/manuell); cleanup_job für das Aktionen-Log
(aufbewahrung_tage('newsletter_aktionen', 30)). Manueller Start über
POST /newsletter/verbindung/{public_id}/start (Dry-Run mit Ausführen-Recht,
Live nur mit Verwaltungsrecht). Statusmail (Zusammenfassung + Fehler) über die
Outbox, nie werfend. DSGVO-Auskunft „Was ist mit X passiert?" via Hash-Suche.
Ein Fehler beendet einen Lauf sauber als fehler, ohne die übrigen zu stoppen.
[0.65.0] — 2026-07-28
- newsletter: Sync-Engine (NL-4).
app/modules/newsletter/sync.phprechnet
für eine Verbindung den Abgleich Quelle→Ziel: neu / aktualisiert / verwaist /
übersprungen. Die eigenen Segment-Tokens werden in phx_segmente gemergt —
Tokens ANDERER Verbindungen und das Feld themen bleiben erhalten. Orphan-
Strategie token_entfernen (nur das eigene Token weg) oder ersatz_token
(z. B. phx-lizenz-abgelaufen); „verwaist ≠ abgemeldet" (Status unberührt).
Idempotent (zweiter identischer Lauf = 0 Änderungen, letzter_sync zählt
nicht als Änderung), Dry-Run erzeugt denselben Bericht wie Live, schreibt aber
nichts. Vollständig gegen den In-Memory-Fake getestet.
[0.64.0] — 2026-07-28
- newsletter: Ziel-Client CleverReach (NL-3).
app/modules/newsletter/ziel.php
schreibt Empfänger in eine CleverReach-Gruppe. Harte, getestete Invarianten:
Batches ≤ 1000; Attribut-Trennung (schreibt nur phx_segmente/quelle/
letzter_sync, ein themen-Schreibversuch wirft); nie reaktivieren
(blacklist wird übersprungen, deaktiviert bleibt deaktiviert, nur Attribute).
Modus simulate (In-Memory-Fake für Engine/Tests) | live (OAuth2 client_
credentials + REST-Upsert, strukturiert vorbereitet, Live-Verifikation vom
Portal offen). Dry-Run meldet identisch, schreibt nichts.
[0.63.0] — 2026-07-28
- newsletter: Quell-Adapter (NL-2).
app/modules/newsletter/quelle.phpliest
die drei Zielgruppen aus der Mitgliederverwaltung und normalisiert sie auf ein
einheitliches Empfänger-Format: clubs → official/clubs
(Vereinsansprechpartner, postaddress.email, sonst finderaddress.email, Gau
als Attribut) · licenseholder → license-holders (Feld email,
license_type als Segment) · course-speakers → courses/course-speakers
(Feld heißt mail, nicht email). Modus simulate (Fixtures) | live; der
Live-Weg nutzt phoenix_api_get_all wieder (X-Auth-Token, Meta-Pagination,
Tageszähler, 401-Backoff). Eigener Test-Guard (NEWSLETTER_TESTLAUF) riegelt
echte Aufrufe im Testlauf ab, unabhängig von phoenix.mode. Leere/ungültige
Adressen werden übersprungen und gezählt (kein Upsert ohne Schlüssel). Tests
decken Feldwahl je Quelle, Normalisierung, Übersprungen-Zählung und Guard ab.
[0.62.0] — 2026-07-28
- newsletter: Fundament des ersten Fachmoduls (NL-1). Neues Modul
app/modules/newsletter/ (routes/controllers/repository) mit den beiden
Pflicht-Hooks newsletter_open_tasks() / newsletter_dashboard_tiles(),
optionalem newsletter_nav() und einer Verbindungs-Übersicht
(/newsletter/verbindungen, Leerzustand). Migration 0021 legt die vier
newsletter_-Tabellen an (Verbindungen, Läufe, Aktionen mit E-Mail-Hash
statt Klartext, verschlüsselte Konfig) und seedet die zwei Modul-Rollen
newsletter.admin (verwalten+ausführen) / newsletter.operator (ausführen)
mit den Rechten newsletter.verwalten / newsletter.ausfuehren. Schema und
Migration deckungsgleich. Ziel des Moduls: Zielgruppen aus der
Mitgliederverwaltung (clubs/licenseholder/course-speakers) nach CleverReach
synchronisieren; Config-Block dafür ergänzt (config.example.php). Quell-
und Ziel-Clients, Sync-Engine, Zeitsteuerung und Verwaltungs-UI folgen in
NL-2…NL-8.
[0.61.9] — 2026-07-27
- fix: Bild-Aktionen im Profilkopf einheitlich gemacht. Das native
<input type=file> brachte einen abgeschnittenen „Keine Datei ausgewählt"-Text
und einen nicht stylbaren Button mit; die drei Aktionen waren unterschiedlich
groß. Jetzt versteckt (barrierefrei über das Label erreichbar), ein gestyltes
Label übernimmt das Auswählen, und „Auswählen"/„Hochladen"/„Entfernen" sind
gleich breit. Bei Dateiauswahl zeigt das Label den (gekürzten) Namen; ohne
JavaScript voll bedienbar.
[0.61.8] — 2026-07-27
Optimierungs-Durchlauf (Code-Review):
- Aufgeräumt: Drei verwaiste Views gelöscht (
home.php,login.php,
platzhalter.php — nie über view() geladen); 19 tote CSS-Regeln aus dem
alten Design entfernt (Deko-SVG, alte Topnav, alter Profilkopf); totes
Registry-Feld daten aus app/blocks.php.
- Flash zentral: Die ~20-fach kopierte Flash-Markup-Zeile durch den Helper
flash_render() ersetzt. Behebt zugleich das ARIA-Level — Fehlermeldungen
bekommen jetzt role="alert" (assertiv) statt role="status".
- Konsistenz: Button „2FA zurücksetzen" → „Anmelde-Code zurücksetzen"
(Label und Button hießen unterschiedlich).
[0.61.7] — 2026-07-27
- Profil aufgeräumt: Die Stammdaten-Blöcke zeigten die Überschrift doppelt
(Block-Kopf „Beim BTV" + interne „BEIM BTV") und ließen oben Leerraum durch
die Trenner-Optik. Neuer Kompakt-Modus: Wird ein Abschnitt als eigener Block
gerendert, fallen die interne Überschrift und die Trennlinie weg, der Inhalt
steht direkt unter dem Block-Kopf. Die leere Listen-Spalte (Konten ohne
Lizenzen) wird nicht mehr gerendert.
- Maillog-Icon von „mail" (Briefumschlag) auf „send" (Papierflieger)
geändert — es stand in der Kopfzeile neben dem gleichen Briefumschlag der
Nachrichten und war nicht unterscheidbar.
[0.61.6] — 2026-07-27
- F8: Anleitungs-Illustrationen für „Anmelde-Code einrichten" ergänzt — neun
selbst gezeichnete, generische SVG-Mockups (iPhone / Android / QR, je drei
Schritte) unter public/assets/hilfe/. Keine echten App-Screenshots, keine
fremden Logos; skalierbar und CSP-konform als <img>. Die Anleitung zeigt
jetzt Bilder statt Platzhalter.
[0.61.5] — 2026-07-27
Abnahme-Befunde Teil 3 (Layout + Registrierung), im Browser verifiziert:
- fix (T7):
.alertzerriss mehrteilige Meldungen. Durchdisplay:flex
wurde jedes Inline-Element (z. B. eine lange E-Mail in <strong>) zu einem
eigenen Flex-Item und in eine schmale Spalte gequetscht — der Satz brach in
Kolumnen (Passwort-vergessen-Bestätigung). Icon jetzt als Inline-Prefix, Text
fließt normal. Gilt für alle Alerts.
- fix (T8): info@-Panel lesbar.
.alertsetzte global fett — der dichte
Drei-Absatz-Hinweis stand komplett in Fettschrift. Absätze auf Normalgewicht,
<strong> bleibt zur Betonung.
- fix (F4): 2FA-Button „In Authenticator-App öffnen" ragte aus der schmalen
Setup-Spalte; jetzt vollbreit und umbruchfähig.
- fix (F6): keine doppelte Verify-Mail bei fast gleichzeitigem Doppel-Start.
Der automatische Erstversand läuft jetzt mit Cap 1 (statt 3) — die zweite,
leicht versetzte Registrierung wird gedeckelt. „Erneut senden" behält Cap 5.
- F7: Systemstatus zusätzlich als benannter Sidebar-Eintrag (war nur ein
Icon in der Kopfzeile und wurde im Test nicht gefunden).
[0.61.4] — 2026-07-27
- core (C7): Reply-To für ausgehende Mails (Migration 0020,
mails.reply_to).
Der Versand läuft technisch weiter über den Portal-Absender (Graph App-only),
aber Nachrichten aus der Verwaltung tragen jetzt ein Reply-To auf das
Prüfteam-Postfach (pruefteam_mail) — Antworten landen dort, wo der Vorgang
bearbeitet wird, statt bei info@. Reply-To wird wie die Empfängeradresse gegen
Header-Injection gehärtet; simulate/SMTP/Graph setzen es je passend.
[0.61.3] — 2026-07-27
Abnahme-Befunde Teil 2 (Funktions- und Textfehler):
- fix (F1): „E-Mail erneut senden" meldet ehrlich. Der Rückgabewert des
Versands wurde ignoriert — war der Stundendeckel erreicht, meldete die Seite
trotzdem „verschickt", und die Mail kam nie. Jetzt ehrliche Rückmeldung, wenn
gerade nichts gesendet werden konnte.
- fix (T3): Kontosperre nennt den Grund („gesperrt"/„deaktiviert") und bietet
einen echten Hilfe-Kontakt-Button statt „(Link unten)".
- T9: Notfall-Codes in der PDF nummeriert (1.–10.), erleichtert das Abhaken.
[0.61.2] — 2026-07-27
Usability-Fixes aus dem fachlichen Abnahme-Test (Teil 1, ABNAHME-BEFUNDE.md):
- fix (F2): Panels lassen sich wieder alle verschieben. Die Einfügelogik
beim Ziehen verglich erst y, dann x — sobald zwei Blöcke nebeneinander in einer
Reihe standen (breit + schmal), waren ihre y-Mitten fast gleich und das
Einsortieren sprang oder blieb aus. Jetzt „nächster Mittelpunkt" (euklidisch),
robust in jedem Raster.
- fix (T2): Doppelter Text auf „Anmelde-Code einrichten" entfernt — der
Payoff-Satz stand bei aktivem Gate zweimal.
- T1: Anmelde-Code-Text weg von „Honorare und Bankdaten" → „schützt Deine
persönlichen und sensiblen Daten"; am Step-up-Gate jetzt kontextkonkret
(„… bevor Du Deine E-Mail-Adresse änderst"), über neuen optionalen Grund in
require_twofa().
- T4: Fokus bleibt beim Rollen-Block. Nach einer Rollenvergabe sprang die
Seite an den Anfang; jetzt Anker zurück auf den Block.
- T5: Geburtsdatum wird beim Tippen automatisch punktiert (TT.MM.JJJJ),
rein unterstützend (funktioniert ohne JS weiter).
- T6: „Adresse korrigieren" auf der Warteseite abgesetzt (Abstand + Linie).
- T10: „Datei auswählen" im Profilkopf ans Design angeglichen.
- I1: Rollen in „Angefragt" und „Zugewiesen" getrennt.
[0.61.1] — 2026-07-20
- core: Rollen-Vorschlag aus der Mitgliederverwaltung wird endlich angezeigt.
phoenix_rollen_vorschlag() gab es seit v0.5x, aber kein einziger Aufrufer
ausserhalb der Tests — die Einstellung phoenix.rollen_mapping haette also
auch gesetzt nichts bewirkt. Die Kontoseite zeigt den Vorschlag jetzt im Block
„Rollen & Rechte" als Angebot mit einem Klick, samt Begruendung („dort gefuehrt
als …"). Bereits vergebene Rollen werden nicht erneut vorgeschlagen.
Weiterhin Vorschlag, nie Auto-Zuweisung (8.1).
- core: Neue Systemrolle
uebungsleiter(„Übungsleiter/in", Migration 0019).
Produktiv liefert license-holders 23 verschiedene license_type-Werte —
aber ausnahmslos Qualifikationen (ÜL C Allround-Fitness, Trainer C
Breitensport Gerätturnen …), keine Funktionen. Fuer den Vorschlag gab es damit
kein sinnvolles Ziel: „Referent/in" waere falsch (das kommt aus den
Kurs-Quellen), „Ehrenamt" ebenso. Die neue Rolle ist bewusst rechtelos und
nicht selbst waehlbar — sie sagt nur aus, dass eine Lizenz gefuehrt wird.
phoenix.rollen_mappingproduktiv gesetzt: alle 23 Lizenzarten →
uebungsleiter. Bewusst auf dem Server aus den echten Indexwerten erzeugt,
nicht abgetippt: Der Abgleich ist eine exakte Zeichenkettengleichheit, ein
einziges falsches Zeichen ergaebe still „kein Vorschlag". Ein Testfall haelt
genau das fest (abweichende Schreibweise → nichts; Rand-Leerzeichen → egal).
[0.61.0] — 2026-07-20
- core: Profil und Kontoseite komplett neu aufgebaut. Beide Seiten bestehen
jetzt aus einem Kopfbereich (wer ist das, was ist offen) und darunter aus
BLOECKEN, die sich jede Person selbst anordnen und zuklappen kann. Neu:
app/blocks.php (Registry), views/_bloecke.php (Huelle), je ein Teil-
Template pro Block, app/controllers/layout.php, Migration 0018
(user_block_layout).
- core: Module koennen eigene Bloecke beisteuern ueber den Hook
<modul>_profil_bloecke($ansicht, $ctx) — dieselbe Mechanik wie bei den
Dashboard-Kacheln. Modul-Bloecke bekommen zwingend den Modulpraefix, damit
kein Modul einen Core-Block ueberschreiben kann.
- Bedienung bewusst doppelt: Ziehen am Griff (Pointer Events, also auch auf
dem Tablet) UND Knoepfe „nach oben"/„nach unten" plus Klapp-Knopf als echte
Formulare. Ohne JavaScript funktioniert alles — nur mit Neuladen statt ohne.
Reines Drag & Drop waere fuer Tastatur und Screenreader unbedienbar
(WCAG 2.1.1); der Griff ist deshalb aria-hidden und erscheint erst, wenn das
Skript laeuft.
- fix: Zwei Karten steckten in der Rollen-Schleife. „Referenten-Daten" und
„Verknuepfung loesen" standen INNERHALB des <li> der Rollen-Liste und wurden
je Rolle erneut ausgegeben — bei drei Rollen dreimal, in ungueltigem Markup.
- fix:
_stammdaten.phpbelegte die Variable$teileim Sichtbereich der
einbindenden View. Ein so allgemeiner Name kollidiert frueher oder spaeter mit
einer View-Variablen (beim Bau der Vorschau ist genau das passiert) — jetzt
$sd_aktiv.
- fix: Anordnung per JS haette nie gespeichert. Das Skript sendete das
CSRF-Feld als csrf, csrf_field() erzeugt _csrf — jeder Speicherversuch
waere an der Pruefung gescheitert. Vom neuen Seiten-Test gefunden, nicht im
Betrieb.
- core: Kontoseite zeigt offene Aufgaben im Kopf und den Zustand als Chips
(Status, Pruefung erforderlich, Zuordnung unklar) — vorher musste man sich das
aus einer Tabelle zusammensuchen.
[0.60.4] — 2026-07-20
- core: Stammdaten neu geordnet. Vier gleich breite Spalten fuer sehr
ungleiche Inhalte: Der BTV-Bereich mit Lizenzen und Kurskategorien quoll
ueber, waehrend die meist leere Bankverbindung genauso viel Platz bekam.
Jetzt zwei Bloecke fuer die eigenen Angaben (Person, Adresse) und je ein
eigener Block fuer „Beim BTV" (mit den Listen) und die Bankverbindung.
- fix: Lizenznummer stand doppelt — einmal als Einzelfeld „Lizenz", einmal
als erster Eintrag der Lizenzliste. Das Einzelfeld erscheint nur noch, wenn
es keine Liste gibt.
- fix: Herkunftshinweis wiederholte sich sechsmal. „Aus der
Mitgliederverwaltung" stand unter jedem einzelnen Wert und war praesenter als
die Werte selbst. Stammen alle gefuellten Felder einer Gruppe aus derselben
Quelle, steht der Hinweis einmal unter der Ueberschrift; bei gemischter
Herkunft bleibt er am Feld, damit die Ueberschrift nichts Falsches behauptet.
- fix: Der Einleitungssatz war zerrissen —
.herkunftist ein Block und
stand als Beispiel mitten im Fliesstext, was den Absatz in drei Teile brach.
- core: Benutzerverwaltung zeigt dieselbe Ansicht. Stammdaten liegen jetzt
in views/_stammdaten.php und werden von Profil und Verwaltung gemeinsam
genutzt — zwei Kopien waeren auseinandergelaufen. In der Verwaltung fehlten
bisher Lizenzen, Kurskategorien und Geschlecht ganz; ohne Bearbeitungsrecht
standen dort nur drei Tabellenzeilen. Der Herkunftshinweis wird dort ebenfalls
ermittelt.
[0.60.3] — 2026-07-20
- fix: Kurskategorien wurden ermittelt und dann still verworfen. Die
Variable fehlte in der use-Liste der Transaktions-Closure — drinnen war sie
undefiniert, gespeichert wurde NULL. Nichts stürzte ab, die Übernahme
meldete Erfolg, und im Profil stand nichts. Der neue Testfall prüft nicht
mehr die Ermittlung, sondern das Ergebnis in der Datenbank; nur so fällt
diese Fehlerart auf.
- fix: Von mehreren Lizenzen blieb genau eine übrig.
license-holders
liefert eine Zeile je Lizenz, nicht je Person — wer drei hat, steht
dreimal in der Antwort, mit derselben Personennummer. Weil der Index über die
Personennummer schlüsselt, überschrieb bisher jede weitere Zeile die vorige;
welche Lizenz überlebte, entschied die Reihenfolge der Antwort. Beim
Zusammenführen werden Listen jetzt vereinigt statt ersetzt (Lizenzen über
Nummer bzw. Art entdoppelt), Einzelwerte weiterhin nur ergänzt.
- fix: Ein zweiter Kurs-Satz verlor seine Kategorien. Für dasselbe
Zusammenführen gab es zwei Regeln: Der Upsert-Weg vereinigte, der
Anreicherungs-Weg füllte nur Lücken — war schon eine Kategorie da, kamen die
übrigen nie an. Beide Wege nutzen jetzt dieselbe Funktion.
- core: Mehrere Lizenzen im Profil (
profiles.lizenzen, Migration 0017)
mit Art, Nummer und Gültigkeit; Kurskategorien und Lizenzen werden im Profil
angezeigt. lizenz_ref bleibt als führende Einzelangabe bestehen.
[0.60.2] — 2026-07-20
- fix: „Daten neu holen" fand nichts, obwohl Daten da waren. Die Regel „bei
mehr als einem Namenstreffer passiert gar nichts" schützt vor Namensvettern —
sie blockierte aber auch den häufigen Realfall, dass **dieselbe Person auf
mehrere Index-Einträge verteilt** ist: einmal als Lizenzinhaber mit
angereichertem Kurs-Satz, einmal als eigener Kurs-Satz, der sich nicht
zuordnen ließ.
Jetzt wird unterschieden: Stimmen bei allen Treffern **E-Mail und
Geburtsdatum** überein, ist es ein Mensch — die Einträge werden
zusammengeführt, der mit der echten Personennummer führt, und die
Kurskategorien beider werden vereinigt (sonst ginge die Hälfte verloren).
Unterscheiden sich die Merkmale, passiert weiterhin nichts. Zwei Testfälle
sichern beide Seiten ab.
[0.60.1] — 2026-07-20
- **fix: Beim Zusammenführen gingen die Zusatzangaben der zweiten Quelle
verloren.** Der Merge setzte nur das Quellen-Kennzeichen und behielt den
bestehenden Payload. Die Kurs-Quellen liefern aber Geschlecht und
Kurskategorien, die Lizenzinhaber-Quelle nicht — dadurch trug nach dem ersten
Lauf nur rund ein Drittel der Referenten diese Felder. Jetzt reichert der
Merge den Payload an: ergänzt wird ausschließlich, was fehlt; der zuerst
geschriebene Satz bleibt führend.
[0.60.0] — 2026-07-20
Referenten-Daten vollständiger übernehmen (Entscheidung 2026-07-20).
- Neu: Geschlecht und Kurskategorien aus der Referenten-Quelle
(Migration 0016). Die echte API-Antwort liefert deutlich mehr als die Spec
beschreibt — unter anderem sex, courses und **entgegen der Spec auch
birthdate**. Letzteres verbessert den sicheren Match für alle Sätze, in
denen es gesetzt ist.
- Geschlecht wird nicht geraten: Die Spec kennt 1/2/5, in den echten Daten
stehen aber auch 3 und 14. Unbekannte Codes werden gar nicht übernommen —
ein falsch zugewiesenes Geschlecht ist verletzend, eine fehlende Angabe nicht.
- Kurskategorien werden bereinigt: Die Quelle liefert Listen mit
null-Einträgen und Duplikaten (beides real belegt). Ungefiltert stünden
leere Zeilen und Wiederholungen im Profil.
- Anzeige: Kategorien als Liste im Profil (eine Person hat bis zu 13 davon —
als Fließtext unlesbar), Geschlecht bei den Personendaten, beides auch in der
Admin-Einsicht.
- Neu: Knopf „Daten neu holen" in der Verwaltung. Der reguläre Weg füllt nur
leere Felder — ändert sich in der Mitgliederverwaltung eine Anschrift, käme
sie dort nie an. Dieser Weg ersetzt auch belegte Angaben, **rührt aber
bestätigte Stammdaten nie an**: Die Bestätigung ist ein Nachweis mit Zeit und
IP (12.5.3), den eine Fremdquelle nicht im Vorbeigehen entwerten darf. Die
Identitätskennung setzt auch dieser Weg nicht.
[0.59.1] — 2026-07-20
- Rollenentzug löst jetzt auch den Verweis. Bisher blieb
referenten_id
stehen, wenn die Rolle „Referent/in" entzogen wurde — ein Verweis auf einen
Referenten-Datensatz an einem Konto, das keiner mehr ist. Die übernommenen
Stammdaten bleiben dagegen bewusst erhalten: Sie gehören inzwischen der
Person, sie hat sie möglicherweise bestätigt, und ein Konto ohne Anschrift
zurückzulassen wäre schlechter als eine Anschrift, die einmal aus der
Mitgliederverwaltung kam.
[0.59.0] — 2026-07-20
Rollenbezogener Abgleich für Referentinnen und Referenten (Entscheidung
2026-07-20). Wird die Rolle „Referent/in" bestätigt, holt das Portal aus den
Kurs-Quellen, was dort an Stammdaten steht.
- Vor- und Nachname genügen — bewusst schwächer als sonst, weil
course-speakers kein Geburtsdatum liefert: 286 Index-Einträge sind über
den sicheren Weg gar nicht auffindbar.
- Der Preis dafür sind drei harte Grenzen:
1. phoenix_person_id wird nie gesetzt. An dieser Kennung hängen
Stammdaten und später Honorare — ein Namensvetter ohne eigenes Konto würde
sie sonst unbemerkt erben. Die Referenten-Kennung landet stattdessen in
profiles.referenten_id.
2. Nur leere Felder werden gefüllt. Was jemand selbst eingetragen hat,
ist seine Aussage über sich.
3. Bei mehr als einem Namenstreffer passiert gar nichts — genau dort wäre
die Verwechslung wahrscheinlich.
- Die übernommenen Werte landen als Snapshot in
phoenix_matches, damit im
Profil der Hinweis „aus der Mitgliederverwaltung" erscheint. Ohne ihn stünden
fremde Angaben unkommentiert im Profil.
- „Ehrenamt" löst bewusst nichts aus: Für Funktionäre ohne Lizenz gibt es in
Phönix keine abrufbare Personenliste (offene Frage an it4sport). Lieber nichts
als geraten.
- Nebenbei:
repo_profile()liefertereferenten_idgar nicht mit — das Feld
existiert seit Beginn im Schema, war aber nirgends lesbar.
[0.58.7] — 2026-07-20
- Neu: Ende-zu-Ende-Test über die ganze Kette *Sync → Index → Abgleich →
Verknüpfung → Herkunftsanzeige*. Anlass: Sechs Fehler dieses Tages hatten
alle 293 Einzeltests passiert, weil jeder davon eine Funktion gegen
Fixtures prüfte — die Fehler saßen aber in den Übergängen. Der neue Test läuft
bewusst über den Index-Pfad, nicht über die simulate-Fixtures, und prüft
am Ende, dass die Verknüpfung wirklich in der Datenbank steht — nicht nur,
dass eine Erfolgsmeldung erschien. Er hätte vier der sechs Fehler gefunden.
Dazu zwei Gegenproben: Namensvetter → Klärfall statt Verknüpfung, leerer Index
→ fehler statt kein_treffer.
- Dafür eine Naht in
phoenix_mode(), die nur im Testlauf greift
(PHOENIX_TESTLAUF). Außerhalb der Tests bleibt der Modus reine
Konfiguration, und echte Netzaufrufe verhindert der bestehende Guard.
TESTPLAN-ABNAHME.md: Die überarbeitete Fassung übernommen und um fünf Fälle
ergänzt (zweistufige Stammdaten, Wiedereinstieg mittendrin, Vereinsauswahl,
nachträgliche Verknüpfung, Verbindung lösen, unterscheidbare Kandidaten).
Der Fall „Personennummer-Feld" ist entfallen — das Feld gibt es seit
v0.58.2 nicht mehr.
[0.58.6] — 2026-07-20
- fix: Eindeutige Treffer wurden nie verknüpft. Beim Index-Abgleich entstanden
die Kandidaten aus dem entschlüsselten Payload — und **phoenix_person_id
fehlte darin**. Folge: Die Registrierung speicherte den Treffer ohne
Verknüpfung (Vorbefüllung klappte trotzdem, deshalb fiel es nicht auf), und
„Jetzt verknüpfen" meldete danach „kein passender Eintrag" — obwohl der Treffer
mit Score 115 vorlag. Jetzt tragen Kandidaten die Kennung unter beiden Namen.
- Schritt 2 verlangte ein Geburtsdatum, das es dort nicht mehr gibt. Der Satz
„gib zusätzlich Dein Geburtsdatum ein" stammte aus der einstufigen Fassung —
seit Etappe 5 zweistufig ist, wird es in Schritt 1 erfragt. Text korrigiert.
- „Eindeutig ohne Kennung" wird nicht mehr als „kein Eintrag" verkauft. Tritt
dieser interne Widerspruch je wieder auf, landet der Fall in der Prüfung statt
in einer Unwahrheit für die Person.
[0.58.5] — 2026-07-20
- fix: Die erste Bestätigungsmail einer Registrierung ging oft gar nicht raus.
Der Versand-Deckel (B9) stand beim regulären Start auf **1 pro Stunde und
Adresse, während „erneut senden" 5** erlaubt — also genau verkehrt herum:
Der reguläre Weg war strenger als der manuelle Knopf. Wer sich innerhalb einer
Stunde ein zweites Mal registrierte, dessen Mail wurde **stillschweigend
verworfen**, während die Warteseite behauptete, sie sei unterwegs.
*(Mein erster Befund „nur Versandverzögerung" war falsch — er stützte sich auf
ältere Mails, die tatsächlich rausgingen.)*
Jetzt: Deckel beim Start 3 pro Stunde — gegen Mail-Bombing genauso wirksam
(der Unterschied zwischen 1 und 3 Mails ist kein Angriff, der zwischen 3 und
300 schon), aber ein Neustart nach Abbruch funktioniert wieder.
- Und vor allem: Der gedeckelte Versand ist jetzt sichtbar.
registrieren_verify_mail_senden() meldet zurück, ob eingereiht wurde; die
Warteseite sagt dann klar, dass vor Kurzem schon eine Mail an diese Adresse
ging — statt auf etwas warten zu lassen, das nie kommt. Vier Testfälle sichern
das Deckel-Verhalten ab.
[0.58.4] — 2026-07-20
- fix: Kurs-Quellen erzeugten einen zweiten Index-Eintrag. Die Feld-Abbildung
wich für address_nr auf id aus — course-speakers liefert aber eine eigene
UUID. Dadurch bekam derselbe Mensch zwei Einträge, die Zusammenführung lief
nie an, und aus einem eindeutigen Treffer wurde ein Klärfall. Jetzt gilt nur
address_nr als Personennummer; ob die Kurs-UUID überhaupt dieselbe Person
meint, ist bei it4sport offen. Zwei Testfälle.
- fix: Die Meldung nach „Jetzt verknüpfen" riet zu Unmöglichem. Sie bat um
Telefonnummer und Anschrift — Angaben, die entweder schon erfasst sind oder
sich im Profil gar nicht bearbeiten lassen. Jetzt drei ehrliche Fälle:
Abgleich gestört · nicht sicher entscheidbar (legt jetzt einen Klärfall an
statt in einer Sackgasse zu enden) · wirklich kein Eintrag.
- Prüfmaske zeigt E-Mail, Ort und Kennung. Zwei Kandidaten mit gleichem
Namen und Geburtsdatum waren sonst nicht unterscheidbar — dann ist die Maske
wertlos.
- Mail „Anmelde-Code eingerichtet" erklärt die Notfall-Codes — ausdrücklich
ohne die Codes selbst: Wer Zugriff aufs Postfach hätte, käme sonst an beide
Sicherheitsstufen, und genau davor soll der zweite Faktor schützen. (Technisch
ginge es ohnehin nur einmal — gespeichert sind sie nur gehasht.) Die Mail sagt
jetzt, dass es zehn Codes gibt, wo man sie herunterlädt und wie man im Profil
neue erzeugt.
[0.58.3] — 2026-07-20
- Warteseite nennt jetzt die Versanddauer. Das Portal verschickt nie sofort,
sondern reiht ein; der Cron stellt im Minutentakt zu. Ohne diesen Hinweis wirkt
jede Registrierung kaputt: Man schaut sofort ins Postfach, sieht nichts, drückt
„erneut senden" — dann kommen zwei Mails, und der Eindruck bleibt, dass nur die
zweite funktioniert hat. *(Nachgeprüft: Der Versand funktioniert, die
Bestätigungsmail ging 57 Sekunden nach dem Einreihen raus.)*
- **fix: Schaltflächen sahen unterschiedlich aus, obwohl dieselbe Klasse
draufsteht.** Die btn-Klassen setzten kein font-family — ein <button>
erbt die Schrift nicht (Browser geben Schaltflächen die System-Schrift), ein
<a> dagegen schon. Jetzt erben alle die Seitenschrift, haben eine gemeinsame
Grundlinie und eine Mindesthöhe je Stufe; .card-head bricht auf schmalen
Schirmen sauber um.
- fix: Auswahlfelder waren nur in
.formgestylt. In.form-inline
(Mail-Log-Filter, Benutzersuche) blieben sie im Browser-Standard und wirkten
neben den gestylten Feldern wie ein Fremdkörper. Jetzt einheitlich, mit einem
eingebetteten SVG-Pfeil — kein externer Abruf (CSP) und kein JavaScript.
[0.58.2] — 2026-07-20
- fix: Der Abgleich lief nach Schritt 1 nie wieder an. Schritt 1 setzte den
alten Abgleich auf offen zurück — das erzeugte einen unrettbaren Zustand:
Die Reservierung arbeitet mit INSERT IGNORE und kann eine bestehende Zeile
nicht neu übernehmen, und der Retry-Job greift nur Zeilen mit leerem
checked_at auf. Das Konto blieb still für immer offen, ohne Fehlermeldung.
Jetzt wird die Zeile gelöscht — eine bereits bestätigte Verknüpfung bleibt
dabei unangetastet (9.4). Zwei Testfälle.
- Feld „BTV-Personennummer" entfernt (Etappe 5, Schritt 1): Praktisch
niemand kennt seine Nummer. Ein optionales Feld, das fast alle leer lassen,
kostet nur Aufmerksamkeit und erzeugt das Gefühl, etwas nicht zu haben, was
man haben müsste. Das Scoring gewichtet address_nr weiterhin, falls die
Nummer später aus anderer Quelle kommt.
- Neu: Einsicht in die geladenen Daten unter
/verwaltung/mitgliederdaten — nur für echte Administratoren
(is_real_admin(), bewusst nicht system.view): Der Index enthält Daten
Dritter, die vom Portal nichts wissen; das geht auch Prüfteams nichts an.
Jeder Aufruf wird protokolliert. Gesucht wird über den verschlüsselten
Nachnamen, also nur exakt und ohne Teilwortsuche — der Index soll bewusst
keine durchsuchbare Adressliste des Verbands sein. Entschlüsselt wird nur,
was auf der angezeigten Seite steht.
[0.58.1] — 2026-07-20
- fix: Paginierung verlor unsere eigenen Parameter. Der Paginierer folgte
bevorzugt dem links.next der API — und diese URL trägt limit nicht mit.
Ab Seite 2 galt damit die Server-Vorgabe (25 statt 1000): Für 4.653 Vereine
brauchte es 187 statt 5 Anfragen, was in die Seiten-Obergrenze lief. Ergebnis
im ersten Live-Sync: official/clubs scheiterte, der Vereins-Cache blieb
leer, und 50 Aufrufe waren für nichts verbraucht.
Jetzt wird die Folgeseite selbst gebaut (aus meta.current_page), und nur
wenn kein brauchbares meta kommt, wird links.next genutzt — dann mit
wieder angehängtem limit. Vier Testfälle sichern beide Wege.
[0.58.0] — 2026-07-20
Letzte zwei Punkte des Umsetzungsauftrags plus zwei Fehler, die der
Praxiseinsatz aufgedeckt hat.
- Nachträgliche Verknüpfung (9.4): „Jetzt verknüpfen" im Profil, sichtbar
nur solange der Zugang nicht verbunden ist. Beim ersten Abgleich fehlten
vielleicht Merkmale oder der Index war leer — ohne diesen Weg bliebe jemand
dauerhaft unverknüpft. Kostet keinen API-Aufruf (rechnet gegen den Index),
und E2 gilt auch hier: verknüpft wird nur mit bestätigtem Geburtsdatum,
sonst wäre der Knopf ein Weg um die Regel herum.
- Nachfass-Mail aus der Prüfmaske (E7): einmalig (
nachfass_requested_at),
ohne Trefferdetails, mit klarem Hinweis auf Freiwilligkeit. Eine zweite Bitte
um dieselben Daten wirkt wie ein Mahnschreiben und bringt fachlich nichts.
Zwei Fehler behoben, die im Betrieb aufgefallen sind:
- Der Sync-Job hatte keine Tagesbremse. Die Job-Registry kennt keine
Intervalle — sie führt bei jedem Cron-Durchgang alle Jobs aus, und der Cron
läuft alle 1–2 Minuten. Sobald die Zugangsdaten vollständig gewesen wären,
hätte der Sync rund 12 Requests pro Durchgang gezogen: über 8.000 am Tag
gegen ein geteiltes Limit von 5.000 — unbemerkt, bis die Website ausfällt.
Jetzt phoenix.sync_abstand_stunden (Standard 24), Zeitstempel vor dem
Lauf gesetzt, damit ein Abbruch keinen Sofort-Neustart auslöst.
- Fehlende Konfiguration ist ein Zustand, kein Dauerfehler. Der Job meldete
bei jedem Durchgang einen Fehler — hunderte Mails am Tag, die echte Störungen
verdecken. Jetzt steigt er still aus und vermerkt den Grund einmal.
- Selfcheck: neue Zeile „Betriebsbereit" (auch im Modus
simulate— genau
dort will man vor dem Umschalten wissen, ob die Zugangsdaten reichen). Alte
Protokollzeilen erscheinen im simulate-Modus nicht mehr als ALARM, sondern
als historisch gekennzeichnete Info: Ein Alarm, den keine Handlung auflösen
kann, macht die ganze Seite unglaubwürdig.
status_notify.phpwird im Selfcheck jetzt explizit geladen — die
Zeitformatierung lief bislang nur deshalb nie ins Leere, weil das
Sync-Protokoll leer war.
[0.57.0] — 2026-07-20
Registrierungsstrecke der Phönix-Anbindung (Konzept 9.3, Auftrag §4).
- Etappe 5 ist zweistufig. Das ist keine Kosmetik: Der Abgleich lief bisher
vor der Stammdaten-Eingabe, also allein mit der E-Mail-Adresse — nach der
Regel „ein Merkmal reicht nie" könnte daraus nie ein eindeutiger Treffer
werden, es gäbe also nie eine Vorbefüllung. Jetzt: erst Name und
Geburtsdatum, dann abgleichen, dann die vorbefüllten Restfelder.
- Welcher Schritt dran ist, ergibt sich aus den Daten, nicht aus der
Session. Nur so funktioniert der Wiedereinstieg auf einem anderen Gerät
(6.3) — mit einem Session-Marker finge man am Handy wieder von vorn an.
- Optionale BTV-Personennummer mit Fundort-Hilfe („findest Du z. B. in
Schreiben des BTV … kannst Du leer lassen, wir finden Dich auch so"). Sie
wird nur für diesen einen Abgleich verwendet und nie als Kennung
übernommen — sie ist eine Behauptung, kein Nachweis.
- Nachfass-Stufe (E7): Nachgefragt wird nur, was noch fehlt, und nur wenn
es bei „wahrscheinlich" bleibt — nie nach bereits erfassten Angaben, und
immer mit der Möglichkeit weiterzugehen.
- Vereins-Selbstauswahl (9.2a): Ein eindeutig erkannter Verein wird
zugeordnet, ein unbekannter blockiert die Registrierung nicht. Sie darf nie
an einer Vereinsschreibweise scheitern.
- Zustimmungssatz zum Abgleich in Etappe 1 (E6) — angekündigt wird er dort,
wo eingewilligt wird, nicht erst, wenn er schon gelaufen ist.
- Geburtsdatum-Prüfung: deutsche und ISO-Schreibweise, nichts Unmögliches
(kein 32., kein zweistelliges Jahr, nichts in der Zukunft).
TESTPLAN-ABNAHME.mdum fünf Fälle ergänzt (C3a–C3e).
[0.56.0] — 2026-07-20
Verwaltungs-Teil der Phönix-Anbindung (Konzept 9.3/9.4, Auftrag §4).
- Entknüpfung als Verwaltungsaktion mit Pflicht-Begründung, Audit
(phoenix.unlinked) und Purge des Snapshots. Der Snapshot enthält die
Stammdaten der fremden Person — und genau deren Zuordnung war ja der Fehler
(Löschklasse 12.5.4). Die bereits ins Profil übernommenen Werte bleiben
dagegen stehen: Sie zu leeren würde die Person aus ihrem eigenen Konto
aussperren. Sie gelten danach als selbst erfasst, weshalb auch der
Herkunftshinweis von allein verschwindet — ein Test prüft beides.
Die Person wird neutral informiert, ohne Trefferdetails.
- Prüfmaske zeigt jetzt, WORAN ein Kandidat passt, nicht nur wie gut:
Spalte „Passt bei" mit den getroffenen Merkmalen plus Punktzahl. Eine nackte
Punktzahl ist für die Entscheidung wertlos — „Geburtsdatum, Telefon" sagt,
ob man ihr trauen kann. Die Option „ohne Zuordnung freigeben" heißt jetzt
„Treffer unzutreffend".
- Externer Label-Satz (9.3): Nach außen zählt nur, ob die Verbindung steht
(„mit der Mitgliederverwaltung verbunden" / „noch nicht … verknüpft" /
„Verbindung wird noch geprüft"). Die internen Zwischenstufen wahrscheinlich
und mehrdeutig bleiben intern — sie zu zeigen verunsichert nur und verrät
nebenbei, dass es mehrere ähnliche Personen gibt.
[0.55.0] — 2026-07-20
- Eigener Selfcheck-Bereich „Phönix" (9.2b/Anhang A): Konfiguration (Modus,
Basis-URL je Modul, Key gesetzt ja/nein — nie der Wert, Pepper,
Rückschreib-Schalter), Status je genutzter API, Match-Index, Tageszähler und
Vereins-Cache, jeweils mit Ampel.
Der Bereich ruft die Schnittstelle nicht auf — alle Angaben stammen aus
dem Protokoll des Sync-Laufs. Sonst würde ausgerechnet das Nachsehen, warum
das Tageskontingent alle ist, weiteres Kontingent verbrauchen. Ein Test
prüft das über den Zählerstand.
- Button „Anbindung jetzt prüfen": POST hinter
system.view, höchstens
alle 15 Minuten, je Schnittstelle genau ein limit=1-Request. Ein
ungedrosselter Prüfknopf auf einer Statusseite wäre eine Einladung, das
Kontingent per Klick zu verbrauchen. Im Modus simulate prüft er stattdessen
die Fixtures — sonst meldete die Seite „alles gut" für eine Anbindung, die
gar nicht existiert.
- Ein leerer oder überalterter Match-Index wird als Alarm gemeldet: Er
bedeutet, dass jeder Abgleich fehler liefert.
[0.54.0] — 2026-07-20
Phönix-Anbindung nach PHOENIX-UMSETZUNG-fuer-Code.md (Konzept 9.2/9.2b) —
Fundament, Client und Sync-Lauf. Wizard-Strecken und Selfcheck folgen.
- Der Abgleich macht keinen API-Aufruf mehr. Bisher hätte jede
Registrierung die Personenliste gezogen — rund 12 Requests pro Vorgang.
Beim Start des Portals sprengt das das mit Website und Reporting geteilte
Tageslimit von 5.000, und die Person wartet minutenlang. Jetzt baut ein
täglicher Sync-Lauf einen lokalen Match-Index (~12 Requests am Tag), der
Abgleich rechnet dagegen.
- Der erfundene Endpunkt ist raus.
/persons?email=existierte in keiner
it4sport-Spec. Ein Test hält fest, dass weder Funktion noch Pfad zurückkommen.
- Scoring statt E-Mail-Suche (E4): Normalisierung (Umlaute deutsch, also
ü→ue; Bindestriche; zweiter Vorname optional; Telefon-Länderkennung),
Gewichte und Schwellen als settings. „Eindeutig" verlangt mindestens ein
*sehr starkes* Merkmal und mindestens zwei Merkmale insgesamt — eine
übereinstimmende E-Mail allein verknüpft nie, weil geteilte Postfächer real
sind. Zwei gleich gute Kandidaten sind mehrdeutig, nicht wahrscheinlich.
- E2: Verknüpft wird erst, wenn das selbst eingegebene Geburtsdatum den
Kandidaten bestätigt.
- Der Index enthält keine Klartext-Merkmale (12.5.4, Daten Dritter):
Vergleich über Hashes mit einem Pepper aus der config.php (nicht aus der
Datenbank — sonst wären sie mitsamt der Tabelle rückrechenbar), Payload
sodium-verschlüsselt, entschlüsselt wird nur der Treffer. Ein Test prüft, dass
nichts im Klartext in der Tabelle landet.
- Lebenszyklus: rollierender Ersatz, aber nur wenn mindestens eine Quelle
geliefert hat — sonst löschte ein Ausfall den Index und legte den Abgleich für
alle lahm. Nach mehr als sieben Tagen ohne Sync wird er hart gelöscht; der
Abgleich liefert dann fehler, nie kein_treffer („wir konnten nicht prüfen"
ist etwas anderes als „es gibt dich nicht").
- Dedup über die Quellen:
course-speakersliefert weder Personennummer
noch Geburtsdatum. Ohne Zusammenführung stünde dieselbe Person zweimal im
Index und ein eindeutiger Treffer würde zum Klärfall. Bei mehreren möglichen
Bezügen wird bewusst nicht zusammengeführt — zwei Personen an einem
geteilten Postfach dürfen nicht verschmelzen.
- Plausibilisierung bestehender Verknüpfungen bei jedem Lauf (Stabilität von
address_nr ist unbestätigt): Abweichung erzeugt einen Prüfhinweis, nie eine
automatische Korrektur.
- Betrieb: Basis-URL je Modul, Tageszähler über jeden Versuch, 401-Backoff
bis Tageswechsel, Sync-Protokoll je Quelle (Grundlage des Selfcheck-Bereichs),
Vereins-Cache für die Selbstauswahl im Wizard.
- Guard: Der Testrunner setzt
PHOENIX_TESTLAUF; der Client verweigert
dann jeden Aufruf, sobald der Modus nicht simulate ist.
- Migration 0015 (idempotent). Neu nötig:
phoenix.index_pepperin der
config.php — einmal setzen, danach nie ändern.
[0.53.1] — 2026-07-20
Nachbesserung zu 0.52.1 — beide Punkte waren Folgen meiner eigenen Lösung.
- Tabellen füllen jetzt immer die verfügbare Breite. Der Scroll lag auf der
Tabelle selbst (display:block; overflow-x:auto). Dadurch wurde die innere
Tabellenbox „shrink-to-fit" und blieb schmaler als der Platz. Der Scroll
liegt jetzt in einem Container um die Tabelle (.tabelle-scroll), die
Tabelle selbst hat width:100%; min-width:100%.
- Keine senkrecht stehenden Wörter mehr auf schmalen Schirmen.
overflow-wrap:anywhere senkt die min-content-Breite auf ein einzelnes
Zeichen — im automatischen Tabellenlayout durfte eine Spalte dann auf
Buchstabenbreite kollabieren. Jetzt break-word (trennt erst beim
tatsächlichen Überlauf und füllt die Zeile) plus min-width:5rem je Zelle.
Weil sich diese Mindestbreite über die Spalten summiert, wächst der
Platzbedarf automatisch mit der Spaltenzahl: schmale Tabellen passen weiter
auf den Schirm, breite lösen den Scroll des Containers aus.
- Auf schmalen Schirmen zusätzlich etwas kompakter (Schriftgröße, Innenabstand).
[0.53.0] — 2026-07-20
Die Seite ist öffentlich erreichbar (Basic-Auth bewusst entfernt) — damit
brauchen die Rechtstexte einen belastbaren Weg.
- **Impressum und Datenschutz verweisen auf die zentral gepflegten Seiten des
Betreibers.** Doppelt gepflegte Rechtstexte laufen auseinander, und die
veraltete Fassung ist dann die angreifbare. Die Adressen sind Texte
(impressum_url, datenschutz_url) und unter *Einstellungen → Texte*
änderbar — der Core bleibt damit white-label-fähig (14.9/O18). Ist ein Feld
leer, rendert das Portal wieder seinen eigenen Text.
/impressumund/datenschutzleiten weiter, wenn eine Adresse
hinterlegt ist. Ohne das bliebe unter der alten Route eine leere Seite
erreichbar — genau der rechtlich heikle Fall, den Lesezeichen und
Suchmaschinen zuverlässig finden.
- Der Wert wird streng geprüft, bevor er in einem
Location-Header landet:
nur http/https, keine Steuerzeichen. Die Steuerzeichen-Prüfung läuft auf
dem Rohwert — trim() würde ein führendes \r\n still entfernen und die
Prüfung ins Leere laufen lassen.
- Ein ausdrücklich geleertes Feld bedeutet „kein Verweis" und fällt nicht
auf die Vorbelegung zurück (sonst ließe sich der Verweis nie entfernen).
- Die Erklärung zur Barrierefreiheit bleibt im Portal — sie ist abgenommen
und beschreibt diese Anwendung; sie wird bei Änderungen fortgeschrieben.
- Externe Rechtstext-Links öffnen in einem neuen Tab, damit eine laufende
Registrierung nicht verloren geht; für Screenreader wird das angesagt
(neue Klasse .visually-hidden) statt stillschweigend ein Fenster zu öffnen.
README-DEPLOY.mdbeschrieb ein „flaches Layout", das der Server seit
Längerem nicht mehr hat. Jetzt am verifizierten Ist-Stand: Repo- und
Server-Pfade sind 1:1 gleich, inklusive Sicherungs-, Reihenfolge- und
Verifikationsregeln.
[0.52.1] — 2026-07-20
- fix: Tabellen brachen auf schmalen Bildschirmen nicht um. Die
Mobil-Regel setzte auf Tabellen pauschal white-space:nowrap — der Rahmen
war also scrollbar, der Inhalt aber auf je eine Zeile gezwungen. Aus einer
harmlosen Tabelle wurde endloses Horizontal-Scrollen. Jetzt brechen Zellen
um (overflow-wrap:anywhere, das senkt auch die min-content-Breite — nur
dadurch kann eine Tabelle überhaupt schmaler werden als ihr längstes Wort);
das Scrollen bleibt als Sicherheitsnetz für sehr breite Tabellen. Für
Zellen, in denen ein Umbruch schadet (Datum/Uhrzeit), gibt es die Klasse
.nowrap — gesetzt im Mail-Log und bei den Notfall-Codes. Nebenbei: Der
Selektor enthielt .grid, eine Klasse, die es nicht gibt und die keine
View benutzt.
[0.52.0] — 2026-07-20
Drei Korrekturen aus dem Praxiseinsatz.
- fix: SSO-Konten bekamen
<oid>@sso.invalidstatt der echten Adresse.
Die E-Mail wurde nur übernommen, wenn Entra sie als email_verified meldet —
Entra ID sendet dieses Claim für Arbeitskonten aber gar nicht. Die
Bedingung war damit immer unwahr, jedes per SSO angelegte Konto trug die
Platzhalter-Adresse. Der Fehler war, ein *fehlendes* Claim wie ein
*verneinendes* zu behandeln. Jetzt gilt: email-Claim, sonst der UPN aus
preferred_username/upn — beides ist tenant-verwaltet, und der Token ist
per tid-Prüfung nachweislich aus dem eigenen Tenant, Gäste sind ausgeschlossen.
E004 bleibt unangetastet: Meldet Entra email_verified ausdrücklich als
false, wird weiterhin nichts übernommen. Bestehende Konten heilen sich beim
nächsten SSO-Login selbst. Drei Testfälle ergänzt.
- 2FA-Einrichtung: „Später einrichten" steht jetzt oben und gleichwertig
(7.6). Der Aufschub war ein blasser Ghost-Button *unter* der kompletten
Anleitung — wer sich überfordert fühlt, sieht ihn also genau dann nicht, wenn
er ihn braucht, und bricht stattdessen ab. Jetzt zwei gleich gerahmte
Optionen direkt unter der Einleitung.
- Stammdaten zeigen ihre Herkunft (13.5): Werte, die noch unverändert aus
dem Abgleich stammen, tragen den dezenten Zusatz „aus der
Mitgliederverwaltung". Kein Herkunfts-Flag in der Datenbank, sondern ein
Vergleich mit dem Snapshot — ein Flag würde beim ersten Bearbeiten lügen.
Systemnamen bleiben draußen; sechs Testfälle sichern das ab.
[0.51.0] — 2026-07-19
Entscheidung zu geteilten Postfächern (B10/6.5a): kein Verbot — Texte angepasst.
- Entscheidung des BTV vom 19.07.2026: Sammeladressen bleiben zulässig. Begründung, die meine ursprüngliche Empfehlung („persönliche Adresse verbindlich") widerlegt: Eine Sperre könnte nur am Präfix ansetzen — und das beschreibt die Schreibweise der Adresse, nicht die Zahl der Personen dahinter. Ein Vereinsvorstand, der
vorstand@allein nutzt, und eine freiberufliche Übungsleitung mitinfo@haben ein vollständig privates Postfach. Ein Verbot hätte genau diese Einzelpersonen ausgeschlossen — ohne jeden Sicherheitsgewinn, dafür ohne Zugang zum Portal. - fix: Der Hinweistext behauptete eine Pflicht, die es nicht gibt. Er sagte „Für dieses Portal brauchst Du eine persönliche Adresse" — nach dieser Entscheidung schlicht falsch, und für einen Freiberufler mit eigener
info@eine unnötige Verunsicherung. Neu als Frage formuliert („Nutzt noch jemand dieses Postfach?") mit dem ausdrücklichen Zusatz „Wenn sie nur Dir gehört, ist alles in Ordnung — dann mach einfach weiter." Die Begründung (Honorare, Reset-Wege) steht weiterhin dabei, für die, die tatsächlich teilen. - Der Prüfhinweis in der Freigabe-Queue heißt jetzt „Sammeladresse?" statt „evtl. Sammelpostfach" und erklärt im Titel, dass es kein Mangel ist — nur ein Anlass zum Nachfragen. Die Bestätigungsmail war bereits bedingt formuliert („Teilst Du dieses Postfach mit anderen? Dann…") und blieb unverändert.
- Konzept 6.5a von „offene Produktentscheidung" auf entschieden umgeschrieben, mit der tragenden Begründung und dem bewusst getragenen Restrisiko (Anhang B): Wer sein Konto auf einem tatsächlich geteilten Postfach anlegt, trägt das Risiko — das Portal weist vorher darauf hin, die Entscheidung liegt bei der Person, die als Einzige weiß, wer Zugang zu ihrem Postfach hat.
- Kein Settings-Schalter (unverändert): Sollte der BTV die Entscheidung je revidieren, ist das eine bewusste Code-Änderung mit Freigabe — kein Häkchen, das später jemand unbemerkt umlegt.
- Betrieb: Team-Postfach auf
portal@btv-turnen.degesetzt (Klärfall-Bericht + Antworten aus dem Portal). Das Portal schickt damit an sich selbst — unproblematisch, da es keine Mails liest; gesendete Mails werden nicht abgelegt (saveToSentItems: false). - 227 Tests grün; der Fall „Vorstand registriert sich mit
vorstand@" live durchgespielt: Konto wird angelegt, Mail geht raus, Hinweis ist eine Frage statt einer Hürde.
[0.50.0] — 2026-07-18
Öffentliche Hilfe-Adresse und internes Team-Postfach getrennt.
- fix: Das Portal schickte Betriebsmeldungen an die öffentliche Hilfe-Adresse. Antwortete jemand auf eine Portal-Nachricht, ging die Benachrichtigung an
help_email()— also aninfo@btv-turnen.de, die Adresse aus Footer, FAQ und jeder Mail-Signatur. Das wirft zwei verschiedene Dinge zusammen: Die eine ist das, was Nutzende anschreiben sollen, die andere das Postfach, in dem das Team gestupst wird. Solange das Portal nicht offiziell läuft, bekam die BTV-Geschäftsstelle damit Meldungen zu einem System, das sie noch gar nicht kennt. - Neu
team_mail(): Interne Benachrichtigungen (Klärfall-Bericht und Antworten aus dem Portal) gehen an das Team-Postfach (pruefteam_mail), nie an die öffentliche Adresse. Musternotify_toaus dem Referenzprojekt. Es geht ab sofort keine einzige automatische Mail mehr an die Hilfe-Adresse — sie ist reine Anzeige. - Ohne Team-Postfach unterbleibt die Mail — die Antwort ist im Portal sichtbar und geht nicht verloren, es wird nur niemand angestupst. Der Selfcheck sagt das jetzt ausdrücklich, statt es stumm geschehen zu lassen; die neue Zeile „Öffentliche Hilfe-Adresse" hält fest, dass dorthin nichts geschickt wird.
- fix (beim Fristen-Prüfen gefunden): Die SLA-Zahl war nicht pflegbar, der Text schon.
sla_klaerfall_werktagerechnet die konkreten Termine („eingegangen am 16.07. — Antwort bis 23.07."), stand aber nicht in der Oberfläche; editierbar war nur der Rückfall-Text. Wer ihn auf „3 Werktage" ändert, während die Rechnung bei 5 bleibt, lässt das Portal an zwei Stellen Verschiedenes versprechen. Beide Felder stehen jetzt nebeneinander, mit Hinweis auf ihren Zusammenhang. - Beschriftungen in den Einstellungen geschärft: „Öffentliche Hilfe-Adresse (nur Anzeige)" vs. „Team-Postfach (Klärfall-Bericht + Antworten aus dem Portal)".
[0.49.0] — 2026-07-18
Spam-Schutz für das öffentliche Registrierungsformular + Gmail-Dubletten.
Anlass: eine Registrierung mit dem Gmail-Punkt-Trick (b.al.la.kl.i.1.997@gmail.com). Kein Schaden — ein unbestätigter Rumpf ohne Passwort, der nach 72 h von allein verschwindet, keine Dubletten, keine Massenanlage. Aber er hat gezeigt, dass unser einziges öffentliches Formular völlig ungeschützt war, während das Referenzprojekt (Akkreditierung) Honeypot, Zeitfalle und Rate-Limit hat. CLAUDE.md nennt es ausdrücklich als Vorlage — übernommen wurde es nie.
- Honeypot + Zeitfalle (Muster Akkreditierung, Konzept 2.2): ein verstecktes Feld, das nur Bots ausfüllen, und eine Mindest-Ausfüllzeit. Bei Verdacht erscheint die normale Warteseite — ein Bot, der eine Fehlermeldung bekäme, lernt daraus, welche Regel er verletzt hat. Kein Konto, keine Mail. Das Feld ist
aria-hiddenundtabindex="-1", damit Screenreader und Tastatur nie hineingeraten. - Rate-Limit je IP-Bereich — bewusst großzügiger als die Vorlage (20/h statt 5) und mit ehrlicher Meldung. Hier ist der Unterschied zum Honeypot entscheidend: Ein gefülltes Honeypot-Feld ist sicher ein Bot; ein erreichtes Limit ist es nicht — dahinter können ein Verein im WLAN oder ein Mobilfunknetz mit tausenden Anschlüssen stecken. Wer still abgewiesen würde, wartete auf eine Mail, die nie kommt. Eigene Tabelle
form_attemptsstattlogin_attempts: Ein Registrierungsversuch darf niemanden vom Login aussperren. Aufbewahrung 24 h, Cleanup protokolliert. - Gmail-Punkt-Trick geschlossen (Migration 0014): Gmail ignoriert Punkte und alles ab
+—b.all@gmail.comundball@gmail.comsind dasselbe Postfach, für unsere UNIQUE-Spalte aber zwei Konten. Damit ließen sich aus einem Postfach beliebig viele Konten anlegen. Neu vergleichtemail_kanonisch()— ausdrücklich nur für Gmail: Bei anderen Anbietern sindvor.name@firma.deundvorname@firma.dewomöglich zwei verschiedene Menschen, die zusammenzuwerfen wäre schlimmer als das Problem. Gespeichert und angeschrieben wird immer die Adresse wie eingegeben. - Im eigenen Test gefunden (1): Die Dubletten-Prüfung verhinderte das zweite Konto — aber es ging trotzdem eine zweite Mail raus, weil an die *getippte* Variante statt an die Adresse des Kontos verschickt wurde. Der Versand-Deckel (B9) zählt je Zeichenkette und griff nicht. Genau die Verstärkung, die der Schutz verhindern sollte. Jetzt geht jede Mail an die Adresse des Kontos; drei Varianten ergeben live ein Konto und eine Mail.
- Im eigenen Test gefunden (2):
repo_user_by_email_kanonisch()hatte eine Abkürzung („ändert die Kanonisierung nichts, lohnt die Suche nicht"). Falsch herum gedacht — wer sein Konto mit Punkten angelegt und ohne getippt hat, wäre nicht gefunden worden: der Aussperr-Fall, den die Funktion verhindern soll. Abkürzung raus, Gegenprobe grün. - Folgerichtig mitgezogen: „Passwort vergessen" und „Anmelde-Code zurücksetzen" finden Gmail-Varianten jetzt ebenfalls — sonst wäre eine neue Sackgasse entstanden: Der Login findet das Konto, der Reset nicht, und die Person wartet auf eine Mail, die nie kommt.
- Kennzahl „Automatisierte Versuche abgewehrt" unter
/kennzahlen; beide Schwellen unter *Einstellungen → Schutz des Registrierungsformulars* pflegbar (gekappt, damit niemand den Schutz versehentlich abschaltet). - 12 neue Tests (Gmail-Varianten fallen zusammen · andere Anbieter NICHT · Honeypot · Zeitfalle in beide Richtungen · Rate-Limit je Formular/IP/Zeitfenster · Aussperr-Fall), 227 Tests grün. Alle vier Bot-Wege und der Menschen-Weg live durchgespielt.
[0.48.0] — 2026-07-16
Mail-Log: Fälle abhaken · Benutzerliste: Rollen auf einen Blick.
- Gescheiterte Mails lassen sich als erledigt markieren (Migration 0013). Ein endgültig gescheiterter Versand ist ein echter Vorfall und muss auffallen — aber er bleibt für immer im Bestand. Ohne ein „erledigt" stünde die Warnung dauerhaft, und eine Warnung, die immer leuchtet, sieht bald niemand mehr an: Genau so übersieht man die nächste echte. Warnbanner und Badge zählen jetzt nur noch offene Fälle; nach dem Abhaken bleibt eine ruhige Zeile („3 gescheiterte E-Mails im Log — alle als erledigt markiert").
- Gelöscht wird nichts. Der Eintrag bleibt samt Fehlergrund stehen, mit Zeitpunkt und Person der Klärung — der Vorfall bleibt nachvollziehbar, und die Anonymisierung nach Frist (12.5.4) räumt ihn ohnehin. „Doch nicht"-Rücknahme je Zeile, Sammel-Abhaken für den Regelfall (nach einer Störung sind es selten einzelne).
- Neuer Filter „gescheitert, offen" neben „gescheitert (alle)" — die Arbeitsliste getrennt vom Archiv.
- Benutzerverwaltung: neue Spalte „Rollen" — kompakt als Badges. Bestätigt und angefragt sind optisch getrennt (letztere mit „?" und Erklärung im Titel): Eine selbst gewählte, unbestätigte Rolle wirkt nicht (8.1) — sie gleichwertig anzuzeigen würde vortäuschen, der Fall sei erledigt.
- Die Rollen kommen aus derselben Abfrage (
GROUP_CONCAT), nicht aus einer Zusatzabfrage je Zeile — bei 100 Treffern wären das sonst 100 Extra-Abfragen für eine Spalte. Trennzeichen ist U+001F: Ein Rollen-Label könnte ein Komma enthalten, ein Steuerzeichen nie. - 5 neue Tests (Abhaken beendet die Warnung, lässt aber Status und Grund stehen · zweimal abhaken ändert nichts · Rücknahme · nur gescheiterte Mails sind abhakbar · Sammel-Abhaken · Rollen getrennt, genau eine Zeile trotz
GROUP BY), 216 Tests grün.
[0.47.0] — 2026-07-16
Löschkonzept: Übernahme der DSB-Vorgabe aus dem Akkreditierungsprojekt (2026-07-15).
Nicht übernommen: die Fristen. Jenes Verfahren kennt keine Finanzdaten und darf nach sechs Monaten alles löschen. Hier gilt § 147 AO — wer die sechs Monate überträgt, vernichtet steuerrelevante Belege. Übernommen sind die Muster.
- fix: Unsere elf Cleanup-Jobs löschten wortlos. Auf die Frage „läuft eure Löschung überhaupt?" gab es nur ein Achselzucken — eine Löschpflicht, die man nicht nachweisen kann, ist gegenüber dem Datenschutzbeauftragten wertlos. Jeder Job meldet jetzt seine Zeilenzahl ins Audit (
aufbewahrung.<job>,{"zeilen":14}, Akteursystem/cron) — nur Zahlen, nie wer oder was. - Mails werden anonymisiert statt gelöscht (DSB-Muster): Empfänger, Betreff und Inhalt raus, die Zeile bleibt. Datenminimierung heißt nicht „alles weg", sondern „nur das mit Personenbezug". Der Gewinn ist doppelt: Das gestern gebaute Mail-Log behält seinen Zustellnachweis („am 16.07. ging eine Bestätigungsmail raus, zugestellt nach zwei Versuchen"), und der Personenbezug verschwindet trotzdem vollständig. Löschen hätte beides zerstört.
- Fristen sind Konfiguration, nicht Code (
aufbewahrung_tage(), Felder unter *Einstellungen → Aufbewahrung & Löschung*). Die O7-Freigabe soll eine Eingabe sein, kein Deploy. Hart gekappt (1–3650 Tage, Konten mind. 30) — ein Tippfehler darf keine Daten vernichten. - Startwerte (Entscheidung 2026-07-16): deaktivierte Konten 1825 Tage (5 Jahre) · Mail-Historie 365 · Zugriffsprotokoll 90 · Kennzahlen 1825 statt bisher 730 — anonyme Aggregate haben keine Löschpflicht, und Betriebsberichte brauchen den Mehrjahresvergleich.
- Neu: Konten-Pseudonymisierung (12.5.4, Migration 0012). Dafür fehlte die Grundlage:
users.account_status_atgab es nicht — die Frist hätte ab gar nichts laufen müssen (oder abregistered_at, was ein gestern deaktiviertes Konto sofort vernichtet hätte). Entfernt wird der Personenbezug, nicht der Datensatz:user_id/public_idbleiben, weil daran Abrechnungen hängen (§ 147 AO, bis 10 Jahre). - Bewusst konservativ — und das ist der teure Teil: Nicht angefasst werden gesperrte Konten (eine Sperre ist vorübergehend), Konten ohne bekannten Wechselzeitpunkt (Bestand — lieber einer zu lange als einer zu früh) und Konten, die noch Rollen tragen (Hinweis auf verbleibende Bindung). Vier Tests sichern genau diese Ausnahmen.
- Für Module verbindlich (
app/modules/README.md): Zweistufigkeit ab Fachereignis — Artefakte früh, Vorgang spät. Bei Honorarabrechnungen kann der Beleg weg, während der Vorgang zehn Jahre bleibt. Plus: anonyme Aggregate übermetricssichern, anonymisieren schlägt löschen,cleanup_job()protokolliert automatisch, Fristen nie als Konstante. - Konzept 12.5.4a: die Übernahme dokumentiert — inklusive der Ansage, dass Projekt29 für O7 nur noch vier Zahlen nennen muss, alles andere ist gebaut und getestet.
- 12 neue Tests, 211 Tests grün.
[0.46.0] — 2026-07-16
Mail-Log mit eigener Rolle — und der Fehler, der die Diagnose unmöglich machte.
Anlass: ein echter Vorfall. Der Versand lief am 15.07. nachweislich (Mails auf sent); seit dem 16.07. um 16:59 scheitert alles, ein Nutzer registrierte sich und bekam nichts. Die Ursachensuche scheiterte an unserem eigenen Code.
- fix: Der Grund eines Fehlversands ging verloren.
mail_send_now()gibt nurtrue/falsezurück — Microsofts Antwort landete ausschließlich im PHP-Error-Log, also in der Cron-Mail an eine Adresse, die selten jemand liest. In der Datenbank stand „Versand fehlgeschlagen (Modus graph)". Das ist keine Diagnose, sondern eine Kapitulation: Ob das Client-Secret falsch ist, die Access-Policy blockt oder der Absender nicht existiert, sind drei verschiedene Probleme mit drei verschiedenen Lösungen. Jetzt landet Microsofts echte Fehlerkennung inlast_error(ErrorAccessDenied,AADSTS7000215, …). - Neu:
/mail-log(Muster Digitalportal) — welche E-Mails das Portal verschickt hat, ob sie ankamen, wie viele Versuche liefen, wann der nächste ist. Zu jedem Fehler steht Microsofts Originaltext und daneben, was er bedeutet und was zu tun ist („Das hinterlegte Client-Secret stimmt nicht — vermutlich rotiert und nicht nachgetragen"). Filter nach Status, Suche über Empfänger und Betreff. Warnbanner bei endgültig gescheiterten Mails, samt Konsequenz: Wenn es Bestätigungslinks waren, müssen die Betroffenen sie neu anfordern. - Eigene Rolle „Mail-Einsicht" (Migration 0011, Recht
mails.view): Wer den Versand beobachtet, muss keine Konten verwalten dürfen. Getestet:mail_einsichtsieht das Log und weder Benutzerverwaltung noch Einstellungen noch Zugriffsprotokoll.adminundgs_adminbekommen das Recht mit; nur ein Admin darf es vergeben (8.2), und es trägt das 2FA-Gate (7.6) — das Log zeigt Personendaten. - Kein Mail-Inhalt, strukturell: Die Abfrage liefert
body/body_redacted/secrets_encnicht — ein Test prüft das, statt dem Kommentar zu glauben. Die Bestätigungs- und Reset-Links darin sind Geheimnisse (B8), und der Text einer Mail an eine Person geht auch die Verwaltung nichts an. Das Log beantwortet „ist sie rausgegangen und warum nicht", nicht „was stand drin". - Fehlerzahl als Badge in Navigation und auf der Startseite: Ein kaputter Versand fiel bisher erst auf, wenn jemand anrief — genau so ist es heute gelaufen.
- 6 neue Tests (Rechte-Trennung · Deutung der drei realen Fehlerfälle · kein Inhalt im Log · Filter/Suche · der Grund überlebt den bool-Rückgabewert), 204 Tests grün.
[0.45.0] — 2026-07-16
Paket 7l: Geteilte Postfächer — Guardrails (UXC-10, Code-Teil von B10).
Entscheidung 2026-07-16: nur Hinweis, kein Verbot und kein Settings-Schalter. Ein Schalter, den niemand umlegt, wäre spekulative Konfiguration; wenn der BTV „persönliche Adresse Pflicht" entscheidet, ist das eine bewusste Code-Änderung mit Freigabe.
- UXC-10 · Hinweis auf der Warteseite bei bekannten Sammel-Präfixen (
info@,vorstand@,geschaeftsstelle@…) — mit Begründung: Wer nur „bitte eine andere Adresse" liest, hält es für Bürokratie; wer erfährt, dass die Kollegin sonst an seine Bankdaten kommt, versteht es sofort. Die „Adresse korrigieren"-Möglichkeit steht direkt daneben (dieselbe Mechanik wie beim Tippfehler-Vorschlag), der Wechsel ist also ein Klick. - Bestätigungsmail nennt den Zeitpunkt („Am 16.07.2026 um 22:05 Uhr wurde mit dieser Adresse eine Registrierung begonnen") und weist auf geteilte Postfächer hin. Die Landing-Page fragt vor dem Einlösen: „Hast Du diese Registrierung begonnen?" — sonst klickt, wer zuerst hinschaut, und erbt ein Konto, das er nie wollte.
- Prüfhinweis „evtl. Sammelpostfach" in der Freigabe-Queue: Vor einer Freigabe ist relevant, dass unklar ist, wer da eigentlich registriert ist.
- Exakter Abgleich des lokalen Teils, kein „enthält":
infoabend@,post.mueller@undhans@info.desind keine Sammeladressen. Ein Fehlalarm untergräbt genau den Hinweis, auf den es ankommt — wer ihn zu Unrecht sieht, lernt ihn zu überlesen. - Konzept 6.5a: Entscheidungsvorlage für den BTV. Die eigentliche Frage („persönliche Adresse Pflicht?") ist dokumentiert — mit dem Argument, das sie entscheidet: Postfach-Zugriff ist Konto-Zugriff. Passwort-Reset und 2FA-Reset gehen beide an diese Adresse, und der „Das war ich nicht"-Abbruchlink landet im selben Postfach; ein Mitnutzer muss ihn nur löschen. Bei geteilten Postfächern gilt die Sicherheitszusage des Portals also schlicht nicht. Empfehlung: Pflicht — samt dem Preis, der dazugehört (manche Ehrenamtliche haben keine eigene Adresse).
- 3 neue Tests (Erkennung · keine Fehlalarme · kaputte Eingaben), 198 Tests grün. Live durchgespielt: Hinweis, Mail-Zeitpunkt, Landing-Frage, Queue-Badge.
- TESTPLAN-ABNAHME.md: A5d (geteiltes Postfach).
[0.44.0] — 2026-07-16
Paket 7k: Anonyme Kennzahlen (UXC-09/UX-07) und O023.
- UXC-09 — das Portal misst sich jetzt selbst. Ohne Messung ist jede weitere UX-Entscheidung Raten: Wir haben die 2FA aufschiebbar gemacht (7.6), weil ein Review sie als härteste Hürde benannte — ob das stimmt, wusste bisher niemand. Die neue Auswertung unter
/kennzahlen(Rechtsystem.view, Migration 0010) zeigt den Registrierungs-Funnel mit Absprung je Etappe, die Login-Erfolgsquote, die Klärfall-Quote und — die interessanteste Zahl — wie viele den Anmelde-Code aufschieben und wie viele ihn später nachholen. Grundlage der Betriebsberichte an den BTV. - Anonym per Bauart, nicht per Zusage: Die Tabelle
metricshat genau drei Spalten —tag,metric,wert. Keineuser_id, keine IP, keine Session, keine Reihenfolge, kein Cookie, kein Drittdienst. Sieben Ereignisse ergeben eine Zeile, keine Einzelspur. Ein Test prüft die Spaltenliste statt dem Kommentar zu glauben: Ein Personenbezug ließe sich nicht einbauen, ohne dass eine Migration auffällt. Der „Absprung" im Funnel ist die Differenz zweier Zähler — dafür wird niemand verfolgt. Aufbewahrung 24 Monate (Cleanup-Job), Transparenzhinweis steht auf der Seite selbst. - Ehrliche Quoten: Ohne Bezugsgröße zeigt die Seite „—" statt „0 %". Eine erfundene Null wäre eine Aussage, die niemand gemacht hat.
- fix: O023 — ein abgewiesener Login wurde als Erfolg gebucht.
login_attempt_log(..., true)bei gesperrtem Konto hatte zwei Folgen: Die Daten logen (jede Auswertung überlogin_attempts— Betriebsberichte, Alarmschwellen, dieser Funnel — zählte abgewiesene Versuche als Anmeldungen), und die Drosselung griff nicht:login_wait_seconds()zählt nursuccess = 0. Wer das Passwort eines gesperrten Kontos kannte, konnte es unbegrenzt oft anpingen und daran ablesen, dass es noch gilt — ein kostenloses Passwort-Orakel, ausgerechnet an einem Konto, dem wir nicht mehr trauen. Die ehrliche Meldung bleibt (die Person muss erfahren, woran sie ist), sie kostet jetzt nur nicht mehr nichts. - Selfcheck: Zeile „Kennzahlen" — eine stumme Messung ist so wertlos wie keine und fiele sonst erst auf, wenn der erste Bericht leer bleibt.
- 9 neue Tests (Funnel-Rechnung inkl. Division durch null · Quoten ohne Grundlage · Spaltenliste der Tabelle · Tagesaggregation statt Einzelspur · „eine Messung darf nie kaputt machen, was sie misst" · O023 mit Gegenprobe: 20 „Erfolge" bremsten nie, 5 Abweisungen bremsen jetzt), 195 Tests grün.
[0.43.0] — 2026-07-16
Paket 7j: Hilfe-Telefon pflegbar und wählbar (O14/UXC-01).
- fix: Der Settings-Wert
help_phonewar nirgends einstellbar. Eingeführt in v0.31.0, aber nie in die Oberfläche gehängt — eine Einstellung, die niemand einstellen kann, ist keine. Eigener Fehler aus dieser Session, gefunden erst, als die Nummer tatsächlich hinterlegt werden sollte. Jetzt steht das Feld unter *Einstellungen → Kontakt & Team*, mit dem Hinweis, dass leer = kein Telefon anbieten bedeutet. - Die Nummer ist wählbar, nicht nur lesbar:
tel:-Link auf der Hilfe-Seite und in der Kontaktzeile aller Fehler-, Warte- und Sperrseiten. Auf dem Handy ist Tippen statt Abtippen der Unterschied zwischen „ruft an" und „gibt auf" — genau der Moment von Persona „Jürgen", der mit verlorenem Handy und verlorenen Codes vor der Code-Abfrage steht.help_phone_tel()räumt dabei Schreibweisen auf (089/5419 6874→tel:08954196874), ohne die Anzeige zu verändern. - Ohne konfigurierte Nummer entsteht kein leerer Link — sonst tippt jemand auf ein totes
tel:. Dann bleibt es bei der E-Mail-Adresse als Weg (keine Sackgasse). - 2 neue Tests (vier Schreibweisen → wählbare Nummer · kein Telefon ohne Konfiguration · Escaping: Der Wert kommt aus den Einstellungen und wird als HTML ausgegeben — ein Admin-Tippfehler darf keine Lücke reißen), 186 Tests grün.
[0.42.1] — 2026-07-16
- docs: Die Dokumenten-Regel steht jetzt dort, wo sie gelesen wird (
app/modules/README.md). Konzept 4.6 verbietet modul-eigene Uploads — aber im Code stand davon nichts, und ein Verbot ohne erreichbare Alternative wird gebrochen. Der Abschnitt nennt den Grund (DocuWare braucht einen Andockpunkt, nicht fünf), erklärt warumdocuments.phpbewusst fehlt (O057: die entwurfsbestimmenden Fragen — Dateitypen, Größen, Virenprüfung, Aufbewahrungsklasse nach § 147 AO — lassen sich ohne den ersten Vorgangstyp nur raten) und gibt dem Modul-Autor das verbindliche Sicherheitsmuster aus 12.2 an die Hand.
[0.42.0] — 2026-07-16
Paket 7i: „Diesem Browser vertrauen" — ehrlich benannt (P02-1, E096).
- fix: Das Portal versprach 30 Tage und lieferte 90. Die Dauer stand hart im Text der Checkbox; mit v0.30.0 wurde sie auf 90 Tage umgestellt (pflegbar) — der Text blieb. Jetzt kommt er aus
trusted_device_tage(), kann also nicht mehr auseinanderlaufen. Eigener Fehler aus dieser Session. - fix: P02-1 — „Diesem Gerät vertrauen" war die falsche Zusage. Vertraut wird ein Browser, kein Gerät: Wer den Portal-Link aus einer App heraus öffnet (WhatsApp, Outlook, Instagram), sitzt in deren eingebautem Browser mit eigenen Cookies — dort gilt das Vertrauen, im normalen Browser nicht. Technisch nicht behebbar (Cookies sind pro Browser), aber benennbar: Der Haken heißt jetzt „Diesem Browser … vertrauen" und der Hilfetext sagt vorher, was passiert. Sonst hält man die zweite Code-Abfrage für einen Fehler — und verliert das Vertrauen ins Portal, nicht in den Browser.
- fix: E096 — die Geräteliste zeigte den rohen User-Agent (
Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 …). Darin erkennt niemand sein Handy. Neu:geraet_klartext()macht daraus „Safari · iPhone" oder „Outlook-App (In-App-Browser) · Android" — In-App-Browser werden zuerst geprüft, weil sie zusätzlich „Safari"/„Chrome" im String tragen und sonst als normaler Browser durchgingen; genau die Verwechslung, die P02-1 beschreibt. Dazu deutsche Datumsangaben statt DB-Zeitstempel. - Beim Live-Test im eigenen Bau gefunden: Ich hatte den Klartext gespeichert und beim Anzeigen ein zweites Mal übersetzt — der zweite Lauf fraß die Kennzeichnung („Facebook-App (In-App-Browser) · iPhone" wurde wieder zu „iPhone"). Jetzt speichert die Datenbank die Tatsache (User-Agent), die Ansicht übersetzt. Das ist auch für den Altbestand richtig: Der lässt sich so jederzeit nachbessern.
- 5 neue Tests (In-App-Erkennung · normale Browser · Edge/Opera gewinnen gegen „Chrome" im selben String · kaputte Angaben ergeben nie eine leere Zelle · kein Systemwert mehr im Label), 184 Tests grün. Live geprüft: Anmeldung aus einem Facebook-In-App-Browser erscheint korrekt benannt in der Liste.
- TESTPLAN-ABNAHME.md: B2 prüft jetzt ausdrücklich den App-Browser-Fall und die lesbare Benennung.
[0.41.0] — 2026-07-16
Paket 7h: Notfall-Codes — Nachweis statt Behauptung (P01-2/P02-3).
- fix: Die Pflicht-Checkbox „Ich habe die Codes gesichert" war kein Nachweis. Man hakt sie an, während die Codes auf dem Bildschirm stehen — sie belegt nichts. Persona „Jürgen" hatte genau das getan und stand später ohne Codes da; das ist kein Randfall, sondern der Regelfall. Neu in zwei Schritten: (1) Codes zeigen, drucken/PDF — (2) Codes ausgeblendet, ein zufällig gewählter davon wird abgefragt. Erst wer in seine gespeicherte Liste schaut, kommt weiter.
- Bewusst keine Falle: „Codes noch einmal anzeigen" führt jederzeit zurück auf Schritt 1 — mit denselben Codes, es werden keine neuen erzeugt. Wer feststellt, dass er doch nichts gespeichert hat, soll das jetzt merken statt in einem Jahr beim Handywechsel.
- Der Nachweis verbraucht keinen Code: Verglichen wird gegen die Anzeige in der Session, nicht über
recovery_redeem()— sonst hätte die Person nach dem Nachweis nur noch neun. Live geprüft: danach sind es 10 von 10. - Zufälliger Index: Wäre es immer der erste Code, notierten sich Leute genau einen — und der Nachweis wäre wieder wertlos. Innerhalb einer Sitzung bleibt der Index stabil (ein Reload darf nicht plötzlich nach einem anderen Code fragen).
- 4 neue Tests (Streuung des Index · Stabilität in der Sitzung · Randfälle 0/1 Codes · kein Verbrauch, inkl. Abgrenzung zu
recovery_redeem()), 179 Tests grün. Live-Durchlauf: Schritt 1 zeigt 10 Codes, Schritt 2 zeigt 0, falscher Code wird freundlich abgelehnt, richtiger führt weiter. - TESTPLAN-ABNAHME.md: A7 beschreibt jetzt den echten Nachweis statt der Checkbox.
[0.40.0] — 2026-07-16
Paket 7g: Adressen mit Umlauten (P04-1).
- fix:
info@münchen.dewurde als „ungültige E-Mail-Adresse" abgewiesen.filter_var(FILTER_VALIDATE_EMAIL)kennt nur ASCII — für einen bayerischen Verband ist das keine Randnotiz, und die Meldung war obendrein eine Lüge: Die Adresse existiert und ist zustellbar. Neu wandeltemail_normalisieren()den Domain-Teil verlustfrei nach Punycode (genau das tun Mailserver ohnehin). - Die Normalisierung sitzt an ALLEN sechs Eingängen — Registrierung, Adress-Korrektur, Login, Passwort-Reset, 2FA-Reset, E-Mail-Änderung. Eine halbe Umsetzung wäre schlimmer als gar keine: Wer sich als
info@münchen.deregistriert (gespeichert als Punycode) und beim Login gegen den Rohtext verglichen würde, käme nie wieder in sein Konto. Live geprüft: Umlaut-, Groß- und Punycode-Schreibweise führen alle ins selbe Konto. - Umlaute vor dem @ werden weiterhin abgelehnt — ohne SMTPUTF8 können wir dorthin nicht zustellen, und eine Adresse anzunehmen, an die nie eine Mail geht, wäre schlimmer als die Ablehnung. Aber die Meldung ist jetzt ehrlich: „Adressen mit Umlauten vor dem @ können wir leider noch nicht zustellen" plus Hilfe-Weg, statt die Person ihren Tippfehler suchen zu lassen, den es nicht gibt.
- Selfcheck: neue Zeile „Umlaut-Adressen (intl)" — ohne die
intl-Extension lässt sich nicht wandeln, dann werden solche Adressen abgewiesen. Kein Alarm, aber sichtbar: sonst wäre die Unterstützung eine stille Zusage, die auf dem Server nicht gilt (intlsteht nicht in der Pflicht-Extensionsliste). - fix (im eigenen Neubau gefunden): Die Prüfung auf Steuerzeichen lief nach
trim()— und PHP'strim()entfernt per Default auch\0. Ausopfer@x.de\0wäre stillopfer@x.degeworden und als gültig durchgerutscht. Exakt die Falle, die inmail_addr_safe()bereits kommentiert steht (B9/E054) — beim zweiten Mal wieder hineingetappt, vom eigenen Test gefangen. Steuerzeichen werden jetzt zuerst geprüft, danach nur echte Leerzeichen getrimmt. - 7 neue Tests (Round-Trip Registrierung↔Login in drei Schreibweisen · ehrliche Ablehnung · Injection-Abwehr · Ergebnis übersteht
mail_addr_safe()), 175 Tests grün.
[0.39.1] — 2026-07-16
Abnahme aus Nutzersicht: alle vier Persona-Durchläufe bestanden (UX-CODE-PAKET Abschnitt 4).
Kein Code-Fix — die Durchläufe sind die Definition of Done des Usability-Pakets. Lokal end-to-end gegen die echte Instanz, jeweils „ohne externe Hilfe ans Ziel, an keiner Stelle ohne nächsten Schritt".
- „Rosa" (P01) — bestanden (bereits v0.33.0): Registrierung ohne Authenticator-App bis zum Abschluss; das Step-up-Gate trägt sie später durch die Einrichtung und zurück zur ursprünglichen Aktion. Der Durchlauf fand damals einen Fehler im eigenen Werk: „Konto absichern" war ausgerechnet für aufgeschobene Konten unsichtbar.
- „Petra" (P09) — bestanden: Abbruch nach der E-Mail-Bestätigung, Rückkehr ohne Cookie. Sie bekommt „Registrierung fortsetzen" statt „Konto existiert bereits" (B1), der Link führt über die POST-Landing-Page (Safe-Links) direkt auf Schritt 2 — kein Kreisel.
- „Tobias" (P10) — bestanden: Tippfehler
@gmial.comwird erkannt; die Warteseite zeigt einen Primär-Button „An …@gmail.com senden" (P10-2), nicht bloß einen Hinweis. Nach dem Klick geht die Mail an die richtige Adresse, das Konto ist korrigiert, die falsche Adresse verschwindet aus der Anzeige (kein Desync, E037). - „Jürgen" (P06) — bestanden: Handy + Notfall-Codes verloren. Der Reset ist von der Code-Abfrage aus erreichbar (B2); die Bestätigung bleibt neutral; die Mail enthält Reset- und „Das war ich nicht"-Abbruchlink. Die Wartefrist-Seite nennt Restzeit in Worten („in etwa 48 Stunden"), den konkreten Zeitpunkt, den Zweck der Frist und den Sofortweg über die Geschäftsstelle (UXC-08b/P01-3). Nach Ablauf: Reset wirkt, TOTP entfernt, Neu-Einrichtung geführt.
Zwei Erkenntnisse aus den Durchläufen:
- In der lokalen Entwicklungs-Datenbank stand
twofa_self_reset_aktiv = false(Rückstand eines alten Test-POSTs auf die Einstellungen-Seite) — Jürgen lief zunächst in „Selbst-Zurücksetzen ist derzeit nicht aktiv". Gegen die Produktion geprüft: dort ist der Reset aktiv, der Code-Default isttrue(7.4). Kein Produktionsfehler, aber ein Beleg dafür, dass eine Test-Datenbank die Abnahme verfälschen kann. - Der Hilfe-Kontakt nennt derzeit nur die E-Mail-Adresse — die Telefonnummer (O14) ist noch nicht hinterlegt. Für Jürgen ist genau das der Moment, in dem ein Telefon zählt. Der Selfcheck weist darauf hin.
[0.39.0] — 2026-07-16
Paket 7f: Das Modul-Fundament trägt jetzt wirklich (Konzept 4.5 — O051 teilweise, O053, O054/O056).
Die Modul-Konvention stand im Konzept und in CLAUDE.md — funktioniert hat sie nicht. Ein Modul, das sich exakt an die Vorgaben hält, wäre schlicht wirkungslos geblieben, ohne eine einzige Fehlermeldung. Aufgefallen wäre das erst beim ersten Fachmodul.
- fix: Das
repository.phpeines Moduls wurde nie geladen. Der Loader inindex.phpkannte nurroutes.php+controllers.php— das gesamte SQL eines Moduls wäre unerreichbar gewesen. Laden passiert jetzt zentral inmodules_load()(Bootstrap) — im Web und auf der CLI. - fix: Module konnten keine Jobs registrieren. Der Cron lud Modul-Code überhaupt nicht; die zugesagte Job-Registry (14.5) war für Module eine leere Zusage — ihre Cron-Arbeit wäre nie gelaufen, lautlos.
- fix: Die Pflicht-Hooks wurden nie aufgerufen.
<modul>_dashboard_tiles()und<modul>_open_tasks()sind laut 4.5 verbindlich; im Code stand dazu nur ein Kommentar „liefern ihre Kacheln später". Jetzt hängen beide an der Startseite. Defensiv: Wirft ein Modul, verschwindet nur dieses Modul aus der Sammlung (Log-Eintrag) — ein fehlerhaftes Fachmodul darf das Portal nicht lahmlegen. - fix: O053 — der Präfix-Guard war eine reine Behauptung. CLAUDE.md sagt „präfixlos = Core-reserviert, Selfcheck prüft"; geprüft hat das nie jemand. Eine präfixlose Funktion in einem Modul kollidiert mit dem Core oder dem nächsten Modul — erst zur Laufzeit, auf einer echten Seite. Der Selfcheck meldet Verstöße jetzt namentlich, ebenso fehlende Pflicht-Hooks je aktivem Modul.
- Korrektur am Befund: O051 („Modul-Loader fehlt") stimmt so nicht —
routes.php/controllers.phpwurden sehr wohl geladen, undnav.phprief<modul>_navbereits auf. Fehlend warenrepository.php, die Jobs, die Pflicht-Hooks und der Guard. app/modules/README.md: die Konvention kompakt (Dateien, Regeln, Pflicht-Schnittstelle, Beispiel).- 6 neue Tests gegen ein echtes Modul im echten Verzeichnis (Fixture
tests/fixtures/modules/demo/, plus ein Gegenbeispiel, an dem der Guard anschlägt) — inklusive „ein kaputtes Modul reißt die Startseite nicht mit". Gegenprobe bestanden. 168 Tests grün. - Nebenbei:
modules_load()hatte einenstatic-Schalter, der die Funktion einmal pro Prozess wirksam machte — überflüssig (require_oncegenügt) und unprüfbar. Raus.
[0.38.0] — 2026-07-16
Paket 7e: Zwei Selbstdiagnose-Fehler, aufgefallen am echten Selfcheck der Produktion.
- fix: Die Rollen-Seed-Prüfung meldete „OK — 5 System-Rollen (erwartet: 4)". Eine Prüfung, die im selben Atemzug bestätigt und widerspricht, ist wertlos: Sie zählte nur (
>= 4) und trug die Erwartung hart im Text — seit GS-Admin dazukam (v0.18), stimmte die Zahl nicht mehr. Schlimmer als der Schönheitsfehler ist die Blindheit: Fehlteadminund gäbe es dafür zwei andere Rollen, wäre die Zahl gleich, der Selfcheck grün — und niemand käme mehr an die Verwaltung. Jetzt wird namentlich geprüft (repo_system_rollen_fehlend()) und die fehlende Rolle benannt. - fix: Endgültig gescheiterte Mails lösten keinen Alarm aus. Der Selfcheck zeigte sie zwar — den liest aber niemand von allein.
/health(der Weg zum Monitoring, 14.6) koppelte nur an Cron-Stillstand und Outbox-Stau; eine Mail, die aufgibt, verlässtqueuedund war für die Ampel unsichtbar. Jetzt zieht ein Fehler der letzten 24 h/healthauf 500. Bewusst mit Zeitfenster: Ein einzelner Altfall darf die Ampel nicht dauerhaft rot färben — sonst gewöhnt sich das Monitoring an Rot. Selfcheck und/healthbenennen jetzt dieselbe Regel. - 1 neuer Test (inkl. Gegenprobe: eine verschwundene System-Rolle muss auffallen), 163 Tests grün.
[0.37.0] — 2026-07-16
Paket 7d: Registrierungs-Robustheit (E026, E029, E030/E031, E037, E038).
- fix: E038 — eine fremde Registrierung überlebte den Login.
session_regenerate_id()tauscht nur die Kennung, die Session-Daten bleiben;onboarding_user_idüberstand damit die Anmeldung — undtwofa_setup_user()/wizard_user()lesen diesen Wert vor dem angemeldeten Konto. Auf einem geteilten Rechner hieß das: A bricht die Registrierung ab, B meldet sich an — und bekommt A's Onboarding serviert, richtet also den Anmelde-Code für A's Konto ein. Live gegengeprüft: ohne den Fix reproduzierbar.auth_login_user()räumt jetzt alle Reste der alten Identität weg. - fix: E029 — die Sperre für BTV-Adressen ließ sich umgehen. Sie stand nur am Einstieg, nicht bei „Adresse korrigieren": erst mit
privat@example.orgstarten, dann auffranz@btv-turnen.dekorrigieren. Live gegengeprüft: ohne den Fix entsteht tatsächlich ein lokales Konto zu einer BTV-Adresse — genau das Schattenkonto, das 6.2.1 ausschließt und das die Zuordnung überentra_oidunterlaufen würde. Die Prüfung ist jetzt eine gemeinsame Regel für beide Wege. - fix: E026 — ein Doppelklick erzeugte eine 500. Zwei parallele Anfragen für dieselbe Adresse sahen beide „kein Konto"; die zweite lief in den Unique-Key, die
PDOExceptionschlug bis zur Fehlerseite durch — ausgerechnet auf dem Weg, der neutral bleiben muss (die Seite darf nicht verraten, ob es die Adresse gibt). Jetzt: SQLSTATE 23000 wird als Race behandelt und neutral weitergeführt; andere Datenbankfehler bleiben sichtbar. - fix: E030/E031 — externer Aufruf und Schreibzugriff im GET-Render. Der Mitglieder-Abgleich lief beim Anzeigen der Stammdaten-Seite: Ein Reload stieß ihn erneut an, zwei Tabs riefen it4sport doppelt auf, und die Seite wartete bis zu 10 s auf eine fremde Schnittstelle. Jetzt wird er im POST ausgelöst, der nach Etappe 5 führt; der Rückfall für Wiederaufnahme-Wege ist durch einen atomaren Claim geschützt (nur ein Request ruft an), und die Ergebnis-Schreibvorgänge liegen in einer Transaktion — vorher konnte ein mehrdeutiger Treffer ohne Prüfhinweis entstehen.
- fix: E037 — Wartezustand ohne Ausweg. Führte „Adresse korrigieren" auf eine bereits vergebene Adresse, blieb die Datenbank auf der alten Adresse — die Session wurde trotzdem auf die neue gesetzt. Die Warteseite zeigte dann eine Adresse, an die nie eine Mail ging, und „Erneut senden" schickte an die alte. Die Antwort bleibt neutral, ist jetzt aber ehrlich in dem, was sie anzeigt.
- Ein hängengebliebener Abgleich bleibt nicht liegen: Der Retry-Job sammelt jetzt auch Zeilen ein, die reserviert, aber nie fertig wurden (
offenohne Prüfzeitpunkt, älter als eine Stunde) — sonst hätte ein Abbruch mitten im Request ein Konto dauerhaft ohne Abgleich gelassen. - 6 neue Tests; E026 und E038 zusätzlich per Gegenprobe gegen den alten Code belegt, E029 end-to-end als reproduzierbare Umgehung. Ehrlich dazu: Der E029-Test prüft die Regel, nicht ihre Verdrahtung im Korrektur-Handler — dieser Teil ist durch den Live-Durchlauf abgesichert. 162 Tests grün.
- TESTPLAN-ABNAHME.md: A5a (Doppelklick), A5b (BTV-Adresse über den Umweg), A5c (geteilter Rechner).
[0.36.0] — 2026-07-16
Paket 7c: Barrierefreiheit (P07-Cluster, WCAG 2.1 AA — Konzept 13).
- fix: P07-2 — die zugesagte „Copy"-Möglichkeit gab es gar nicht. Der Schlüssel stand in einem
<code data-copy>: nicht fokussierbar, ohne Namen, per Tastatur unerreichbar — und die Kopier-Funktion war nie implementiert, der Klick tat schlicht nichts. Konzept 7.2 sagt „Copy" als dritten gleichwertigen Weg zu. Jetzt ein echter Button mit Rückmeldung überaria-live; klappt das Kopieren nicht, wird der Text markiert. Ohne JavaScript bleibt der Schlüssel sichtbar und von Hand markierbar. - fix: P07-5 — Auswahlfelder wechselten den Kontext von selbst (
data-autosubmit→ WCAG 3.2.2 „On Input"). Schlimmer: Die „Ansicht als Rolle" ließ sich ohne JavaScript gar nicht umschalten, weil es keinen Absende-Button gab (Regel „Formulare funktionieren ohne JS"). Beide Stellen haben jetzt einen echten Button; der Listenfilter hatte ohnehin schon einen. Der totedata-autosubmit-Handler ist ausapp.jsraus. - fix: P07-4 — Icon-Link ohne verlässlichen Namen: Die Admin-Einstellungen trugen nur ein
title.aria-labelergänzt. (Die übrigen Header-Icons waren bereits korrekt benannt — geprüft, nicht geändert.) - P07-1/P07-3 bereits erledigt (v0.31.0, UXC-06): Feld-Hilfetexte hängen per
aria-describedbyam Feld, der Datumsformat-Hinweis steht sichtbar statt nur im Placeholder. - TESTPLAN-ABNAHME.md: C12 (Bedienung ohne Maus), C13 (ohne JavaScript). 156 Tests grün, live geprüft.
[0.35.0] — 2026-07-16
Paket 7b: Rollen-Workflow — Modul-Rechte greifen jetzt überhaupt (E079, E081, E093).
- fix: E081 — jede Modul-Rolle wäre rechtelos geblieben. Der Rechte-Editor bietet
<modul>.*an; der Wert landete als Literal inrole_rightsund wurde exakt verglichen —abrechnung.*traf also nieabrechnung.einreichen. Die Oberfläche versprach eine Rechtevergabe, die es nicht gab, und zwar lautlos: Aufgefallen wäre es erst beim ersten Fachmodul. Ein Wildcard deckt jetzt alle Rechte unterhalb seines Präfix ab — nie über den Punkt hinaus (abrechnung.*greift nicht aufabrechnungX.*und niemals aufusers.manage). - fix: E079 — Modul-Rechte griffen ohne Freigabe des Kontos. Konzept 8.1 verlangt „bestätigte Rolle und Freigabe"; geprüft wurde nur die bestätigte Rolle. Ein Konto im Klärfall bekam Modul-Funktionen. Core-Rechte hängen bewusst nicht an der Freigabe — sonst nähme ein Klärfall am eigenen Konto der Verwaltung die Verwaltungssicht. Fehlt
review_statusim Nutzerobjekt, wird fail-closed verweigert und geloggt (stilles Verweigern sähe aus wie „Modul kaputt"). - fix: E093 — die Schlüsselerzeugung verschluckte Umlaute.
[^a-z0-9]traf auchä/ö/ü/ß: Aus „Übungsleitung" wurde der Schlüssel_bungsleitung, und „Prüfung"/„Pr fung" fielen auf denselben Schlüssel. Der Schlüssel steht in URLs undrole_rights. Deutsche Umlaute werden jetzt ausgeschrieben (uebungsleitung,pruefteam,strasse). - fix (Nebenbefund, gleiche Fehlerklasse wie bei den Settings):
repo_role_rights()hatte einen statischen Cache, den kein Schreibvorgang verwarf — wer im selben Request Rechte speicherte und danach eine Rechteprüfung auslöste, arbeitete mit dem alten Stand. Cache ist jetzt verwerfbar;repo_role_set_rights()/repo_role_delete()tun das. - 9 neue Tests gegen die echten Funktionen inkl.
role_rights-Tabelle (ein erster Entwurf hatte die Logik in den Test kopiert und hätte sich nur selbst bestätigt — verworfen). Gegenprobe: E079 und E081 schlagen gegen den alten Code nachweislich an. 156 Tests grün.
[0.34.0] — 2026-07-16
Paket 7a: 2FA- und Klärfall-Robustheit (E002, E003, E021, E087, E090, E091, E092, O086, E096).
2FA-Robustheit
- fix: E003 — die Sperre galt nur für einen der beiden Wege. Der Recovery-Zweig wurde abgearbeitet, *bevor*
totp_check_account()die Sperre prüft: Ein gesperrtes Konto ließ sich über Notfall-Codes unbegrenzt weiter durchprobieren. Die Prüfung sitzt jetzt vor der Verzweigung und gilt für beide. - fix: E002 — TOTP-Fehlversuche waren für die Drosselung unsichtbar. Sie landeten nur im Audit, nicht in
login_attempts— die IP-/Konto-Drosselung sah einen Angriff auf viele Konten gleichzeitig also gar nicht. Jetzt werden TOTP- und Notfall-Code-Fehlversuche mitgezählt. - fix: E021 — die Geräteliste zeigte tote Geräte als „vertraut".
trusted_device_valid()verlangt zusätzlich die aktuellesession_epoch; nach Passwortänderung oder Sperrung sind alle Geräte entwertet (12.2). Die Liste im Profil filterte darauf nicht — eine Sicherheitsaussage, die nicht stimmte. - Nicht zutreffend (geprüft, nicht geändert): E006/E011 —
recovery_redeem()ist bereits atomar (UPDATE … WHERE used_at IS NULL+rowCount()===1).
Klärfälle
- fix: E087 — die Sammelfreigabe gab auch mehrdeutige Fälle frei. „Sammelfreigabe unauffälliger Fälle" (8.4) schloss sie nie ein: Wird ein mehrdeutiger Abgleich pauschal freigegeben, bleibt offen, welche Person gemeint ist — und die Kandidatendaten Dritter bleiben liegen, weil der Purge-Job nur aufgelöste Fälle räumt. Solche Fälle sind jetzt in der Liste nicht auswählbar (plus serverseitige Prüfung) und werden beim Absenden benannt, statt still übersprungen zu werden.
- fix: E092 — die Phönix-Kennung kam ungeprüft aus dem Formular und wurde nach 9.4 sofort führend. Ein Tippfehler band dauerhaft die falsche Person. Sie muss jetzt aus den tatsächlich ermittelten Kandidaten des Kontos stammen.
- fix: E090 — gesperrte Prüffälle verschwanden aus Queue und Zähler. Wer ein Konto sperrte, ließ den Prüfvorgang lautlos und dauerhaft verschwinden. Sie bleiben jetzt sichtbar und tragen ihren Status als Badge.
- fix: O086 — „wartet seit" rechnete ab
registered_at. Bei einem Fall, der erst später entstand (Nachtreffer), log das Alters-Badge um Wochen. Jetzt ab Eintritt in die Queue (review_status_at), mit Rückfall aufregistered_atfür Bestandsfälle. - fix: E091 — der Klärfall-Digest schaltete sich lautlos ab, wenn kein Prüfteam-Postfach hinterlegt war: Niemand merkte, dass der tägliche Bericht nie ankommt. Der Selfcheck meldet das jetzt (mit Zahl der offenen Fälle), ebenso ein fehlendes Hilfe-Telefon und den Zustand der Vorsaison-Erinnerung.
- fix: E096 — rohe Systemwerte in der UI: Die Queue zeigte den Enum-Wert (
mehrdeutig). Übersetzung jetzt zentral inabgleich_label()— eine Quelle, auch vonaccount_labels()genutzt. - Beim Live-Test im eigenen Werk gefunden: Die neue E087-Regel sperrte *jede* Checkbox, weil
repo_klaerfaelle()review_statusnicht mitlieferte — die Sammelfreigabe wäre komplett unbenutzbar geworden. Fail-closed sieht von außen aus wie „funktioniert". Feld ergänzt, Regel meldet fehlende Felder jetzt ins Log, Regressionstest gegen die echte Queue. - 7 neue Tests (Gegenprobe verifiziert: E087/E090/O086 schlagen gegen den alten Code an), 147 Tests grün.
[0.33.0] — 2026-07-16
Paket 6c: Abnahme-Durchlauf „Rosa" (Definition of Done, UX-CODE-PAKET Abschnitt 4).
- fix: „Konto absichern" war für genau die Konten unsichtbar, die es brauchen. Aufgefallen im Durchlauf: Wer die 2FA aufschiebt, gilt als „vollständig" (7.6) — und die volle Startseite zeigte die Aufgabenliste gar nicht, nur die eingeschränkte tat das. Die dauerhaft geforderte Empfehlung (7.6/8.6) verschwand also lautlos. Jetzt steht sie als ruhiger Hinweis (kein Alarm — das Konto funktioniert ja) auf der vollen Startseite, gezielt gesucht statt über die Listenposition gegriffen.
- Durchlauf „Rosa" (P01) bestanden, lokal end-to-end: Registrierung ohne Authenticator-App bis zum Abschluss (
review_status=freigegeben, Portal nutzbar), „Später einrichten" in Etappe 3 greift, Pausieren-Hinweis und „Warum fragen wir das?" (mitaria-describedby) erscheinen, Verify-Link ist in der Datenbank redigiert (B8 arbeitet). Danach:/profil/emaillöst das Gate aus, die Gate-Seite trägt die Begründung und kein „Später", nach Einrichtung + Notfall-Codes landet Rosa zurück auf/profil/email.
[0.32.0] — 2026-07-16
Paket 6b: Statuskommunikation und Fristen (UXC-05, UXC-08).
- UXC-05 · Wartende erfahren jetzt aktiv, was passiert. Bisher merkte man Fortschritt nur durch erneutes Einloggen. Neu: zentrales
status_notify()schickt bei jedem Übergang (Angaben eingegangen · in Prüfung · freigegeben · Rückfrage) eine Portal-Nachricht UND eine Mail über die Outbox. Die vier Freigabewege (Einzelfreigabe, Klärfall-Auflösung, Sammelfreigabe, Registrierungsabschluss) liefen bisher auseinander — teils nur Mail, im Portal nichts. Jetzt ein Pfad. - Konkrete Fristen statt Floskel (Migration 0009,
users.review_status_at): „Eingegangen am 16.07.2026 — wir melden uns in der Regel bis 23.07.2026" statt „in der Regel innerhalb von 5 Werktagen". Wer seit acht Tagen wartet, liest die Floskel als Hohn. Werktage-Rechnung überspringt Wochenenden (Feiertage bewusst nicht — deshalb „in der Regel bis", keine Garantie); Zieltage in Werktagen pflegbar (sla_klaerfall_werktage, 1–60 gekappt). Bestandskonten ohne bekannten Wechselzeitpunkt behalten die alte Formulierung — ein erfundenes Datum wäre schlimmer als eine vage Aussage. - fix: Fünf Stellen setzten
review_statusdirekt per SQL anrepo_user_set_review()vorbei (oidc.php,jobs.php,registrieren.php) — dort wäre der neue Zeitstempel leer geblieben. Alle stempeln jetzt mit;repo_user_set_review()stempelt nur bei echtem Wechsel, sonst schöbe ein erneuter Admin-Klick die Frist immer weiter nach hinten. - UXC-08a · Vorsaison-Erinnerung als Cron-Job (O040): Zwischen zwei Abrechnungen liegen Monate — zum Saisonstart sind Passwort vergessen, Handy neu und Anmelde-Code weg, bei allen gleichzeitig. Eine ruhige Mail vorher verteilt die Last. Konten ohne Anmelde-Code werden gezielt angesprochen (7.4/7.6). Steuerung über
vorsaison_monat_tag(Standard leer = der Job tut nichts, kein BTV-Fixwert im Core); Merker gegen Doppelversand wird vor dem Versand gesetzt (eine ausgelassene Erinnerung ist harmloser als eine doppelte an alle), 7 Tage Nachlauf für ausgefallene Cron-Läufe. - UXC-08b/P06-3 · Reset-Wartefrist verständlich: Die Seite zeigte den rohen Datenbank-Zeitstempel (
2026-07-18 10:00:00). Jetzt: Restzeit in Worten („in etwa 24 Stunden"), konkreter Zeitpunkt, wozu die Frist dient, der „Das war ich nicht"-Weg und der Sofortweg über die Geschäftsstelle (P01-3). Und kein „error"-Kasten mehr — es ist nichts schiefgegangen. - 10 neue Tests (Werktage-Rechnung inkl. „nie ein Wochenende als Zusage" über 28 Starttage · keine erfundene Frist ohne Zeitpunkt · SLA-Kappung · alle vier Übergänge ohne Systemnamen · Portal+Mail · Fälligkeit des Massen-Mailings), 141 Tests grün.
- TESTPLAN-ABNAHME.md: C4a (Statuskommunikation), B5c (Wartefrist).
[0.31.0] — 2026-07-16
Paket 6a: Usability vor Livegang — Hilfe, Anleitung, Vertrauen, Sackgassen (UXC-01/02/03/04/06/07, P01-1).
- UXC-07 · Microcopy-Leitlinie als
docs/MICROCOPY.md: Haltung (Du, genderneutral, schuldfrei), Verbotsliste für Systemnamen, die drei Zustands-Muster (Fehler/Leer/Warte — jeweils *was ist passiert · was kannst Du jetzt tun · wo bekommst Du Hilfe*) und eine Checkliste vor jedem Merge. Gilt auch für alle künftigen Module. - fix: Die 419-Seite war eine Sackgasse. Ein abgelaufenes Formular endete in
exit('Sitzung abgelaufen …')— ohne Layout, ohne Ausweg, und die Eingaben waren weg. Jetzt: Seite im Portal-Layout, die erklärt was passiert ist, die Eingaben überform_draftsrettet (13.3) und weiterführt. Erster Anwendungsfall der Leitlinie. - fix: Die 2FA-Sperre war eine Sackgasse. „Zu viele falsche Codes — bitte warte einige Minuten" nannte weder Notfall-Codes noch Reset-Weg, Anleitung oder Hilfe — genau der Moment, in dem Leute anrufen oder aufgeben. Jetzt eine eigene Seite mit konkreter Restzeit, allen Selbsthilfe-Wegen und der Klarstellung „Dein Konto ist nicht dauerhaft gesperrt".
- fix: P01-1 — der
otpauth://-Link lief ins Leere. Ohne installierte App passiert beim Antippen nichts; die Person hält das für kaputt (Persona „Rosa"). Neu: Zwischenseite/2fa/app-oeffnen, die erst erklärt, dann verlinkt, und den Fall „es passiert nichts" auffängt. Das Geheimnis kommt dabei aus der Session, nie aus der URL (sonst stünde es im Zugriffsprotokoll). - UXC-02 · Bebilderte Anleitung unter
/hilfe/anmelde-code: Schritt für Schritt je Gerät (iPhone „Passwörter" · Android Google Passwortmanager · zwei Geräte per QR), Einstieg „Ich habe noch keine App", Fehlerhilfe (u. a. falsch gehende Handy-Uhr). Die Screenshots sind Zulieferung — die Seite ist auch ohne sie vollständig benutzbar, jede Lücke zeigt ihren Dateinamen (public/assets/hilfe/README.md, inkl. Warnung: keine echten QR-Codes abbilden). - UXC-01 · Hilfe erreichbar machen: neuer Settings-Wert
help_phone(Standard leer — der Core trägt keine BTV-Nummer, O18) +hilfe_kontakt_zeile()auf Fehler-, Warte- und Sperrseiten. Bisher stand die Nummer nur in der Barrierefreiheitserklärung — also genau dort nicht, wo Leute feststecken. - UXC-03 · Vertrauen & Nutzen als pflegbare Bausteine: Betreiber-Klarheit und Datenschutz in Alltagssprache auf der Startseite, Nutzen-Moment vor der Registrierung, Payoff-Framing an der 2FA-Etappe und am Step-up-Gate.
- fix: Die Startseite forderte ein Smartphone als Voraussetzung — nach dem 2FA-Aufschub (7.6) stimmt das nicht mehr und hätte genau die Leute abgewiesen, die der Aufschub gewinnen soll. Jetzt: „Kein Smartphone nötig, um loszulegen".
- fix:
views/registrieren_intro.phpwar eine Waise — seit die Registrierung in die Startseite wanderte, hat die „Das brauchst Du"-Seite niemand mehr gerendert. Inhalte in den Registrieren-Tab verschoben (dort werden sie gelesen), Datei entfernt. - UXC-04 · Pausieren-Hinweis als Partial
_pausieren.phpan jeder Wizard-Etappe: Die Resume-Mechanik gab es seit Beginn — sie stand nur nirgends, wo jemand sie liest. - UXC-06 · „Warum fragen wir das?" als wiederverwendbares Muster (
feld_hilfe(), peraria-describedbyam Feld statt Deko daneben; Formathinweis raus aus dem Placeholder). Erster Fall: Geburtsdatum. Gilt für alle künftigen Module (Bankdaten). - Ansprache vereinheitlicht (Du/Dein statt gemischt) auf der Startseite und in der Sitzungsablauf-Meldung.
- TESTPLAN-ABNAHME.md: A9/A10 (Anleitung, kein totes Antippen), B8/B9 (Formular zu lange offen, zu viele falsche Codes). 131 Tests grün, alle Seiten live geprüft.
[0.30.0] — 2026-07-16
Paket 5: 2FA erst vor der ersten sensiblen Aktion (Konzept 7.6/7.5, Entscheidung 2026-07-16).
- Die 2FA-Einrichtung ist in der Registrierung nicht mehr Pflicht, sondern empfohlen und überspringbar („Später einrichten", Etappe 3). Sie war die härteste Hürde des Funnels, bevor überhaupt ein Nutzen erlebt wurde — und ein frisches Konto enthält noch keine Finanzdaten, die den Zwang rechtfertigen. Der Aufschub wird in
users.twofa_deferred_atfestgehalten (Migration 0008);/startzeigt dauerhaft die Aufgabe „Konto absichern (empfohlen)". - Verpflichtend wird die 2FA am neuen Step-up-Gate
require_twofa()— an dem Punkt, an dem ihr Zweck offensichtlich ist. Belegt: E-Mail-Änderung, Recovery-Codes nachgenerieren, Trusted Devices verwalten und alle Routen hinter erhöhten Rechten (users.*,roles.*,settings.*,audit.*,system.view). Letzteres sitzt zentral inrequire_right()statt in 20 Controllern — eine vergessene Zeile wäre sonst ein Loch. Nach der Einrichtung geht es zurück zur ursprünglich aufgerufenen Aktion. - Die Sicherheitsgrenze bleibt, wo sie war: Wer 2FA eingerichtet hat, kommt ohne bestandene Prüfung nirgends hin — live gegengeprüft, auch wenn die 2FA mitten in einer laufenden Session aktiviert wird. Gelockert ist ausschließlich der Fall „noch keine 2FA eingerichtet". Reihenfolge in
require_right(): erst das Recht, dann das Gate — wer das Recht nicht hat, bekommt die 403 und wird nicht in ein Onboarding für eine Seite geschickt, die er nie sieht. - Rücksprungziel nur als interner Pfad (
pfad_intern())://host,/\host, absolute URLs und Steuerzeichen werden abgewiesen — sonst wäre das Gate eine Phishing-Brücke (12.2). Am Gate gibt es kein „Später" — dort ist die 2FA der Weg hindurch. - Trusted Devices: 30 → 90 Tage (7.5), jetzt als Settings-Wert
trusted_device_tagepflegbar, hart auf 1–365 begrenzt (ein Tippfehler soll kein quasi-ewiges Gerät erzeugen). - fix (Nebenbefund):
setting_set()pflegte den Request-Cache vonsetting_get()nicht — wer im selben Request speicherte und wieder las (Einstellungs-Editor), bekam den alten Wert. - Dokumentiertes Restrisiko (Konzept 7.6/Anhang B): Bis zur ersten sensiblen Aktion ist das Konto nur passwortgeschützt; bei kompromittiertem Passwort könnte ein Angreifer die 2FA selbst einrichten. Gegenmaßnahmen: Info-Mail bei jeder Aktivierung + „Das war ich nicht"-Abbruchlink; keine Finanz-/Vertragsdaten ohne Gate. Interne Konten sind nicht betroffen (IdP-MFA).
- 6 neue Tests (Wizard-Aufschub · Gate-Katalog · Open-Redirect-Abwehr · Trusted-Device-Frist ·
account_is_full· Aufgabenliste) + Live-Durchlauf, 131 Tests grün.
[0.29.0] — 2026-07-16
Paket 4f: Phönix-Anbindung belastbar gemacht (B7 — P-API-1, P-API-3/E052, E059, E064).
- fix: E059/E064 — der Retry-Job zerstörte bestätigte Zuordnungen. Konzept 9.4 sagt „nachträgliche Treffer überschreiben nie bestätigte Daten"; ein Kommentar im Code behauptete das auch — die Zeile darunter tat das Gegenteil. Bei bestätigten Konten schrieb der Job die Abgleich-Zeile neu: ein Nachtreffer überschrieb
phoenix_matches.phoenix_person_idsamt Snapshot mit der fremden Person, „kein Treffer" nullte die gebundene Referenz — währendconfirmed_by_user_atstehen blieb (eine bestätigte Zuordnung ohne Zuordnung). Neu: bei bestätigten Stammdaten werden nur Status und Prüfzeitpunkt fortgeschrieben, Referenz und Snapshot bleiben unangetastet; ein Nachtreffer erzeugt ausschließlich einen Prüfhinweis (review_status) + Audit-Eintrag für die Verwaltung. Die führende Spalteprofiles.phoenix_person_idwar nie betroffen. - fix: P-API-1 — falsches Auth-Verfahren. Der Client schickte einen
Bearer-Token; it4sport authentifiziert laut Spec perX-Auth-Token-Header. Inlive/testhätte jeder Abruf mit HTTP 401 geendet. - fix: P-API-3/E052 — nur die erste Seite gelesen. it4sport paginiert; ein mehrseitiger Mehrfachtreffer sah dadurch wie ein eindeutiger Treffer aus — das Portal hätte die falsche Person vorbelegt und deren
phoenix_person_idgebunden. Neu sammeltphoenix_api_get_all()alle Seiten ein (überlinks.next, sonstmeta.current_page) und bricht laut ab, statt still unvollständig zu zählen. - Offen (O5, Entscheidung Stefan ↔ it4sport): Der Endpunkt
/persons?email=…ist erfunden — eine serverseitige Personensuche per E-Mail gibt es bei it4sport nicht (PHOENIX-API-ANALYSE.md, P-API-2). Vorphoenix.mode = liveist der Weg zu klären: (A) Personenquellen seitenweise ziehen und den E-Mail→Person-Index nur flüchtig im Abgleichlauf aufbauen, oder (B) Abgleich über Name + Geburtsdatum. Bis dahin trägtsimulate. Im Code als Warnblock markiert. - 4 neue Tests (Gegenprobe verifiziert: gegen den alten Code schlagen sie an —
NULLbzw. fremdeSIM-2001), 125 Tests grün.
[0.28.0] — 2026-07-16
Paket 4e: SSO-Über-Provisionierung geschlossen (B6 — E001, E004).
- fix: E001 — jedes Tenant-Member wurde automatisch
btv_hauptberuf. Die Gruppenprüfung war „optional": ohne gesetzteentra.group_idbekam jede·r im Tenant automatisch ein Konto mitusers.view/users.review/system.view. Neu: zwei Gruppen mit getrennter Bedeutung —entra.group_id= Zugang (alle mit BTV-Adresse),entra.group_hauptamt= Rollebtv_hauptberuf(nur BTV-Hauptamt). Wer Zugang hat, aber nicht im Hauptamt ist, bekommt ein Konto mit Basisrechten (profile.*) — keine Verwaltungs-Einsicht mehr per Autoanlage. - Fail-safe statt Aussperrung: Lässt sich die Mitgliedschaft nicht prüfen (keine
group_idkonfiguriert, oder der Token trägt keinengroups-Claim — fehlende Token-Konfiguration bzw. „groups overage"), entstehen keine neuen Konten mehr (Loch zu), bestehende Konten bleiben nutzbar (sonst wäre die gesamte Verwaltung ausgesperrt). Der Zustand wird geloggt. - fix: E004 — unverifizierte E-Mail überschrieb die Login-Kennung.
users.emailfolgte dememail-Claim ohne Prüfung. Jetzt wird die Adresse nur übernommen, wenn Entra sie peremail_verifiedals verifiziert meldet. - 5 neue Tests (kein Claim → Deny · falsche Gruppe → Deny · Zugang ohne Hauptamt → keine Einsichtsrechte · Hauptamt → Rolle · unverifizierte Adresse wird ignoriert), 121 Tests grün.
[0.27.0] — 2026-07-16
Paket 4d: Versand-Deckel gegen Mail-Bombing (B9 — E055, E034, E035). Damit ist B9 abgeschlossen.
- fix: E055/E034/E035 — Mail-Bombing über das Portal. Registrierungsstart, Passwort-Reset, 2FA-Reset und die „Konto existiert"-Hinweismail waren ungedeckelt und anonym auslösbar: Beliebig viele Mails an eine fremde Adresse — das Portal als Waffe. Neu: Deckel je (Adresse, Typ, Stunde) in
queue_mail(); Unterdrückung nur im Audit sichtbar (mail.gedeckelt), die Antwort nach außen bleibt neutral (12.2). Neue Spaltemails.mail_type+ Zähler-Index (Migration 0007). - Gedeckelte Kanäle (1/Stunde):
verify_email(Start),resume_registration,dublette,reset_password,passwort_sso_hinweis,reset_2fa. Nutzer-Antworten an die Hilfe-Adresse: 10/Stunde. Ohne Typ kein Deckel — vom Team ausgelöste Nachrichten bleiben frei. - Konflikt beim Bau erkannt und gelöst: Der „E-Mail erneut senden"-Klick nutzt dieselbe Funktion wie der Start. Mit einem harten 1/Stunde-Deckel hätte die Warteseite „wurde erneut verschickt" behauptet, während gar nichts rausging. Der ausdrücklich angeforderte Resend (sitzungsgebunden + 60-Sekunden-Sperre) hat deshalb einen eigenen, höheren Deckel (5/Stunde) — der anonyme Start bleibt bei 1.
- 3 neue Tests (Angriff, Resend-Fall, ungedeckelte Team-Mails), 116 Tests grün.
[0.26.0] — 2026-07-16
Paket 4c: Mail-Bodies sind at rest redigiert (B8 — E033, E056, E067).
- fix: E033/E056/E067 — kein Klartext-Link mehr in der Datenbank. Bisher lag
mails.bodybis zum Versand im Klartext und wurde erst danach redigiert: Bei Cron-Stillstand (oder acht fehlgeschlagenen Versuchen) blieb ein gültiger Verify-/Reset-Link dauerhaft lesbar in der DB und hebelte das Token-Hashing aus (12.2). Neu:queue_mail()speichert den Body sofort redigiert; die Geheimnisse liegen verschlüsselt (sodium, Format 12.3) in der neuen Spaltemails.secrets_encund werden nur im Versandmoment im Arbeitsspeicher wieder eingesetzt. Nach Versand (oder endgültigem Fehler) wirdsecrets_encgelöscht — der Body bleibt für immer redigiert. - Platzhalter sind jetzt nummeriert (
[[GEHEIM:0]]), weil ein gemeinsames[REDIGIERT]nicht umkehrbar wäre. - Migration 0006 legt
secrets_encan und bereinigt den Altbestand: bereits versendete/fehlgeschlagene Mails übernehmen ihre redigierte Fassung — damit sind auch die historischen Klartext-Links weg. Queued-Altfälle laufen über einen Kompatibilitätspfad im Worker. - Rundlauf verifiziert: redigiert in der DB → echter Link beim Empfänger → danach wieder redigiert. 3 neue/angepasste Tests, 113 Tests grün.
[0.25.0] — 2026-07-16
Paket 4b: Token-Hygiene (B8 — E043, E049, E032).
- fix: E043 —
token_issue()ist jetzt atomar. Invalidieren der alten und Ausstellen der neuen Tokens lief in zwei getrennten Statements; zwei parallele Anforderungen konnten beide erst invalidieren und dann einfügen → zwei gültige Tokens, obwohl „Neuausstellung invalidiert alte" gilt. Jetzt in einer Transaktion (db_tx), mit Test auf „nie mehr als ein offenes Token je (user, purpose)". - fix: E049 —
token_peek()respektiert die Wartefrist. Vorher meldete es ein noch nicht fälliges Token als gültig, die Landing-Page zeigte ein Formular, das der POST dann ablehnte.token_peek()ist jetzt strikt (sicherer Default); für die bewusste Wartefrist-Anzeige („verfügbar ab …", 2FA-Reset) gibt es neutoken_status(). Der bisherige Aufrufer wurde umgestellt, der Testfall auf den neuen Vertrag nachgezogen. - fix: E032 — Klartext-Tokens nicht mehr im Zugriffsprotokoll. Landing-Pages führen das Token im Pfad;
log_access()schrieb ihn roh inaccess_log.pathund hob damit das Token-Hashing aus. Neulog_path_maskieren(): lange Zufallssegmente →[TOKEN], Kennungen wieBTV-S5HBWHGEbleiben lesbar. Restrisiko dokumentiert: der Webserver protokolliert die volle URL weiterhin selbst (nur über Server-Logrotation adressierbar). - 5 neue Tests, 112 Tests grün.
[0.24.0] — 2026-07-16
Paket 4a: SMTP-Header-/Command-Injection geschlossen (B9/E054).
- fix: B9/E054 — Injection über die Empfängeradresse.
$toging mit reinemtrim()inRCPT TO:<…>und denTo:-Header; ein CR/LF darin schleust zusätzliche SMTP-Kommandos oder Header (z. B.Bcc:) ein. Neu:mail_addr_safe()— weist jedes Steuerzeichen ab (bewusst vor dem Trimmen geprüft, weil PHPstrim()\0\r\n\tsonst still wegputzt und der Wert „bereinigt" durchrutscht), prüft Länge und Format, normalisiert. Angewandt inqueue_mail()(vergiftete Adressen kommen gar nicht erst in die Outbox),mail_send_smtp()(auch für den Absender) undmail_send_graph()— überall fail-closed. Betreffzeilen werden von Steuerzeichen befreit. - 4 neue Tests mit echten Angriffs-Mustern (
tests/mail_injection.test.php), 109 Tests grün.
[0.23.0] — 2026-07-16
Paket 3 der Überarbeitung: Scoping + Audit-Dichtheit (B4, B5) — inkl. eines dabei gefundenen Live-Bugs.
- fix: B4 —
require_right_scoped()echt implementiert. War ein Stub, deruser_scopesstill ignorierte: ein Modul hätte dagegen entwickelt und wäre ungeschützt gewesen. Jetzt echte Prüfung mit dokumentierter Semantik (8.3): Scopes verengen ein bereits gehaltenes Recht ·'*'= alle Werte · kein Eintrag = unbeschränkt (Verwaltung). Dazuuser_scope_allows(), die testbarescope_werte_erlauben()undscope_filter_werte()als Listen-Filter für Modul-Übersichten. - fix: B5 — Audit prüft jetzt auch Werte, nicht nur Schlüssel (
audit_assert_value_safe): erkennt IBAN, Passwort-Hashes und Token/Geheimnisse unabhängig vom Schlüsselnamen; Muster bewusst konservativ, damit deutsche Fließtexte nicht anschlagen. - fix: E072 + Live-Bug — Blockliste war zu grob.
'konto'traf als Teilstring'kontostatus'; damit warfaudit('login.blocked', …, ['kontostatus' => …])inlogin.php:49— die Anmeldung an einem gesperrten Konto endete im 500er statt in der freundlichen Meldung. Blockliste jetzt präzise (kontonummer/kontoinhaber/bankverbindungstattkonto/bank,passwort/pwdstattpass);'code'bleibt bewusst grob (schützt die Notfall-Codes). Regressionstest ergänzt. - 8 neue Tests (
tests/scoping_audit.test.php), 105 Tests grün.
[0.22.0] — 2026-07-16
Paket 2 der Überarbeitung: Rechte-Eskalation geschlossen (B3-Rest, N-DELTA-1/2/3).
- fix: N-DELTA-1/E076 —
repo_admin_rights()vollständig.users.view,users.reviewundsystem.viewgalten nicht als Verwaltungsrechte. Dadurch konnte einusers.manage-Inhaber (z. B. GS-Admin) die Rollebtv_hauptberuf— die genau diese Rechte trägt — an beliebige Konten vergeben. Jetzt sind alle Verwaltungs-/Einsichtsrechte erfasst; solche Rollen vergibt nur ein Admin (settings.manage). - fix: E076 — Nicht-Eskalations-Gate. Niemand kann eine Rolle vergeben, die Rechte enthält, die er selbst nicht hat (greift auch für künftige, selbst angelegte Rollen).
- fix: E077 — interne Rollen nur an SSO-Konten. Neue Rollen-Eigenschaft
internal_only(Migration 0005, Marker an der Rolle statt hartkodierter Schlüssel → White-Label-konform);btv_hauptberufist intern,adminbewusst nicht (Anker-/Dienstleister-Konten). - fix: E078 — keine Selbst-Rollenvergabe. Eigene Rollen sind in der Verwaltung nicht mehr änderbar.
- fix: N-DELTA-3/E086 — Sperren/Deaktivieren jetzt geschützt wie das Löschen: kein Selbst-Sperren, kein Anker-Admin, kein letztes aktives Admin-Konto (
repo_admin_count()); der Letzter-Admin-Schutz gilt jetzt auch beim Löschen. - fix: N-DELTA-2 — Self-Lockout verhindert. Der Admin-Rolle können
settings.manage/roles.managenicht mehr entzogen werden (repo_role_locked_rights()), sonst könnte niemand mehr Rollen oder Einstellungen verwalten. - 6 neue Tests (
tests/rechte_eskalation.test.php), 97 Tests grün.
[0.21.0] — 2026-07-16
Paket 1 der Überarbeitung: Nutzer-Sackgassen beseitigt (B1, B2, P10-2).
- fix: B1 — unerreichbares Konto behoben. Ein Konto-Rumpf mit bestätigter E-Mail, aber ohne Passwort (Abbruch nach der Bestätigung) galt bei erneuter Registrierung als „Dublette" → „Konto existiert, bitte anmelden", obwohl weder Anmeldung (kein Passwort) noch „Passwort vergessen" (greift nur mit Hash) möglich waren. Jetzt: Registrierung fortsetzen über die vorhandene Resume-Strecke — sowohl bei erneuter Registrierung als auch über „Passwort vergessen". Entscheidung in der puren, getesteten Funktion
registrierung_weg(). - fix: B2 — 2FA-Reset erreichbar. Wer Handy und Notfall-Codes verloren hat, saß fest:
/2fa-resetwar von keiner Anmelde-/2FA-Seite verlinkt. Jetzt Link auf der Anmeldeseite und auf der Anmelde-Code-Seite (auch im Notfall-Code-Modus). - fix: P10-2 — Warteseite. Bei erkanntem Tippfehler ist die Korrektur jetzt der Primär-Klick („An <korrigiert> senden"); „Erneut senden" wird zurückgestuft (erneutes Senden an die falsche Adresse hilft nicht).
- Hinweis: E044 war ein Symptom von B1 — der CTA „Neuen Link anfordern" existierte bereits seit v0.14, lief aber ins Leere; mit dem B1-Fix funktioniert der Weg.
- 4 neue Tests (
tests/registrierung_weg.test.php), 91 Tests grün.
[0.20.0] — 2026-07-15
Hintergrundbild + Verwaltungs-Listen-Feinschliff:
- core: Turnverband-Hintergrundbild (
bg-turnverband.webp, 22 KB) als dezenter, fixierter Seitenhintergrund — global auf allen Seiten (zunächst zum Ansehen); ersetzt den Platzhalter-SVG auf der Anmeldeseite. - core: Benutzerliste mit farbigen Status-Badges (Zugang/Prüfung/Typ) statt Rohtext — orientiert an den Mockups.
[0.19.0] — 2026-07-15
Rollen/Rechte-Modell, Einstellungs-Reiter, Audit mit Rolle:
- core: Rechte mit Klarname + Beschreibung im Rollen-Editor (was bewirkt das Recht) statt nur technischer Schlüssel.
- core: Rollenverwaltung in die Benutzerverwaltung verschoben (
/verwaltung/rollen) und mit neuem Rechtroles.manageabgesichert — nur Admins. Rollen-Zuweisung/-Bestätigung an Personen bleibt beiusers.*. - core: Neue Systemrolle „GS-Admin" = normale Verwalterrolle (Benutzer verwalten, Freigaben/Rollen bestätigen, Nachrichten senden, Protokoll/Systemstatus). Ohne Einstellungen/Rollenverwaltung. (Migration 0004.)
- core: Einstellungsseite mit Reitern (Allgemein / Inhalte & Texte / später Module) + editierbare Systemwerte (Hilfe-/Team-Adresse, Freigabe-SLA-Text, 2FA-Self-Reset an/Frist, SSO-Domains, aktive Module). Modul-Reiter-Hook
<modul>_settings_tab(). - core: Audit protokolliert die handelnde Rolle (
audit_log.actor_role) — mit welcher Rolle eine Änderung/Nachricht ausgelöst wurde; sichtbar im Zugriffsprotokoll und im Konto-Verlauf.
[0.18.0] — 2026-07-15
Nachrichten, UI-Feinschliff, Rollen-Editor, Löschbestätigung:
- core: Nachrichten-System (Admin ↔ Nutzer) — Admin schreibt aus der Benutzerverwaltung; die Nachricht erscheint im Portal-Postfach (
/nachrichten, Kopf-Icon + Ungelesen-Badge) und kommt per E-Mail; Nutzer können antworten, Antworten laufen in der Verwaltung zusammen (E-Mail an die Hilfe-Adresse). Neue Tabelleuser_messages(Migration 0003), Router-Platzhalter{thread_id}. - fix: riesiger Anmelde-Button —
.icohatte keine Basisgröße und füllte den Button; Basis-Icon-Größe ergänzt (visuell verifiziert). - core: Rollen editierbar — der Rollen-Editor kann jetzt auch den angezeigten Namen ändern (alle Rollen, auch System; Schlüssel bleibt fest). Rolle „Referent/in bzw. Übungsleiter/in" → „Referent/in".
- core: Freigaben statt „Klärfälle" (verständlicherer Name; Registrierungen prüfen + ausstehende Rollen-Bestätigungen).
- core: Zweite Lösch-Bestätigung — eigene Bestätigungsseite („Wirklich löschen?") vor der endgültigen Löschung;
data-confirmwirkt jetzt auch auf Buttons/Links. - core: Anmeldeseite — „Honorarabrechnungen einreichen"; „Lizenzen & Fristen" als geplant markiert + Klarstellung, dass die BTV-Lizenzverwaltung nicht ersetzt wird; Hinweis, dass Daten über Schnittstellen mit anderen BTV-Systemen synchronisiert werden.
- core: Geburtsdatum im deutschen Format (Anzeige TT.MM.JJJJ, Eingabe als Datumsfeld).
[0.17.0] — 2026-07-15
Fixes (Mobil, Checkbox) + Benutzer löschen:
- fix: Datenschutz-Checkbox endgültig behoben —
.form label{display:block}(btv-ui.css) schlug per Spezifität den Flex; jetzt.form label.radio-row{display:flex}+ Checkboxdisplay:inline-block;width:auto(visuell verifiziert). - fix: Mobile Darstellung — Tabellen (
.check-table/.grid) werden bei ≤640px horizontal scrollbar (statt die Seite zu sprengen); Layout-Kindermin-width:0; Header-Icons brechen um;grid-2/daten-grideinspaltig; veraltete.topnav-Mobile-Regeln durch.headericons/.apptop-rightersetzt. (Headless-Chrome-Render geprüft: kein Seiten-Überlauf.) - core: Benutzer löschen (Admin, Recht
users.manage) — „Gefahrenzone" auf der Konto-Detailseite; löscht Konto +user_*-Kindtabellen (Kaskade), Audit/Zugriffs-/Mail-Protokolle bleiben (12.5); Schutz gegen Selbst- und Anker-Admin-Löschung; Audituser.geloescht. Bei abrechnungsrelevanten Daten später Pseudonymisierung (§147 AO).
[0.16.0] — 2026-07-15
Optimierungsschleife 3 (Rechtstexte, Verwaltung, Klärfälle, Breadcrumb, Fixes):
- core: Rechtstext-Entwürfe Impressum, Datenschutz, Erklärung zur Barrierefreiheit (neu,
/barrierefreiheit) — nach Vorlage der BTV-Hauptseite, auf das Portal angepasst, als HTML inapp/legal/*.html(im Content-Editor bearbeitbar); jeweils mit deutlichem „Entwurf — vom BTV freizugeben"-Hinweis und Platzhaltern. Footer-Link ergänzt. - core: Benutzerverwaltung — Stammdaten sicht- und änderbar (Recht
users.manage): Person/Adresse editierbar, Bank/Zuordnung als Hinweis; eine Transaktion + Audit (user.stammdaten_geaendert). - core: Klärfälle zeigen jetzt „Ausstehende Rollen-Bestätigungen" (selbst gewählte, unbestätigte Rollen wie Referent/in); Header-Badge zählt sie mit.
- core: Breadcrumb-Navigation in der Shell — datengetrieben aus Kernbereichen + Modul-Nav (
<modul>_nav()), damit spätere Module automatisch eine Sektion erhalten. - core: Registrierung/Anmeldung ist die öffentliche Startseite (bereits 0.15); Registrierung dort integriert.
- fix: Datenschutz-Checkbox (Registrierung) — Text rutschte in die nächste Zeile (Checkbox erbte
width:100%); jetzt natürliche Breite. - fix: Entra-Profilbild-Abruf — nutzt jetzt einen eigenen App-Token der SSO-App statt der Mail-App (deren Zugriff ist per Access-Policy aufs Absender-Postfach beschränkt). Braucht
ProfilePhoto.ReadWrite.All(Application) + Admin-Consent auf der SSO-App (O3-C). - core: Kontakt-/Hilfeadresse
info@btv-turnen.de(Default).
[0.15.0] — 2026-07-15
UI-Optimierungsschleife 2 (Ansprache, Auth-Seite, Navigation, Stammdaten):
- core: durchgaengige „Du"-Ansprache, genderneutral — gesamtes Portal (Views, Controller, E-Mails, Fehlermeldungen, Default-Inhalte) von Sie auf wertschaetzendes Du umgestellt; genderneutrale Rollen-/Personenbegriffe; im Konzept 4.7 als verbindliche Regel fuer alle kuenftigen Module verankert
- core: Registrierung in die Auth-Seite integriert — gemeinsame
auth-View mit Tabs „Anmelden"/„Registrieren";/(oeffentliche Startseite) = diese Anmelde-/Registrierungsseite (SSO-first) - core: Benutzerverwaltung zurueck in die linke Sidebar (Kernfunktion oben, darunter Module); die vier uebrigen Kernfunktionen (Klaerfaelle, Zugriffsprotokoll, Systemstatus, Hilfe) bleiben in der Kopfzeile, jetzt nur als Icons, rechtsbuendig
- core: Profil zeigt alle Stammdatenfelder (Person, Adresse, Bankverbindung, BTV-Zuordnung) — auch leere („noch nicht hinterlegt") mit Vollstaendigkeits-Badge; IBAN nie im Klartext (nur Vorhanden-Hinweis)
- core: Entra-Profilbild-Sync scharf (
entra.photo_sync=true,ProfilePhoto.ReadWrite.Allgesetzt — O3-C erledigt) - core: Kontakt-/Hilfeadresse Default
info@btv-turnen.de - core: Auth-Seite mit dezenter Deko-Grafik (Platzhalter fuer finales Illustrations-Asset)
[0.14.0] — 2026-07-15
UI-Optimierungsschleife (Navigation, Begruessung, Login, Avatar):
- core: Navigation umgebaut — Kern-Portalfunktionen (Benutzerverwaltung, Klaerfaelle, Zugriffsprotokoll, Systemstatus, Hilfe) in die Kopfzeile; Nutzername/Avatar -> Nutzermenue (Mein Profil & Einstellungen); Admin-Einstellungen ueber Zahnrad-Icon; linke Sidebar jetzt fuer Module reserviert (Aufklapp-Untermenues via
<modul>_nav()children), im MVP ausgeblendet - core: „Ansicht als Rolle"-Umschalter fuer Admins (wie Digitalportal) — nur Sicht, keine Rechte-Eskalation (immer Teilmenge), mit Ansichts-Banner + Zurueckschalten
- core: wertschaetzende Begruessung nach Tageszeit + erster Besuch heute vs. wiederkehrend
- core: Anmeldeseite nach Bild-5 — zweispaltig (Willkommen + Feature-Cards | Auth-Card mit SSO-first, E-Mail/Passwort, 2FA-Hinweis, Registrieren-Tab, Hilfe-Footer)
- core: Profilbild wird nach Upload auf 320x320 heruntergerendert (zentriert quadratisch, WebP/gd); Entra-Foto-Sync (Abruf beim SSO-Login + Rueckschreiben nach Aenderung) implementiert — gated per
entra.photo_sync, braucht Graph-PermissionProfilePhoto.ReadWrite.All(O3-C, sonst No-Op)
[0.13.0] — 2026-07-14
UI-Shell + Profilbild (orientiert an den Mockups, Konzept 4.7):
- core: Portal-Shell fuer angemeldete Seiten — feste linke Sidebar (datengetrieben aus Core-Rechten +
<modul>_nav()) + Topbar mit Nutzer-Chip/Avatar/Rolle; oeffentliche/Onboarding-Seiten behalten das zentrierte Layout - core:
nav.php—portal_nav(), Inline-SVG-Icons (CSP-konform), Rollen-Label, Initialen,portal_avatar() - core: Profilbild (Avatar) — Upload auf /profil (JPG/PNG/WebP, max 3 MB, finfo-MIME-Pruefung), Ablage ausserhalb des Webroots (storage/avatars/), Auslieferung nur ueber /profil/bild; Anzeige in Topbar + Profil-Header, Initialen als Fallback (Migration 0002
profiles.avatar_ext) - core: semantische Status-Badge-Klassen (fuer spaetere Vorgangslisten), mobile Sidebar-Kollaps
- docs: Kap. 4.7 „Portal-Shell & Modul-Oberflaechenintegration" ins Konzept (Modul-Hooks _nav/_widgets, Vorgangs-Aggregator, Notifications, Dokumenten-Panel); Konzept v1.10
[0.12.0] — 2026-07-14
Block 12 „Härtung + Livegang" — Code-Teil (Konzept Kap. 16, 12.x, 13.6):
- core: Löschkonzept scharf (12.5.4) — alle Aufbewahrungsklassen als Cron-Jobs:
mails12 Monate,access_log90 T,job_runs90 T, Trusted Devices 30 T nach Ablauf,phoenix_matches.candidates_json(Dritt-Daten) bei Klärung/90 T — zusätzlich zu den bestehenden (login_attempts, auth_tokens, form_drafts, Rümpfe, unvollständige Registrierungen) - core: Security-Header verschärft — CSP um
style-src 'self'/img-src 'self' data:ergänzt,X-Frame-Options: DENY,Permissions-Policy(Kamera/Mikro/Geo/… aus),X-Permitted-Cross-Domain-Policies, HSTS mitpreload - core: Barrierefreiheit (13.6) — Skip-Link,
<main id="inhalt">-Landmark,role="alert"/role="status"auf Meldungen (23 Views), sichtbarer:focus-visible-Fokus (autocomplete/Labels waren bereits durchgängig) - docs:
SECURITY-REVIEW.md— interner Security-Review gegen den Härtungskatalog (Auth/2FA/Krypto/Tokens/OIDC/Web/DSGVO/Barrierefreiheit, im Code verifiziert) + operative Livegang-Checkliste (Cron, DNS/TLS, SPF/DKIM/DMARC, Entra/Phönix/Erst-Admins, Rechtstexte, ZAP-Baseline, Restore-Test mit TOTP-Kanarienwert, externer Check O17) - core: 3 neue Tests (Cleanup-Registry vollständig, Retention-Löschung, Kandidaten-Purge) — 86 gesamt
Damit ist der MVP-Code aller 12 Blöcke fertig. Der eigentliche Livegang
(1.0.0) folgt nach der operativen Checkliste und den Zulieferungen O3/O4/O5/O7.
[0.11.0] — 2026-07-14
Block 11 „Hilfe & Inhalte" (Konzept 13.4, 12.5.5, Kap. 11):
- core:
content.php— pflegbare Text-/Mail-Bausteine übersettings(content.<key>) mit sinnvollen deutschen Defaults; Registry steuert den Editor; Hilfe-Adresse (O14) und Mail-Signatur zentral - core: Hilfe & Kontakt (
/hilfe) mit Hilfe-Adresse undpublic_idals Support-Referenz für Angemeldete; FAQ (/faq) mit den fünf Top-Fällen (13.4): Mail nicht angekommen · Authenticator einrichten · neues Handy/Codes weg · Passwort vergessen · Konto löschen/Auskunft - core: Impressum und Datenschutzerklärung als strukturierte Seiten mit klarem O7-Platzhalterhinweis (Texte liefert BTV/Projekt29 vor Livegang)
- core: statische Wege Konto löschen (
/konto-loeschen) und Datenauskunft (/datenauskunft) — je eine E-Mail an die Hilfe-Adresse (12.5.5, Selbstservice-Export bereits in der Verwaltung) - core: Content-Editor unter
/einstellungen/inhalte(Rechtsettings.manage) — alle Textblöcke + Hilfe-Adresse bearbeitbar; verifiziert, dass Änderungen sofort auf den öffentlichen Seiten wirken - core: Startseiten-Intro aus
settings; Platzhalter-Controller entfernt; Footer „Hilfe & Kontakt" auf jeder Seite (bereits im Layout) - core: 4 neue Tests (Registry, FAQ-Top-Fälle, Defaults, Settings-Override) — 83 gesamt; alle Seiten + Editor end-to-end verifiziert
[0.10.0] — 2026-07-14
Block 10 „Verwaltung + Startseiten" (Konzept 8.4/8.5/8.6, 7.4, 12.5.5):
- core:
account_state.php— Kontozustände als orthogonale Dimensionen (8.5), zwei Label-Sätze (intern exakt/extern laienverständlich, 13.5), Aufgabenliste für die eingeschränkte Startseite - core: Startseite (8.6) — vollständig freigegeben → Kacheln nach Rechten (mit Klärfall-Zähler-Badge für Interne); nicht freigegeben → eingeschränkte Ansicht mit Aufgabenliste, Erklärtext, Ziel-Bearbeitungszeit und E-Mail-Zusicherung
- core: Benutzerverwaltung (8.4) — Suche (Name/E-Mail/
public_id) + Filter, Detailseite mit allen Zuständen; Rollen einsehen/bestätigen/vergeben/entziehen (Rollen mit Verwaltungs-Rechten nur durch Admin), Konto sperren/entsperren/deaktivieren (session_epoch++, wirkt sofort), Prüfstatus setzen, Admin-2FA-Reset (mit dokumentierter Identitätsprüfung), Audit-Historie je Konto - core: Klärfall-Queue (8.4) — nach Alter sortiert mit Badges, Kandidaten-Gegenüberstellung (Nutzereingabe ↔ Phoenix-Kandidaten mit Auswahl), Ein-Klick-Auflösung mit Zuordnung, Sammelfreigabe, Zähler auf der internen Startseite; täglicher Digest ans Prüfteam (O15, Cron-Job)
- core: Datenauskunfts-Export (12.5.5) — JSON je Konto (Profil, Rollen, Status, Phoenix, Mail-Historie, Audit; ohne
password_hash), deckt Art. 15/20 ab - core: Self-Service-2FA-Reset (7.4, O12) —
reset_2fa-Token mit Sicherheits-Wartefrist (not_before, 24 h), sofortige Benachrichtigung mit „Das war ich nicht"-Abbruchlink; nach Ablauf geführte Neu-Einrichtung inkl.session_epoch++ - core: Rollen-Editor (8.2) unter
/einstellungen— Rollen anlegen (portalweit oder je Modul), Rechte per Checkliste (gefiltert gegen den Katalog, Verwaltungs-Rechte markiert), System-Rollen nicht löschbar;/zugriffe(Audit-Protokoll, Rechtaudit.view) - core: 8 neue Tests (Kontozustand/Labels, Suche, Rollen, Statuswechsel-Epoch, Klärfall-Zähler, Rollen-Editor) — 79 gesamt; alle Admin-Strecken + Self-Service-Reset end-to-end verifiziert
[0.9.0] — 2026-07-14
Block 9 „Phoenix" (Konzept Kap. 9):
- core:
app/integrations/phoenix.php— it4sport-Client mit Modi simulate/test/live, nur lesend,phoenix_find_person_by_email()/phoenix_get_person(), Timeouts ≤ 10 s; simulate-Fixtures für alle vier Ergebnispfade (Adress-Kennung+eindeutig/+mehrdeutig/+fehler/sonst); test/live-REST-Client vorbereitet (exakter Personensuch-Endpunkt = O5) - core: Abgleich im Registrierungs-Wizard vor der Stammdatenerfassung (9.3); Fehler blockieren nie (→
fehler+ Selbst-Erfassung + Retry-Job) - core: vier Ergebnispfade — eindeutig (Vorbefüllung, aktive Bestätigung,
phoenix_person_idwird führend) · mehrdeutig (keine Auto-Zuordnung, Kandidaten nur intern, Klärfallpruefung_erforderlich) · kein_treffer / fehler (Selbst-Erfassung) - core: Geburtsdatum-Härtung (9.3) — bei Treffer nicht vorbefüllt, sondern zur Bestätigung eingegeben und gegen die Mitgliederverwaltung geprüft; Abweichung → Klärfall statt Zuordnung
- core: Nutzer-Wording ohne Systemnamen (13.5); Bestätigungs-Snapshot mit Quelle (phoenix/selbst, 12.5.3); Adresse optional vorbefüllbar
- core: Re-Match-/Führungsregeln (9.4) —
phoenix_person_idführend, E-Mail-Änderung löst keinen Re-Match aus; Retry-Job wiederholtfehler-Läufe und überschreibt bestätigte Daten NIE (nur Referenz + Prüfhinweisphoenix.nachtreffer) - core: Datums-Helfer
valid_datum()/datum_iso()(TT.MM.JJJJ + ISO, keine Zukunft); Selfcheck-Zeile Phönix-Modus - core: 8 neue Tests (vier Pfade, Fixtures, Datums-Härtung) — 71 gesamt; alle vier Wizard-Pfade + Abschluss-Freigabe + Retry-Regel end-to-end verifiziert
[0.8.0] — 2026-07-14
Block 8 „Entra-SSO intern" (Konzept 6.2, Checkliste 6.2.2):
- core:
oidc.php— gehärteter, dependency-freier OIDC-Client: Authorization Code Flow + PKCE S256, State/Nonce einmalig (sofortiges unset, kein Callback-Replay),alg-Header explizit RS256, Signaturprüfung gegen JWKS (RSA-PEM aus JWK dependency-frei), iss/aud/exp/iat/nbf mit Uhren-Toleranz, tid-Prüfung gegen den Tenant; Discovery + JWKS 24 h dateigecacht; nuropenid profile email— keine Graph-Tokens in der Session - core: Konto-Zuordnung ausschließlich über
entra_oid— E-Mail-Match verknüpft nie: bestehendes lokales Konto → Login abgewiesen + Klärfall (pruefung_erforderlich), unbestätigte Rümpfe werden verworfen; Autoanlage nur Tenant-Member (kein Gast/#EXT#), optional an die Gruppe „BTV-Portal-Nutzer" gebunden (O3); neue Konten:btv_hauptberuf(source sso, bestätigt), sofort freigegeben; E-Mail/Name folgen dem Entra-Konto - core:
amr-Claim wird als MFA-Nachweis protokolliert;twofa_passedbeim Callback (2FA erbringt der IdP); IdP-Fehler (error-Param) mit freundlicher Seite - core: Erst-/Notfall-Admin-Anker (8.1):
emergency_admin_oidsaus config.php wirkt nur für Entra-Konten, perentra_oid, über die Web-UI nicht entziehbar - core: Selfcheck — Entra-Konfiguration (O3) + Ablaufwarnung Client-Secret (30 Tage); Login-Seite mit „Anmelden mit BTV-Konto (Microsoft)"
- core: 15 neue Tests (JWT-Verifier: gültig/none/HS256/kid/Signatur/exp/aud/tid/nonce/Toleranz · Zuordnung: Provisionierung, oid-Wiedererkennung, Gast, Kollision→Klärfall, Rumpf-Übernahme) — 64 gesamt
- Hinweis: Der Live-Roundtrip gegen Entra braucht die O3-Zulieferung (M3/Staging) — alle prüfbaren Teile sind lokal getestet
[0.7.0] — 2026-07-14
Block 7 „Passwort- & E-Mail-Strecken + Formularrettung" (Konzept 6.4/6.5, 13.3):
- core: Passwort vergessen — immer neutrale Antwort (Konto/Nicht-Konto identisch, Versand über die Outbox), Reset-Link 60 min, Policy-Fehler verbrauchen den Token nicht, Reset →
session_epoch++(alle Sessions/Geräte raus) + Info-Mail; ein Reset umgeht die 2FA nie; SSO-Konten erhalten den Microsoft-Verweis - core: Passwort ändern (angemeldet) — aktuelles Passwort nötig,
session_epoch++, eigene Session zieht nach, andere sterben; Info-Mail + Audit - core: E-Mail-Adresse ändern (6.5) — Re-Auth (Passwort + Anmelde-Code), Bestätigungslink an die NEUE Adresse (Konflikt neutral mit Hinweis-Mail), Umstellung erst nach POST-Einlösung, Info an die ALTE Adresse mit Widerspruchshinweis,
session_epoch++; Phoenix-Regel 9.4 (kein Re-Match) dokumentiert - core: Formulardaten-Rettung (13.3) — POST mit abgelaufener Sitzung sichert die Eingaben (24 h, Cookie- + Konto-gebunden, sensible Felder werden NIE gespeichert), nach dem Re-Login geht es direkt auf dem vorbefüllten Formular weiter; Cleanup-Job
- core:
/profil-Übersicht mit Strecken-Kacheln (SSO-Konten sehen den Microsoft-Hinweis) - core: Bugfix: Idle-/Absolut-Timeout griff nur für Dev-Sessions — echte Konten-Sessions liefen nie ab; Idle-Minuten hängen jetzt am Kontotyp (intern 30 / extern 60, O9)
- core: 3 neue Tests (Sensibel-Filter, Draft-Roundtrip, Redirect-Ziel-Härtung) — 49 gesamt
[0.6.0] — 2026-07-14
Block 6 „Registrierungs-Wizard" (Konzept 6.3, O6):
- core: Zustandsmaschine mit 6 sichtbaren Etappen („Schritt x von 6") — der Fortschritt liegt am Konto, nie in der Session; Abbruch/Wiedereinstieg/Gerätewechsel jederzeit möglich
- core: „Das brauchen Sie"-Seite vor Etappe 1 (Dauer, Postfach, Smartphone)
- core: Etappe 1 mit Datenschutz-Checkbox (Zeitpunkt + Dokumentversion), Tenant-Domain-Sperre mit SSO-Verweis, neutraler Dubletten-/Rumpf-Behandlung (identischer Bildschirm; Bestandskonten erhalten eine Hinweis-Mail, unbestätigte Rümpfe einen neuen Link — Übernahme durch den echten Postfach-Inhaber)
- core: Warteseite mit großer Adressanzeige, Adresse korrigieren, Tippfehler-Vorschlag (gmial→gmail …), „Erneut senden" mit sichtbarem Countdown, Spam-Hinweis mit Absender, „Ich habe bestätigt — weiter" (nur solange kein Passwort gesetzt ist)
- core: Landing-Pages mit POST-Einlösung für Bestätigen + Fortsetzen (Safe-Links-fest); Gerätewechsel: der Wizard läuft auf dem Gerät weiter, das den Link öffnet
- core: Etappen 2–5 — Passwort (Policy), 2FA (Block-5-Strecke, kehrt in den Wizard zurück), Rollen-Selbstauswahl (nur
selectable, unbestätigt), Stammdaten mit aktiver Bestätigung + Snapshot (Feldwerte, Quelle, Textversion/Hash, Zeit, IP — 12.5.3) - core: Etappe 6 — automatische Freigabe unauffälliger Registrierungen (
review_status='freigegeben', O6/8.1 v1.9); Klärfälle entstehen erst mit dem Mitgliederabgleich (Block 9) - core: Lebenszyklus-Jobs — Rümpfe 72 h, unvollständige Registrierungen 30 Tage, Erinnerung an Tag 23 mit 7-Tage-Fortsetzungslink (genau einmal je Konto)
- core: 3 neue Tests (Zustandsmaschine) — 46 gesamt
[0.5.0] — 2026-07-14
Block 5 „2FA" (Konzept Kap. 7, 12.3):
- core:
crypto.php— sodium secretbox im Format keyid‖nonce‖ciphertext, Key-Map mit Rotation (php cron.php reencrypt), Kanarienwert im Selfcheck - core:
totp.php— RFC-6238-TOTP dependency-frei (HMAC-SHA1, 6 Ziffern, 30 s, Toleranz ±1,hash_equals), gegen die offiziellen RFC-Testvektoren getestet; Replay-Schutz überlast_used_step; Brute-Force-Limits pro Konto (5 Versuche → progressive Sperre) - core: Pending-Session ≤ 5 min zwischen Passwort und Code (6.1); Login ohne eingerichtete 2FA führt zwingend ins Onboarding
- core: Onboarding mit drei gleichwertigen Wegen —
otpauth://-Button (ein Gerät genügt), lokal gerenderter QR-Code (phpqrcode, kein externer Dienst), manueller Schlüssel;Cache-Control: no-storeauf allen Geheimnis-Seiten - core: Recovery-Codes — 10 Einmal-Codes (gehasht, Batch), Anzeige genau einmal, PDF (tFPDF), Pflicht-Checkbox; Nachgenerieren unter
/profil/2famit Re-Auth (Passwort + Code); Recovery-Login erzwingt Neu-Einrichtung inkl.session_epoch++(alte Sessions + vertraute Geräte verfallen) und Info-Mail mit Restanzahl - core: „Diesem Gerät 30 Tage vertrauen" — Cookie-Token gehasht in
user_trusted_devices, ansession_epochgebunden, serverseitig widerrufbar (/profil/2fa) - core: Vendor-Bausteine
lib/phpqrcode/,lib/tfpdf/(aus btv-akkreditierung übernommen) - core: 14 neue Tests (RFC-Vektoren, Base32, Toleranz/Replay, Krypto-Roundtrip/Manipulation, Recovery-Batches) — 43 gesamt
[0.4.0] — 2026-07-14
Block 4 „Externer Login" (Konzept Kap. 6.1/6.4, 12.1/12.2):
- core:
password.php— Argon2id viapassword_hash()mit sodium-Fallback, Rehash-Migration beim Login, Policy (min. 12 Zeichen, lokale Top-Passwortliste, keine Sonderzeichen-Pflicht), Dummy-Vergleich für Timing-Gleichheit - core:
tokens.php— verbindliche auth_tokens-Semantik: nur Hashes, atomarer Verbrauch (rowCount===1), Neuausstellung invalidiert,not_before-Wartefrist (Fristen rechnet die DB — eine Uhr), Landing-Page + POST - core:
/loginmit neutralen Fehlern (Antwort + Timing identisch, Konto-Enumeration ausgeschlossen), harte Zugangsschalter (gesperrt/deaktiviert) - core: Login-Rate-Limit — Fehlversuche je E-Mail/IP-Bucket (IPv6 /64), progressive Verzögerung 2/4/8/15 min (Deckel), ehrliche Lockout-Meldung, Entsperr-Link per Mail (max. 1/h je Konto, Unterdrückung im Audit), keine harte Kontosperre durch Fremd-Fehlversuche
- core: echte Konten-Sessions — nur
user_id+session_epochin der Session, Konto je Request frisch aus der DB (Sperrung/Passwortänderung wirkt sofort); Dev-Login bleibt getrennt - core:
repo_user_create_local()mit public_id-Kollisions-Retry; Rollen für Rechte nurconfirmed=1(8.1) - core: Cleanup-Jobs
login_attempts(30 Tage) + abgelaufeneauth_tokens - core: 13 neue Tests (Policy, Hash-Roundtrip, ip_bucket, Delay-Progression, Token-Atomik/not_before/TTL-Validierung) — 29 gesamt
[0.3.0] — 2026-07-14
Block 3 „Mail-Outbox" (Konzept Kap. 11):
- core:
mailer.phpals Outbox —queue_mail()schreibt nur inmails, Versand ausschließlich über den Cron-Worker (kein Synchronversand, kein Timing-Leak) - core: Worker mit Backoff-Retry (2^n min, Deckel 60; max. 8 Versuche →
status='error'+ Auditmail.failed) - core: Token-Redaktion —
queue_mail(…, ['redact' => [$token]])speichert eine redigierte Fassung; nach Versand/endgültigem Fehlschlag ersetzt sie den Klartext-Body (Migrationmigrate_0001fügtbody_redactedhinzu) - core: Versandwege
simulate(Datei unter storage/mails/),smtp(dependency-frei, STARTTLS/SSL, AUTH LOGIN) undgraph(App-only sendMail, O3 App B) - core:
repo_schema_install()setzt jetzt eine Migrations-Baseline (Gesamtstand zählt vorhandene Migrationen als angewendet) - core: Selfcheck — Mail-Modus, dauerhaft fehlgeschlagene Mails, Outbox-Stau
- core: 3 neue Tests (Redaktion, Backoff-Progression) — 16 gesamt
[0.2.0] — 2026-07-14
Block 2 „Schema + Konto-Basis" (Konzept Kap. 16):
- core: Gesamtschema
sql/schema.sqlnach Konzept 5.2 — 20 Tabellen inkl. vorbereiteter Felder (users, profiles, roles/user_roles/role_rights, user_scopes, user_totp, user_recovery_codes, user_trusted_devices, auth_tokens, login_attempts, phoenix_matches, documents, form_drafts, audit_log, access_log, mails, settings, schema_migrations, job_runs) - core: Seeds — 4 System-Rollen (
is_system, referent/ehrenamtselectable) + Rechte-Katalog 8.2 +modules_enabled - core: Installations-/Migrations-Mechanik (5.3):
php cron.php install(nur leere DB) undphp cron.php migrate(explizit, protokolliert inschema_migrations; Selfcheck meldet nur) - core: Audit-Log
audit.phpmit erzwungener Inhalts-Blockliste (audit_assert_safe()wirft bei Passwort/Token/Code/IBAN/Phoenix-Rohdaten — 12.4) - core: Rechteprüfung liest jetzt aus
role_rights(DB) mit Request-Cache; statischer Katalog nur noch als Fallback ohne Schema - core: Selfcheck erweitert (Schema installiert, Rollen-Seeds); access_log aktiv
- core: O8-Konten dokumentiert (franz@/it-services@btv-turnen.de) — es fehlen die Entra Object-IDs
[0.1.0] — 2026-07-14
Block 1 „Gerüst + Fundament" (Konzept Kap. 16):
- core: Projektstruktur nach BTV-Webapp-Muster (public/, app/, views/, sql/, storage/, tests/)
- core: Front-Controller mit Routentabelle, Platzhaltern
{id}/{public_id}/{token}und Modul-Registrierung übermodules_enabled(4.5) - core: Helfer
config(),e(),view(),csrf_*,db_tx(),new_public_id()(Alphabet ohne 0/O/1/I),no_store() - core: zentrale Session-Härtung (
__Host--Cookie, Secure/HttpOnly/SameSite=Lax, strict_mode, Idle-/Absolut-Timeout, Dev-Ausnahme) inauth.php - core: Sicherheits-Header inkl. CSP (
script-src 'self',form-action 'self', kein Inline-JS/-Styles) - core: zentraler Error-/Exception-Handler mit CD-500-Seite (
errors.php) - core: Rechte-Guards
require_login()/require_right()/require_right_scoped()mit MVP-Rechtekatalog (8.2; DB-Anbindung folgt mit Block 2) - core: Dev-Login (Rollen-Umschalter) hart an
app_env='dev'gebunden; Logout nur per POST - core: Selfcheck (
/selfcheck, Rechtsystem.view) nach Anhang A und öffentlicher/health-Endpoint (200/500, spiegelt Cron-Ausfall) - core: Cron-Runner
cron.php(run/list/migrate) + Job-Registry mitGET_LOCK()-Schutz, Heartbeat als Dead-Man-Switch (14.5) - core: dependency-freier CLI-Testrunner
php tests/run.phpmit ersten Helfer-Tests (14.8) - core: btv-ui eingebunden (Tokens, Komponenten, Logo) + portal-eigenes
style.css/app.js(data-*-Muster)