Seiten im Browser anlegen und löschen, Adresshierarchie und Beitragstyp

Migration 20260912000400 ergänzt page.path, parent_id, type und
created_at. Die websiteweite Eindeutigkeit wandert vom Slug auf den
vollständigen Pfad, damit gleiche Segmente unter verschiedenen Eltern
möglich sind. Nur ADD COLUMN: Index- oder Fremdschlüsseländerungen
würden SQLite die Tabelle neu aufbauen lassen, was am Fremdschlüssel
revision.page_id scheitert; die Indizes werden in postUp() gesetzt.

POST /admin/pages legt Seiten und Beiträge als privaten Entwurf an
(ROLE_EDITOR), POST /admin/pages/{id}/delete entfernt eine Seite mit
allen Revisionen (ROLE_ADMIN). Beides mit CSRF, ohne JavaScript
bedienbar, mit Schutz für Elternseiten und veraltete Versionen.
Die öffentliche Route bedient verschachtelte Pfade und lässt die
CMS-Routen unberührt.

Testumgebung: Login-Throttling zählt jetzt in einem Array-Cache,
weil die persistierten Zähler die Anmeldung über Testläufe hinweg
aussperrten.

Geprüft: 56 PHP-Tests / 212 Assertions unter PHP 8.2.12 und 8.4.22,
10 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:31:46 +02:00
1 parent 82b07820bc
commit a6ffb9bdfd
16 files changed
+555 -23

No files matched your search

+12
View File
@@ -104,3 +104,15 @@ Schema v3 ergänzt den Container `gallery`, dessen Kinder ausschließlich Bildbl
`layout.effects` erlaubt nur vordefinierte Schatten, Hover-Anheben/Bildzoom sowie Einblenden beim Laden. Effekte gelten geräteübergreifend. Die CSS-Datei wird nur für v3-Seiten eingebunden; es wird kein öffentliches JavaScript ergänzt. Bewegungsregeln gelten ausschließlich bei `prefers-reduced-motion: no-preference`. Inhalte bleiben ohne Animation lesbar; Einblendung ist keine scrollgesteuerte Animation.
Der idempotente Konverter unterstützt nun v1/v2 → v3 und verhindert Downgrades. Erst Einfügen einer Galerie/eines Katalogbildes, eine Bildbeschriftung oder Effektänderung hebt den lokalen Entwurf auf v3. Die Umstellung ist Teil derselben Undo-Transaktion; Speichern erzeugt eine neue Revision. v1/v2 bleiben les- und schreibbar. Kein Massendatenupdate. Rückweg über alte Revisionen oder gemeinsames Backup plus passenden Code; ältere v1/v2-Software darf keine v3-Revisionen laden. Die neue Medientabelle darf beim Rückweg nicht unbedacht gelöscht werden.
## ADR 010 – Seitenerstellung, Hierarchie und Inhaltstypen (12.09.2026)
Seiten konnten bisher nur über die Kommandozeile entstehen, und `page.slug` war websiteweit eindeutig. Damit war weder eine URL-Hierarchie wie `/leistungen/webdesign` noch ein Beitragstyp möglich. Beides gehört in das Datenmodell und wird deshalb jetzt entschieden, solange noch keine Kundeninhalte gespeichert sind; eine spätere Änderung würde eine Migration über echte Kundendaten erzwingen.
Migration `20260912000400` ergänzt `page.path`, `page.parent_id`, `page.type` und `page.created_at`. `slug` bleibt das einzelne Adresssegment, `path` ist die vollständige öffentliche Adresse und trägt die websiteweite Eindeutigkeit über den Index `uniq_page_path (website_id, locale, path)`. Der bisherige eindeutige Index über `slug` entfällt: ein Segment muss nur unter seinen Geschwistern eindeutig sein. Die Tiefe ist auf vier Ebenen begrenzt (`PageRepository::MAX_DEPTH`), Beiträge (`type = post`) nehmen keine Unterseiten auf.
`parent_id` ist bewusst **ohne** Fremdschlüssel angelegt, und die Indexumstellung passiert nicht über die Doctrine-Schemadifferenz, sondern in `postUp()` beziehungsweise `Version20260912000400::applyIndexes()`. Grund: SQLite baut bei Index- oder Fremdschlüsseländerungen die gesamte Tabelle neu auf (`CREATE TEMPORARY TABLE … DROP TABLE page …`), was am Fremdschlüssel `revision.page_id` scheitert. Mit reinen `ADD COLUMN`-Anweisungen bleibt die Migration auf SQLite und MySQL gleichermaßen ein einfacher, nicht tabellenkopierender Schritt. Die Elternbeziehung wird dafür im `PageRepository` innerhalb derselben Transaktion aufgelöst und geprüft. `path` ist auf Datenbankebene `NULL`-fähig, weil der eindeutige Index vor dem Backfill entsteht und wiederholte `NULL`-Werte in SQLite wie in MySQL nicht kollidieren; die Anwendung schreibt niemals eine Seite ohne Pfad, und `postUp()` bricht ab, falls nach dem Backfill noch ein Pfad fehlt.
Anlegen und Löschen laufen über Formulare, nicht über die JSON-API: `POST /admin/pages` benötigt `ROLE_EDITOR`, `POST /admin/pages/{id}/delete` benötigt `ROLE_ADMIN`, beide mit CSRF-Token. Eine neue Seite entsteht als privater Entwurf mit einem gültigen Startdokument (Schema v3, genau eine H1) und wird erst durch bewusstes Veröffentlichen öffentlich. Löschen entfernt alle Revisionen einer Seite, weist aber Seiten mit Unterseiten ab und prüft die erwartete Version, damit keine Inhalte unbemerkt verschwinden. Die reservierten Adressen `admin`, `media`, `assets` und `favicon` sind für Wurzelseiten gesperrt; zusätzlich hält ein Lookahead in der öffentlichen Route die CMS-Pfade aus dem Auffangmuster heraus.
Rückweg: Code-Rollback allein genügt nicht, sobald Unterseiten existieren, weil ältere Software `path` und `parent_id` nicht kennt. Rückweg über das vor der Migration erzeugte Backup (`var/backups/before-hierarchy-*.db`) zusammen mit dem passenden Codestand.