presseportale/dev/archive/2026-09-nutzergewinnung/bestandsaufnahme-code.md
Kevin Adametz 8e3554bf91 dev: Doku aufgeräumt, neue Roadmap als einzige Liste offener Punkte
- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen,
  gegen Code und Git abgeglichen (Stand v1.7.0)
- Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung,
  2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen →
  archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets,
  Go-Live-Roadmap vom 20.07.
- Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update)
  und changelog/ (Decision-Update Changelog)
- dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf
  die neuen Pfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:25:09 +00:00

375 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bestandsaufnahme Code — Stand 01.09.2026
**Zweck:** Ausgangslage vor der Umsetzung von `roadmap-2026.md`. Was ist gesund,
was ist kaputt, was muss vor dem ersten Roadmap-Arbeitspaket geradegezogen
werden.
**Prüfumfang:** 348 PHP-Dateien / ~33.900 Zeilen in `app/`, 108 Migrationen,
201 Testdateien, dazu der gesamte noch nicht committete Arbeitsstand
(41 geänderte + 22 neue Dateien aus den Blöcken „Suchtabelle/FULLTEXT-Umzug",
„410-Tombstone", „API-Portal-Zuordnung", „Bounce-Import").
> **Bearbeitungsstand 01.09.2026:** Abschnitt 7 ist vollständig abgearbeitet.
> Was genau geändert wurde, welche Vorschläge abgewandelt oder verworfen wurden
> und welche Entscheidung noch offen ist, steht in
> **[`umsetzung-protokoll.md`](umsetzung-protokoll.md)**.
>
> **Eine Korrektur an diesem Bericht:** Befund **L6** war falsch — siehe unten.
---
## 1 Werkzeugstand
| Prüfung | Befehl | Ergebnis |
|---|---|---|
| Tests | `php artisan test --compact` | **1479 grün**, 1 übersprungen, 6472 Assertions, 121 s |
| Codestil | `vendor/bin/pint --test` | **passed** |
| Statische Analyse | `vendor/bin/phpstan analyse` | **fehlgeschlagen — 390 Fehler außerhalb der Baseline** |
Die Testabdeckung ist gut und der neue Arbeitsstand ist mitgetestet
(`GoneTombstoneTest`, `PressReleaseSearchIndexTest`, `ApiPortalResolutionTest`,
`FixApiPortalDuplicatesTest`, `ImportNewsletterBouncesTest`,
`OnlineSchemaChangeCommandTest`). Die statische Analyse dagegen ist faktisch
außer Betrieb — siehe P1, das ist der Befund mit dem größten Hebel.
---
## 2 Werkzeug & Prozess
### P1 — Statische Analyse liefert seit Monaten nur noch Rauschen
**Symptom:** 390 Fehler außerhalb der Baseline. Darunter Dutzende Meldungen der
Art „Strict comparison using === between string and
`App\Enums\PressReleaseStatus::Published` will always evaluate to false"
(u. a. `app/Models/PressRelease.php:507`, `app/Services/PressRelease/BoostService.php:45`,
`app/Listeners/ProcessStripeWebhook.php:424`). Zur Laufzeit funktioniert dieser
Code — die Meldungen sind falsch.
**Ursache:** Larastan löst **überhaupt keine Casts auf**. Nachgewiesen mit
`PHPStan\dumpType()`:
```
$pressRelease->status → string (erwartet: PressReleaseStatus)
$pressRelease->portal → string (erwartet: Portal)
$pressRelease->published_at → string|null (erwartet: Carbon|null)
```
Grund: Alle 49 Models deklarieren ihre Casts über die `casts()`-**Methode**
(Laravel-11-Stil). Laravel führt diese Methode erst zur Laufzeit mit
`$this->casts` zusammen (`HasAttributes.php:206`), Larastan instanziiert Models
aber mit `newInstanceWithoutConstructor()` und sieht deshalb nichts. Für genau
diesen Fall gibt es den Schalter `parseModelCastsMethod`, dessen Larastan-Default
`false` ist (`vendor/larastan/larastan/extension.neon:34`) und der in
`phpstan.neon` nicht gesetzt ist.
**Nachgewiesene Wirkung des Schalters:** mit `parseModelCastsMethod: true`
lösen alle drei Typen oben korrekt auf, die Fehlerzahl fällt von 390 auf 266.
**Zweitursache:** Die Baseline (`phpstan-baseline.neon`, 127 Einträge) ist seit
dem 12.05.2026 unverändert — knapp vier Monate Drift. 39 ihrer Einträge greifen
inzwischen ins Leere (`ignore.unmatched`).
**Drittursache:** `composer analyse` existiert, läuft aber in keiner Pipeline.
`.github/workflows/lint.yml` ruft nur Pint auf, `tests.yml` nur die Tests. Nichts
hält die Analyse grün.
**Schritte**
1. `parseModelCastsMethod: true` in `phpstan.neon` setzen.
2. Der Rest der 266 Meldungen ist überwiegend echtes Rauschen ohne Codefehler:
84 × `property.notFound` (`withCount`-Aliasse wie `recent_releases_count`,
Relationen ohne Generic), 50 × `method.notFound` (eigene Scopes wie
`forPortalValues()`, `visibleInArchive()` ohne `@method`-Annotation),
35 × `nullsafe.neverNull`. Das lässt sich mit `@method`-Annotationen auf den
Scopes und `/** @return Builder<static> */` an den Scope-Methoden
substanziell senken, ohne eine Zeile Logik anzufassen.
3. Baseline **danach** neu erzeugen, nicht vorher — sonst zementiert sie die
falschen Cast-Meldungen.
4. `composer analyse` in `tests.yml` aufnehmen. Ohne Gate driftet die Baseline
binnen weniger Wochen erneut.
---
## 3 Befunde — hoch
### H1 — Deploy-Fenster: bis zu 60 s SQL-Fehler in der Suche
`app/Models/PressReleaseSearchIndex.php:75` — `isAvailable()` cacht die Antwort
eine Minute:
```php
return Cache::remember('press-release-search.available', now()->addMinute(), …);
```
Solange `false` gecacht ist, greift der Rückfallpfad
(`PressReleaseSearchIndex.php:110`) und setzt `whereFullText()` **auf
`press_releases`** ab. Läuft in diesem Fenster die Migration
`drop_fulltext_indexes_from_press_releases`, existiert der Index dort nicht mehr
→ MySQL-Fehler 1191 („Can't find FULLTEXT index matching the column list") →
500er auf jeder Suche der Web-Seite und der Admin-PM-Liste, bis der Cache-
Eintrag abläuft.
**Fix:** In `up()` beider Such-Migrationen (`create_press_release_search_table`
und `drop_fulltext_indexes_from_press_releases`)
`Cache::forget('press-release-search.available')` aufrufen. Ergänzend den
Rückfallpfad in `constrain()` gegen `QueryException` absichern und im Fehlerfall
auf die Suchtabelle umschalten.
### H2 — Newsroom-Entfernung flusht den kompletten Seiten-Cache N-mal
`app/Services/Company/CompanyRemovalService.php:61-71` lädt **alle**
Pressemitteilungen der Firma mit `->get()` in den Speicher und ruft je Zeile
`PressReleaseService::markGone()`. Diese Methode flusht in
`PressReleaseService.php:495` über `WebCaches::forgetPublicPages()` jedes Mal:
* `WebPageCache::flush()` → Generations-Zähler +1 → **sämtliche** `web.`-Seiten-Caches verworfen,
* `WebCaches::forgetSitemaps()` → alle Sitemap-Caches je Host.
Bei einer Firma mit 300 Meldungen sind das 300 vollständige Cache-Invalidierungen
in einer Schleife, gefolgt von einer 301. in Zeile 73. Jede davon zwingt die
gesamte öffentliche Seite in den Neuaufbau — ein selbst ausgelöster
Cache-Stampede auf einer Aktion, die aus dem Admin heraus mit einem Klick kommt.
Dazu der unbegrenzte `->get()`.
**Fix:** `markGone()`/`restoreFromGone()` um einen Parameter erweitern, der den
Flush unterdrückt (oder eine interne `markGoneWithoutFlush()`), über
`chunkById()` statt `get()` iterieren und **einmal** am Ende flushen. Zeile 73
macht das ohnehin schon.
### H3 — `restoreFromGone()` aktiviert Firmen, die vorher deaktiviert waren
`app/Services/Company/CompanyRemovalService.php:90-93` setzt beim Aufheben
bedingungslos `is_active = true`. `markGone()` (Zeile 46-49) setzt vorher
bedingungslos `is_active = false`, ohne den Ausgangswert zu sichern.
War die Firma **vor** der 410-Entfernung bereits deaktiviert — im Bestand der
Normalfall bei Karteileichen —, ist sie nach dem Aufheben aktiv und taucht in
Newsroom-Übersicht und Sitemap auf. Die UI verspricht ausdrücklich
Umkehrbarkeit; das ist keine.
**Fix:** Ausgangszustand mitschreiben (z. B. Flag in `activity_log`-Meta oder
eine Spalte `was_active_before_gone`) und beim Aufheben zurücksetzen. Minimal:
`is_active` beim Aufheben gar nicht anfassen und den Redakteur entscheiden
lassen.
### H4 — `no_export` bleibt nach dem Aufheben einer PM-Entfernung stehen
`PressReleaseService::markGone()` (Zeile 480-490) setzt `no_export = true`.
`restoreFromGone()` (Zeile 511-519) setzt nur `gone_at` zurück. Die Mitteilung
ist danach zwar wieder freigegeben, bleibt aber dauerhaft aus allen Exporten
ausgeschlossen — ohne dass das irgendwo sichtbar wäre. Dieselbe Asymmetrie wie
H3, im selben Arbeitspaket.
**Fix:** Entweder `no_export` mit zurücksetzen (dann den Vorzustand sichern, weil
die Spalte auch redaktionell gesetzt sein kann) oder `no_export` in `markGone()`
gar nicht setzen — `gone_at` filtert bereits über `scopeVisibleInArchive()`.
---
## 4 Befunde — mittel
### M1 — Generations-Zähler wird prozessweit eingefroren
`app/Support/WebPageCache.php:74`
```php
return self::$generation ??= (int) Cache::get(self::GenerationKey, 0);
```
Unter PHP-FPM stimmt das (ein Prozess = ein Request). In langlebigen Prozessen —
`queue:work`, ein späterer Octane-Betrieb — wird der Wert beim ersten Aufruf
eingefroren und nie wieder gelesen. Konkrete Folge:
`BoostService::forgetPlacementCaches()` (Zeile 230-240) baut seine
`Cache::forget()`-Keys über `WebPageCache::key()`. Läuft das in einem Worker mit
veralteter Generation, löscht es Keys einer alten Generation, während der
aktuelle Eintrag stehen bleibt — die Boost-Platzierung erscheint bis zum
TTL-Ablauf nicht.
**Fix:** Memoisierung an die Request-/Job-Lebensdauer binden (Reset in einem
`JobProcessing`-Listener) oder ganz streichen und pro Aufruf lesen — bei Redis
ist das ein GET.
### M2 — Der Generations-Zähler liegt im Namensraum, den er invalidiert
`WebPageCache::GenerationKey = 'web.generation'` (Zeile 39). Der Zähler ist genau
der eine `web.`-Key, der einen Flush überleben muss. Der Code fängt einen Verlust
zwar ab (Zeile 88-94), aber nur innerhalb desselben Prozesses. Umbenennen auf
etwas außerhalb des Präfixes, z. B. `page-cache:generation`.
### M3 — Wasserstandsmarke ist nur chunkweise idempotent
`app/Console/Commands/AggregatePressReleaseViews.php:90-101`: erst laufen alle
`increment()` des Chunks, danach wird die Marke geschrieben. Bricht der Prozess
dazwischen ab (OOM, Deploy, `kill`), sind die bereits verbuchten Releases beim
nächsten Lauf **ein zweites Mal** dran. Der Klassenkommentar (Zeile 26-27)
behauptet ausdrücklich das Gegenteil („ein doppelter Lauf zählt nichts doppelt").
**Fix:** Increments und `AppSetting::setValue()` je Chunk in eine
`DB::transaction()`. Kommentar entsprechend präzisieren.
### M4 — `press_release_events` wächst unbegrenzt
Die Tabelle ist seit dem Umbau die Quelle jedes Seitenaufrufs (`PressRelease::recordDeferredView()`),
liegt laut Migrationskommentar bei knapp einer Million Zeilen und wächst mit
jedem Request weiter. Im Scheduler (`routes/console.php`) gibt es Aufräum-Tasks
für Magic Links, Snapshots und Entwürfe — für dieses Log keinen.
Nach der Aggregation werden die Aufruf-Ereignisse nur noch für die geplante
Absenderansicht (Roadmap 1c.1, „Aufrufe im Verlauf") gebraucht. Das legt einen
Aufbewahrungszeitraum nahe, keine Ewigkeit.
**Fix:** Retention festlegen (Vorschlag: 13 Monate, damit Vorjahresvergleiche
möglich bleiben) und einen `press-releases:purge-events`-Task ergänzen, der
ausschließlich Ereignisse **unterhalb der Wasserstandsmarke** löscht. Diese
Entscheidung gehört vor 1c.1 — sie bestimmt, wie weit die Verlaufsansicht
zurückreicht.
### M5 — Migration umgeht den AppSetting-Cache
`database/migrations/2026_08_25_120000_seed_press_release_view_watermark.php`
schreibt mit `DB::table('app_settings')->updateOrInsert()` und räumt den
`rememberForever`-Eintrag `app-setting.press_release_views.aggregated_through_id`
nicht ab. Lief der Scheduler-Task im Deploy-Fenster einmal vor der Migration, hält
der Cache die alte, niedrigere Marke und der nächste Lauf zählt das Fenster
dazwischen doppelt. Ein `Cache::forget()` am Ende von `up()` genügt.
### M6 — `$companyId` in der Firmen-Bearbeitung ist nicht `#[Locked]`
`resources/views/livewire/admin/companies/edit.blade.php:19` —
`public int $companyId;` ohne Attribut, obwohl das Gegenstück in
`admin/press-releases/edit.blade.php:34` `#[Locked] public int $id` verwendet.
`save()`, `deleteCompany()` und die neuen `markGone()`/`restoreGone()` arbeiten
alle auf diesem vom Client manipulierbaren Wert. Die `authorize('companies:write')`-
Prüfung greift, das Ausmaß ist also begrenzt — aber die Aktion trifft dann eine
andere Firma als die im Formular angezeigte, und die Inkonsistenz zum
PM-Formular ist unnötig. `#[Locked]` ergänzen.
### M7 — Legacy-API kann `portal = null` schreiben
`app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php:111`
```php
$pressRelease->portal = $this->resolveRequestedPortal(…)?->value;
```
`resolveRequestedPortal()` (`app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php:20`)
gibt `null` zurück, wenn weder Parameter noch Portal-Kontext noch
`$company->portal` greifen. Die Spalte ist ein `ENUM … NOT NULL DEFAULT 'both'`
(`create_press_releases_table.php:19-22`) — das Ergebnis ist ein SQL-Fehler und
damit ein 500er statt einer verwertbaren Fehlermeldung für den Alt-Client.
Praktisch schwer zu erreichen, aber der `?->`-Operator sagt, dass der Autor den
Fall für möglich hält. Entweder auf `Portal::Both` defaulten oder mit 422
antworten.
### M8 — Passwort in der pt-osc-Optionsdatei ist nicht maskiert
`app/Console/Commands/OnlineSchemaChange.php:192-196` schreibt
```php
sprintf("[client]\nuser=%s\npassword=%s\n", …)
```
In einer my.cnf beginnt `#` einen Kommentar, führende/abschließende Leerzeichen
werden verschluckt. Ein DB-Passwort mit `#` wird an dieser Stelle abgeschnitten
und pt-osc scheitert mit einem Authentifizierungsfehler, dessen Ursache man
nirgends sieht. Wert in doppelte Anführungszeichen setzen und `"`/`\` escapen.
Die Umgehung der Kommandozeile (Zeile 81-83) ist ansonsten richtig gelöst,
ebenso `tempnam()` + `chmod(0600)` vor dem Schreiben.
### M9 — Bounce-Suppression erfasst nur `users`, nicht `newsletter_subscriptions`
`app/Console/Commands/ImportNewsletterBounces.php:58` sucht ausschließlich über
`User::query()->where('email', …)` und protokolliert alles Übrige als „Kein
User-Konto". Die Tabelle `newsletter_subscriptions` führt eine eigene
Empfängerliste mit eigenem `unsubscribed_at` und bleibt unberührt.
Die Roadmap macht „Bounce-Suppression produktiv" zum harten Blocker für 1b.6,
1d.3 und 3.2. Solange die zweite Liste nicht mitgeführt wird, ist diese
Voraussetzung nicht erfüllt — mit unmittelbarer Wirkung auf die
Absender-Reputation und damit auf die Zustellbarkeit der Magic Links, die die
Roadmap selbst als das eigentliche Risiko benennt.
**Fix:** `newsletter_subscriptions` in denselben Lauf aufnehmen und ein
gemeinsames Suppression-Konzept festlegen (eigene Tabelle oder gemeinsames
Merkmal), bevor Block 1b/1d gebaut wird.
---
## 5 Befunde — niedrig / Aufräumen
| Nr. | Fundstelle | Befund |
|---|---|---|
| L1 | `app/Services/PressRelease/ProofPdfRenderer.php:211` | `$image === null` ist immer falsch — `$embed` ist bereits null, wenn `$image` null ist |
| L2 | `app/Services/PressRelease/ProofPdfRenderer.php:946` | `$width < 1 \|\| $height < 1` nach `imagesx()`/`imagesy()` immer falsch |
| L3 | `app/Services/Billing/ManualInvoiceService.php:78` | `current_period_end === null` — laut Schema nicht nullable, Prüfung tot |
| L4 | `app/Models/Company.php:178` | linke Seite von `&&` immer wahr |
| L5 | `app/Models/PressRelease.php:415` | `null`-Arm im `match ($this->portal)` unerreichbar (Spalte NOT NULL) |
| ~~L6~~ | `app/Listeners/ProcessStripeWebhook.php:56-57` | **FALSCHER BEFUND.** Beide werden in den Zeilen 123, 149, 257 und 293 gelesen. Die PHPStan-Meldung ist ein Folgefehler: Cashier typisiert `findBillable()` gegen den *Trait* `Billable`, PHPStan hält `if (! $user instanceof User) return;` deshalb für immer wahr und alles danach — inklusive der Lesezugriffe — für unerreichbar. Nichts entfernt. |
| L7 | `app/Console/Commands/ReindexPressReleaseSearch.php:66` | `upsert()` mit 500 `mediumText`-Zeilen kann `max_allowed_packet` reißen — `--chunk` beim Erstlauf niedriger ansetzen oder im Kommentar vermerken |
| L8 | `resources/views/livewire/admin/press-releases/index.blade.php:301` | `fn($inner)` statt `fn ($inner)` — Pint erfasst Blade nicht, deshalb unbemerkt |
| L9 | `app/Console/Commands/ImportNewsletterBounces.php:58` | Adressen werden kleingeschrieben verglichen; unter MySQL (`utf8mb4_*_ci`) egal, unter SQLite im Test nicht — verdeckt mögliche Lücken |
Positiv und ausdrücklich erwähnt: keine Roh-SQL-Stelle nimmt Nutzereingaben
entgegen (alle `selectRaw`/`whereRaw` sind parametrisiert oder erhalten
Literale), alle `{!! !!}`-Ausgaben in `resources/views/web/` speisen sich aus
Sprachdateien oder laufen über `PressReleaseHtmlSanitizer::render()`, und die
neuen Admin-Aktionen prüfen durchgängig `authorize()`.
---
## 6 Voraussetzungen, die die Roadmap braucht und die noch fehlen
Reine Feststellungen aus dem Code, keine neuen Arbeitspakete.
1. **`companies.email` hat keinen B-Tree-Index.** Vorhanden sind nur
`(portal, slug)`, `(portal, is_active)`, `(created_at, id)` und ein
FULLTEXT über `(name, email, slug)`. Block 1a schlüsselt die
Newsroom-Konsolidierung ausdrücklich über die Firmen-E-Mail, und 1a.4 will
diesen Lookup bei **jeder** eingehenden API-Meldung ausführen. Ohne Index ist
das ein Full Scan über 70 MB pro Einreichung. Der Index selbst ist billig:
`ADD INDEX` läuft auch mit vorhandenem FULLTEXT-Index `INPLACE` — nur
`ADD COLUMN` erzwingt den COPY-Rebuild.
2. **Aufbewahrung von `press_release_events` ist offen** (M4). Sie bestimmt, wie
weit die Verlaufsansicht aus 1c.1 zurückreicht, und muss vor 0.3
(„Bestandsaufnahme Aufrufzahlen: Median und Verteilung der letzten drei
Monate") entschieden sein.
3. **Bounce-Suppression ist unvollständig** (M9) — harter Blocker für 1b.6, 1d.3
und 3.2.
4. **Der `gone_at`-Pfad ist nicht sauber umkehrbar** (H3, H4). Roadmap 1b.5
(„fällt ein Newsroom auf null Meldungen, verschwindet die öffentliche Seite
mit") baut direkt darauf auf.
5. **`ReleaseDetailPageData` ist ungecacht, `NewsroomDetailPageData` gecacht**,
und der Newsroom zahlt seit dem 410-Umbau einen zusätzlichen Slug-Lookup
**vor** dem Cache (`NewsroomDetailPageData.php:34-40`). Das ist bewusst so
entschieden und im Code begründet; sobald 1b/1c den Newsroom zum Produkt
machen und die Aufrufzahlen steigen, gehört die Entscheidung noch einmal auf
den Tisch — eine gecachte Menge entfernter Slugs mit kurzer TTL erreicht
dasselbe ohne Query pro Aufruf.
---
## 7 Empfohlene Reihenfolge
**Vor dem nächsten Deploy** (betrifft laufenden Betrieb):
1. H1 — `Cache::forget()` in die beiden Such-Migrationen. Sonst kostet der
FULLTEXT-Umzug eine Minute 500er auf jeder Suche.
2. H2 — Flush aus der Schleife holen, `chunkById()` statt `get()`.
3. H3 + H4 — Umkehrbarkeit von `gone_at` geradeziehen, jeweils mit Test.
4. M5 — `Cache::forget()` in die Watermark-Migration.
**Direkt danach** (Werkzeug, größter Hebel auf alles Weitere):
5. P1 — `parseModelCastsMethod: true`, Scope-Annotationen, Baseline neu,
`composer analyse` ins CI.
**Vor dem ersten Roadmap-Arbeitspaket:**
6. M9 — Bounce-Suppression vervollständigen (Blocker für 1b.6/1d.3/3.2).
7. M4 — Aufbewahrung `press_release_events` entscheiden (blockiert 0.3/1c.1).
8. Index auf `companies.email` (Voraussetzung für 1a).
**Laufend, ohne Eile:**
9. M1, M2, M3, M6, M7, M8 sowie L1–L9.