651 lines
32 KiB
Markdown
651 lines
32 KiB
Markdown
# Umsetzungsprotokoll — Abarbeitung von `bestandsaufnahme-code.md`, Abschnitt 7
|
||
|
||
**Stand:** 01.09.2026
|
||
**Bezug:** `bestandsaufnahme-code.md` (Befund-Nummern H1–H4, M1–M9, P1, L1–L9)
|
||
|
||
Jeder Abschnitt hält fest: was war der Befund, was wurde geändert, wie ist es
|
||
abgesichert. Abweichungen von der ursprünglich vorgeschlagenen Reihenfolge oder
|
||
Lösung sind ausdrücklich vermerkt.
|
||
|
||
---
|
||
|
||
## Ausgangsstand
|
||
|
||
| Prüfung | vorher |
|
||
|---|---|
|
||
| `php artisan test` | 1479 grün, 1 übersprungen |
|
||
| `vendor/bin/pint --test` | passed |
|
||
| `vendor/bin/phpstan analyse` | **390 Fehler außerhalb der Baseline** |
|
||
|
||
---
|
||
|
||
## Block A — vor dem nächsten Deploy
|
||
|
||
### Schritt 1 — H1: Suchausfall im Deploy-Fenster
|
||
|
||
**Befund:** `PressReleaseSearchIndex::isAvailable()` cachte seine Antwort eine
|
||
Minute. Stand dort `false`, während die Migration die Volltext-Indizes von
|
||
`press_releases` entfernte, setzte der Rückfallpfad sein `MATCH()` weiter auf
|
||
die Tabelle ab, an der es keinen Index mehr gab → MySQL-Fehler 1191, 500er auf
|
||
jeder Suche der Web-Seite und der Admin-PM-Liste.
|
||
|
||
**Geändert**
|
||
|
||
- `app/Models/PressReleaseSearchIndex.php`
|
||
- Cache-Key als Konstante `AvailabilityCacheKey` herausgezogen.
|
||
- `isAvailable()` cacht die beiden Antworten unterschiedlich lange: `true`
|
||
(Dauerzustand nach dem Umstieg) 60 s, `false` (gilt nur während des
|
||
Umstiegs) 5 s. Die Prüflogik steckt jetzt in `resolveAvailability()`.
|
||
- Neu: `forgetAvailability()`.
|
||
- `database/migrations/…_create_press_release_search_table.php` und
|
||
`…_drop_fulltext_indexes_from_press_releases.php` rufen
|
||
`forgetAvailability()` in `up()` **und** `down()` auf. Das ist der eigentliche
|
||
Fix; die kurze TTL ist das Netz für einen Eingriff von Hand.
|
||
|
||
**Abweichung vom Vorschlag:** Der Bericht schlug zusätzlich vor, den
|
||
Rückfallpfad gegen `QueryException` abzusichern. Nicht umgesetzt — `constrain()`
|
||
baut die Abfrage nur auf, ausgeführt wird sie später vom Aufrufer. Ein
|
||
try/catch an dieser Stelle könnte den Fehler gar nicht sehen. Die
|
||
Cache-Invalidierung in der Migration deckt den Fall vollständig ab.
|
||
|
||
**Abgesichert:** drei neue Tests in `tests/Feature/PressReleaseSearchIndexTest.php`
|
||
(kurze TTL bei `false`, lange bei `true`, Wirkung von `forgetAvailability()`).
|
||
|
||
---
|
||
|
||
### Schritt 2 — H2: Cache-Flush in der Schleife
|
||
|
||
**Befund:** `CompanyRemovalService::markGone($company, includePressReleases: true)`
|
||
lud alle Mitteilungen der Firma per `get()` und rief je Zeile
|
||
`PressReleaseService::markGone()`, das jedes Mal **sämtliche** Seiten- und
|
||
Sitemap-Caches verwarf. Bei 300 Meldungen: 301 Vollinvalidierungen auf einen
|
||
Admin-Klick, dazu die unbegrenzte Ergebnismenge im Speicher.
|
||
|
||
**Geändert**
|
||
|
||
- `PressReleaseService::markGone()` / `restoreFromGone()` bekommen
|
||
`bool $flushPublicPages = true`. Das Standardverhalten bleibt unverändert.
|
||
- `CompanyRemovalService::markGone()` iteriert mit `chunkById(200)` statt
|
||
`get()` und übergibt `flushPublicPages: false`. Der eine Flush am Ende der
|
||
Methode bestand bereits und genügt.
|
||
|
||
**Abgesichert:** `GoneTombstoneTest` → „cascading a newsroom removal flushes the
|
||
page cache once, not per release" (drei Mitteilungen, Generations-Zähler steigt
|
||
um genau 1).
|
||
|
||
---
|
||
|
||
### Schritt 3 — H3 + H4: Umkehrbarkeit von `gone_at`
|
||
|
||
**Befund:** `markGone()` überschrieb `companies.is_active` (→ false) und
|
||
`press_releases.no_export` (→ true), ohne den Vorzustand zu sichern. Das
|
||
Aufheben setzte beide pauschal auf den Normalzustand zurück. Eine Firma, die
|
||
schon vorher deaktiviert war, stand danach öffentlich in Newsroom-Übersicht und
|
||
Sitemap; eine Mitteilung blieb dauerhaft als „kein Export" markiert. Die
|
||
Oberfläche verspricht an beiden Stellen ausdrücklich Umkehrbarkeit.
|
||
|
||
**Geändert**
|
||
|
||
- Neue Migration `2026_09_01_090000_add_gone_restore_state_to_companies_and_press_releases_tables.php`:
|
||
`companies.is_active_before_gone` und `press_releases.no_export_before_gone`,
|
||
beide `boolean nullable`. `null` = nicht entfernt bzw. bereits
|
||
zurückgenommen. Idempotent über `Schema::hasColumn()`.
|
||
- `Company` und `PressRelease`: `$fillable` + `casts()` ergänzt.
|
||
- `CompanyRemovalService::markGone()` sichert `is_active`,
|
||
`restoreFromGone()` stellt es wieder her (`?? true` als einzige mögliche
|
||
Annahme für Altfälle ohne Merker).
|
||
- `PressReleaseService::markGone()`/`restoreFromGone()` analog für `no_export`.
|
||
Fehlt der Merker, bleibt der aktuelle Wert stehen — raten wäre schlechter als
|
||
nichts zu tun.
|
||
|
||
**Zur Kostenfrage der Migration:** Auf `press_releases` ist das `ADD COLUMN`
|
||
seit dem Entfernen der Volltext-Indizes ein `ALGORITHM=INSTANT`. `companies`
|
||
trägt seinen FULLTEXT-Index weiterhin und wird kopiert — bei 70 MB wenige
|
||
Sekunden, wie in `CLAUDE.md` als vertretbar festgehalten. Die Migration ist
|
||
bewusst **nach** `drop_fulltext_indexes_from_press_releases` datiert.
|
||
|
||
**Verworfene Alternative:** `is_active` in `markGone()` gar nicht anzufassen und
|
||
stattdessen überall auf `gone_at` zu filtern. Dafür hätten 12 Abfragen in
|
||
`app/Services/Web/`, `SitemapController` und `AuthorizesPressReleaseMediaAccess`
|
||
angefasst werden müssen — eine übersehene Stelle hätte einen entfernten
|
||
Newsroom öffentlich gelassen. Der Merker ist die kleinere Angriffsfläche.
|
||
|
||
**Abgesichert:** drei neue Tests in `GoneTombstoneTest` (inaktive Firma bleibt
|
||
nach dem Aufheben inaktiv; `no_export` wird zurückgesetzt; ein vorher gesetztes
|
||
`no_export` bleibt bestehen).
|
||
|
||
---
|
||
|
||
### Schritt 4 — M5: Watermark-Migration umging den AppSetting-Cache
|
||
|
||
**Befund:** Die Migration schrieb die Wasserstandsmarke per Query Builder,
|
||
`AppSetting::getValue()` cacht aber dauerhaft (`rememberForever`). Lief der
|
||
Scheduler-Task im Deploy-Fenster schon einmal, hielt der Cache die alte,
|
||
niedrigere Marke — der nächste Lauf hätte die Ereignisse dazwischen ein zweites
|
||
Mal auf `press_releases.hits` addiert.
|
||
|
||
**Geändert**
|
||
|
||
- `AppSetting::forgetCached(string $key)` als öffentliche Methode; `setValue()`
|
||
benutzt sie jetzt selbst.
|
||
- `…_seed_press_release_view_watermark.php` ruft sie in `up()` und `down()` auf.
|
||
|
||
---
|
||
|
||
## Block B — Werkzeug
|
||
|
||
### Schritt 5 — P1: Statische Analyse wieder in Betrieb
|
||
|
||
**Befund:** 390 Fehler außerhalb der Baseline, davon der größte Teil
|
||
Falschmeldungen: Larastan löste **keinen einzigen Cast** auf, weil alle 49
|
||
Models ihre Casts über die `casts()`-Methode deklarieren (Laravel-11-Stil) und
|
||
Larastans Schalter `parseModelCastsMethod` per Default `false` ist.
|
||
`$pressRelease->status` galt als `string`, jeder Enum-Vergleich als „immer
|
||
falsch". Zusätzlich: Baseline seit dem 12.05.2026 unverändert, `composer
|
||
analyse` in keiner Pipeline.
|
||
|
||
**Geändert**
|
||
|
||
1. `phpstan.neon`: `parseModelCastsMethod: true`, mit Begründung im Kommentar.
|
||
Wirkung: 390 → 266 Meldungen.
|
||
2. **102 Relations-Annotationen** in `app/Models/*.php` ergänzt — jede
|
||
`BelongsTo`/`HasMany`/`HasOne`/`BelongsToMany`/`MorphTo`-Methode hat jetzt
|
||
ihr `@return …<Related, $this>`. Das war die zweite Sammelursache: Ohne
|
||
Generics landete PHPStan auf `Illuminate\…\Model` und meldete jede
|
||
Modell-Eigenschaft und jeden Scope als unbekannt. Wirkung: 266 → 169,
|
||
`property.notFound` von 84 auf 12. Nebeneffekt: dieselbe Verbesserung in der
|
||
IDE-Vervollständigung.
|
||
3. Aufräum-Funde vorgezogen (siehe Schritt 5b).
|
||
4. Baseline neu erzeugt: **166 Einträge / 188 Meldungen** (vorher 127 Einträge
|
||
plus 390 ungefasste Meldungen). `vendor/bin/phpstan analyse` meldet jetzt
|
||
**No errors**.
|
||
5. `.github/workflows/tests.yml`: neuer Schritt „Run Static Analysis" vor den
|
||
Tests. Ohne Gate driftet die Baseline erneut.
|
||
|
||
**Was bewusst in der Baseline bleibt und warum**
|
||
|
||
| Anzahl | Art | Grund |
|
||
|---|---|---|
|
||
| 66 | `property.notFound` | `withCount`-Aliasse (`recent_releases_count`) und Closure-Parameter, die als blankes `Builder` typisiert sind. Behebbar nur mit `@param Builder<PressRelease>` an jeder Closure — viel Rauschen für keinen Erkenntnisgewinn. |
|
||
| 34 | `nullsafe.neverNull` | `?->` auf Werten, die seit der Cast-Auflösung nachweislich nicht null sind. Stilfrage, kein Fehler. |
|
||
| 18 | `method.notFound` | Eigene Scopes (`forPortalValues()`, `visibleInArchive()`) auf denselben blanken Closure-Buildern. |
|
||
| 10 | `trait.unused` | **Falschmeldung:** `app/Livewire/Concerns/*` wird ausschließlich aus Volt-Single-File-Komponenten in `resources/views/livewire/**.blade.php` eingebunden, die PHPStan nicht analysiert. Nachgeprüft, die Traits sind in Benutzung. |
|
||
| 9 | `class.notFound` | Legacy-Migrationsmodelle (`App\Models\Legacy\…`) und `Laravel\Cashier\Concerns\Stripe\TaxId`. |
|
||
| 5+2+2 | `instanceof.alwaysTrue` / `property.onlyWritten` / `method.unused` in `ProcessStripeWebhook` | **Falschmeldungs-Kaskade:** Cashier typisiert `findBillable()` gegen den **Trait** `Billable`. PHPStan kann den nicht mit `User` vereinbaren, hält deshalb `if (! $user instanceof User) return;` für immer wahr und alles danach für unerreichbar — inklusive der Stellen, an denen `$this->activityLog` und `$this->numbers` gelesen werden. Die Laufzeitprüfung ist richtig und bleibt. |
|
||
|
||
**Korrektur zur Bestandsaufnahme:** Befund **L6** („`ProcessStripeWebhook`:
|
||
`$numbers` und `$activityLog` injiziert, nie gelesen") war falsch. Beide werden
|
||
in den Zeilen 123, 149, 257 und 293 verwendet; die Meldung ist ein Folgefehler
|
||
der oben beschriebenen Cashier-Kaskade. Nichts entfernt.
|
||
|
||
---
|
||
|
||
### Schritt 5b — Aufräum-Funde L1–L5 und M7 (vorgezogen)
|
||
|
||
**Warum vorgezogen:** Diese Funde stammen aus der statischen Analyse. Hätte ich
|
||
die Baseline vor ihrer Behebung erzeugt, wären sie zugedeckt gewesen — und ihre
|
||
spätere Behebung hätte die Baseline sofort wieder ungültig gemacht
|
||
(`ignore.unmatched`). Sie gehören deshalb vor den Baseline-Neuaufbau, nicht in
|
||
Schritt 9.
|
||
|
||
| Fund | Datei | Änderung |
|
||
|---|---|---|
|
||
| L1 | `ProofPdfRenderer::renderOverview()` | `if ($embed === null \|\| $image === null)` → `if ($embed === null)`. `$embed` ist bereits null, wenn `$image` null ist. |
|
||
| L3 | `ManualInvoiceService::invoiceFor()` | Prüfung `current_period_end === null` entfernt — die Spalte ist `date()` **NOT NULL**. |
|
||
| L4 | `Company::logoUrl()` | `if ($legacyPortal && filled($logoFilename))` → `if (filled($logoFilename))`. `$legacyPortal` fällt auf `$this->portal->value` zurück und ist damit immer gesetzt. |
|
||
| L5 | `RecordMailActivity::recordSent()` | `getTo() ?? []` → `getTo()`. Die Symfony-Methode liefert immer ein Array. |
|
||
| — | `PressReleasePreviewController` | `$magicLink->expires_at && …->isPast()` → `…->isPast()`. `magic_links.expires_at` ist **NOT NULL**. |
|
||
| — | `RetireAgencyCompanyType` | `if (! $isNewsroom && $isProtected)` → `if (! $isNewsroom)`. Der Zweig darüber endet für den Gegenfall immer mit `continue`. |
|
||
| — | `MigrateLegacyMedia::selectedTypes()` | `@return list<'company-logos'\|'press-release-images'>` ergänzt. Damit ist das `match` ohne `default` nachweislich vollständig statt nur faktisch. |
|
||
|
||
**M7 — Legacy-API konnte `portal = null` schreiben** (ebenfalls vorgezogen, weil
|
||
derselbe Sachverhalt):
|
||
|
||
- `ResolvesRequestedPortal::resolveRequestedPortal()` gibt jetzt ein
|
||
nicht-nullbares `Portal` zurück; letzte Stufe ist `Portal::Both` — exakt der
|
||
Default, den beide `portal`-Spalten ohnehin tragen. Vorher konnte die Methode
|
||
`null` liefern, was als SQL-Fehler und damit als 500er beim Alt-Client
|
||
gelandet wäre; `PressReleaseController:53` hätte sogar einen Fatal Error
|
||
ausgelöst, weil es das Ergebnis direkt mit `->value` weiterreicht.
|
||
- `LegacyPressReleaseController` und `LegacyCompanyController`: `?->value` → das
|
||
Enum bzw. `->value` direkt.
|
||
- `PressRelease::publicPortalLinks()`: der unerreichbare `null =>`-Arm im
|
||
`match` entfällt.
|
||
- `PressRelease::booted()` und `LegacyPressReleaseController` weisen jetzt das
|
||
Enum statt des Rohwerts zu — die Spalten sind darauf gecastet.
|
||
|
||
**Bewusst NICHT geändert:** `ProofPdfRenderer:946` (`$width < 1 || $height < 1`
|
||
nach `imagesx()`/`imagesy()`). Laut PHPStan-Stub tot, zur Laufzeit eine
|
||
Schutzprüfung in der Bildverarbeitung des Rechtsnachweis-PDFs. Für null
|
||
funktionalen Gewinn wird dort keine Absicherung entfernt — Meldung baseliniert.
|
||
|
||
---
|
||
|
||
## Block C — Voraussetzungen für die Roadmap
|
||
|
||
### Schritt 6 — M9: Bounce-Suppression vervollständigt
|
||
|
||
**Befund:** `newsletter:import-bounces` erfasste ausschließlich `users` und
|
||
protokollierte alles Übrige als „ohne User-Konto". Die zweite Empfängerliste
|
||
`newsletter_subscriptions` — die öffentliche Anmeldung auf den Portalen, mit
|
||
eigenem `unsubscribed_at` und eigener Synchronisierung nach außen — blieb
|
||
unberührt. Die meisten Einträge dort haben gar kein Konto, wurden also gar
|
||
nicht erreicht. Die Roadmap macht „Bounce-Suppression produktiv" zum Blocker
|
||
für 1b.6, 1d.3 und 3.2.
|
||
|
||
**Geändert** (`app/Console/Commands/ImportNewsletterBounces.php`)
|
||
|
||
- Aufgeteilt in `suppressUsers()` und `suppressSubscriptions()`; beide melden
|
||
zurück, ob die Adresse in ihrer Liste stand.
|
||
- „Ohne User-Konto" gilt jetzt erst, wenn die Adresse in **keiner** der beiden
|
||
Listen steht.
|
||
- Anmeldungen werden scope-frei über alle Portale gesucht (der CLI-Lauf hat
|
||
keinen Portal-Kontext, eine Adresse kann auf beiden Portalen stehen),
|
||
bekommen `unsubscribed_at`, laufen durch `NewsletterSyncService` (damit die
|
||
Abmeldung auch beim externen Versender ankommt) und landen mit
|
||
`method: bounce` im Aktivitäts-Verlauf.
|
||
- Eigene Ergebniszeile für die Anmeldungen; `--dry-run` deckt beide Listen ab.
|
||
- **L9 gleich mit erledigt:** Der Vergleich läuft über
|
||
`whereRaw('LOWER(email) = ?')` wie in `User::findByEmail()`. Bounce-Listen
|
||
kommen aus dem Mailserver-Log und geben die Adresse so wieder, wie der
|
||
Absender sie geschrieben hat.
|
||
|
||
**Abgesichert:** vier neue Tests (Anmeldung ohne Konto wird abgemeldet;
|
||
Idempotenz beim zweiten Lauf; alle Portale einer Adresse; Dry-Run;
|
||
Groß-/Kleinschreibung).
|
||
|
||
---
|
||
|
||
### Schritt 7 — M4: Aufbewahrung `press_release_events`
|
||
|
||
**Befund:** Die Tabelle nimmt seit dem Umbau des Aufruf-Zählers jeden
|
||
Seitenaufruf auf (Bestand knapp eine Million) und war als einzige Log-Tabelle
|
||
des Systems ohne Bereinigung.
|
||
|
||
**Geändert**
|
||
|
||
- Neu `config/press_releases.php`: `events.retention_days`, Default **395 Tage
|
||
(13 Monate)**, überschreibbar per `PRESS_RELEASE_EVENTS_RETENTION_DAYS`.
|
||
- Neu `app/Console/Commands/PurgePressReleaseEvents.php` mit `--days`,
|
||
`--chunk`, `--dry-run`.
|
||
- Scheduler: täglich 03:20, `withoutOverlapping()`, mit Betriebs-Alert.
|
||
- `.env.example` ergänzt.
|
||
|
||
**Zwei Sicherungen, beide zwingend und getestet**
|
||
|
||
1. Gelöscht wird ausschließlich **unterhalb der Wasserstandsmarke** von
|
||
`press-releases:aggregate-views`. Alles darüber ist noch nicht in `hits`
|
||
verbucht und wäre sonst dauerhaft verloren. Fehlt die Marke, bricht der Lauf
|
||
mit Exit-Code 1 ab, statt zu raten.
|
||
2. Gelöscht wird in Blöcken — ein DELETE über Millionen Zeilen hielte die
|
||
Sperre über die gesamte Laufzeit.
|
||
|
||
**⚠ Offene Entscheidung:** Die **13 Monate sind meine Festlegung, nicht deine.**
|
||
Gewählt, weil damit der Vorjahresmonats-Vergleich möglich bleibt. Der Wert
|
||
bestimmt zugleich, wie weit die Verlaufsanzeige der geplanten Absenderansicht
|
||
(Roadmap 1c.1) zurückreicht — wer dort mehr Historie zeigen will, muss ihn
|
||
**vor dem ersten Lauf** erhöhen. Einmal gelöschte Ereignisse kommen nicht
|
||
zurück. Der Zusammenhang steht als Kommentar in `config/press_releases.php`.
|
||
|
||
**Abgesichert:** fünf Tests in `tests/Feature/PurgePressReleaseEventsTest.php`.
|
||
|
||
---
|
||
|
||
### Schritt 8 — Index auf `companies.email`
|
||
|
||
**Befund:** `companies` hatte nur `(portal, slug)`, `(portal, is_active)`,
|
||
`(created_at, id)` und einen FULLTEXT über `(name, email, slug)`. Ein
|
||
FULLTEXT-Index beantwortet kein `WHERE email = ?` — jede Suche über die
|
||
Firmen-E-Mail war ein voller Tabellendurchlauf. Roadmap 1a schlüsselt die
|
||
Newsroom-Konsolidierung ausdrücklich darüber, 1a.4 bei **jeder** eingehenden
|
||
API-Meldung.
|
||
|
||
**Geändert:** Migration `2026_09_01_091000_add_email_index_to_companies_table.php`.
|
||
|
||
**Warum nur `email` und nicht `(portal, email)`:** Die Adresse ist hochselektiv,
|
||
die Portal-Einschränkung kostet danach nichts mehr — und derselbe Index trägt
|
||
zusätzlich die portalübergreifende Gruppierung des Trockenlauf-Berichts (1a.1),
|
||
was ein zusammengesetzter Index nicht könnte.
|
||
|
||
**Kosten:** `ADD INDEX` läuft bei InnoDB auch mit vorhandenem FULLTEXT-Index
|
||
`INPLACE` — anders als `ADD COLUMN`. Bei 70 MB Sekunden.
|
||
|
||
---
|
||
|
||
## Block D — Schritt 9: laufende Aufräumarbeiten
|
||
|
||
### M1 — Generations-Zähler prozessweit eingefroren
|
||
|
||
`WebPageCache::$generation` wurde mit `??=` einmal je Prozess gefüllt. Unter
|
||
PHP-FPM stimmt das; ein Queue-Worker lebt aber über beliebig viele Jobs hinweg
|
||
und hätte dauerhaft mit dem Stand seines ersten Zugriffs gearbeitet — konkret
|
||
hätte `BoostService::forgetPlacementCaches()` die Keys einer alten Generation
|
||
gelöscht, während der aktuelle Eintrag bis zum TTL-Ablauf stehen bleibt.
|
||
|
||
- Neu: `WebPageCache::resetMemo()`.
|
||
- `AppServiceProvider::boot()` registriert `Queue::looping(…)`, verwirft die
|
||
Merkung also zwischen zwei Jobs. Die Merkung selbst bleibt — sie war als
|
||
Ersparnis gedacht und ist es weiterhin, nur eben mit definierter Lebensdauer.
|
||
Ein späterer Octane-Betrieb bräuchte denselben Reset auf `RequestReceived`;
|
||
das steht als Hinweis am Feld.
|
||
|
||
### M2 — Zähler lag im Namensraum, den er invalidiert
|
||
|
||
`web.generation` → **`page-cache.generation`**. Der Zähler ist der eine Eintrag,
|
||
der eine Verwerfung des `web.`-Namensraums überleben muss.
|
||
|
||
> **Deploy-Hinweis:** Die Umbenennung setzt den Zähler einmalig auf 0 zurück.
|
||
> Unkritisch, solange sie zusammen mit dem übrigen noch nicht committeten Block
|
||
> ausgeliefert wird — die Generations-Nummer existiert auf dem Live-System noch
|
||
> gar nicht. Wird sie später einzeln nachgezogen, vorher einmal
|
||
> `php artisan cache:clear` einplanen.
|
||
|
||
### M3 — Wasserstandsmarke nur chunkweise idempotent
|
||
|
||
`AggregatePressReleaseViews`: `increment()`-Schleife und `AppSetting::setValue()`
|
||
laufen jetzt in **einer** `DB::transaction()` je Durchgang. Vorher war nur der
|
||
Durchgang wiederanlaufsicher, nicht der Abbruch darin — ein Absturz mitten im
|
||
Chunk hätte einen Teil der Zähler erhöht, die Marke aber nicht bewegt, und der
|
||
nächste Lauf hätte genau diese Aufrufe doppelt verbucht. Der Klassenkommentar,
|
||
der Idempotenz behauptete, ist präzisiert.
|
||
|
||
### M6 — `#[Locked]` auf `$companyId`
|
||
|
||
`resources/views/livewire/admin/companies/edit.blade.php`: `public int $companyId`
|
||
hat jetzt `#[Locked]`, wie das Pendant `$id` der PM-Bearbeitung. `save()`,
|
||
`deleteCompany()`, `markGone()` und `restoreGone()` arbeiten alle auf diesem
|
||
Wert; ohne das Attribut konnte der Client ihn im Livewire-Payload austauschen,
|
||
sodass die Aktion eine andere Firma träfe als die im Formular angezeigte.
|
||
|
||
### M8 — pt-osc-Passwort nicht maskiert
|
||
|
||
`OnlineSchemaChange::writeDefaultsFile()` schreibt Benutzer und Passwort jetzt
|
||
über `quoteOptionValue()` in doppelten Anführungszeichen mit escaptem `\` und
|
||
`"`. In einer my.cnf beginnt ein `#` sonst einen Kommentar und umschließende
|
||
Leerzeichen fallen weg — ein Passwort mit Raute wäre abgeschnitten bei pt-osc
|
||
angekommen und der Lauf an einem Authentifizierungsfehler gescheitert, dessen
|
||
Ursache nirgends sichtbar ist.
|
||
|
||
### L7 — `max_allowed_packet` beim Erstlauf des Reindex
|
||
|
||
`ReindexPressReleaseSearch`: Klassenkommentar um den Hinweis ergänzt, dass ein
|
||
Durchgang `--chunk` vollständige Textkörper in **einem** `upsert` schreibt und
|
||
beim erstmaligen Indizieren des Bestands im Zweifel `--chunk=100` zu wählen ist.
|
||
|
||
### L8 — Blade-Codestil
|
||
|
||
`admin/press-releases/index.blade.php`: `fn($inner)` → `fn ($inner)`. Pint
|
||
erfasst Blade-Dateien nicht, deshalb war die Abweichung durchgerutscht.
|
||
|
||
### L9
|
||
|
||
Zusammen mit Schritt 6 erledigt, siehe dort.
|
||
|
||
---
|
||
|
||
## Endstand
|
||
|
||
| Prüfung | vorher | nachher |
|
||
|---|---|---|
|
||
| `php artisan test` | 1479 grün, 1 übersprungen | **1495 grün**, 1 übersprungen (6517 Assertions) |
|
||
| `vendor/bin/pint --test` | passed | **passed** |
|
||
| `vendor/bin/phpstan analyse` | 390 Fehler außerhalb der Baseline | **No errors** (Baseline 166 Einträge / 188 Meldungen) |
|
||
|
||
**16 neue Tests**, verteilt auf `PressReleaseSearchIndexTest` (3),
|
||
`GoneTombstoneTest` (4), `ImportNewsletterBouncesTest` (4) und das neue
|
||
`PurgePressReleaseEventsTest` (5).
|
||
|
||
### Neue Dateien
|
||
|
||
| Datei | Zweck |
|
||
|---|---|
|
||
| `app/Console/Commands/PurgePressReleaseEvents.php` | Aufbewahrung des Ereignis-Logs (M4) |
|
||
| `config/press_releases.php` | Aufbewahrungszeitraum, konfigurierbar |
|
||
| `database/migrations/2026_09_01_090000_add_gone_restore_state_…` | Vorzustand der 410-Entfernung (H3/H4) |
|
||
| `database/migrations/2026_09_01_091000_add_email_index_to_companies_table.php` | Voraussetzung Roadmap 1a |
|
||
| `tests/Feature/PurgePressReleaseEventsTest.php` | Absicherung M4 |
|
||
|
||
### Reihenfolge beim Deploy
|
||
|
||
1. `php artisan migrate --force` — die neuen Migrationen laufen **nach**
|
||
`drop_fulltext_indexes_from_press_releases` (auf `press_releases` damit
|
||
`INSTANT`, auf `companies` ein Kopiervorgang von wenigen Sekunden).
|
||
2. Danach einmal `php artisan press-releases:aggregate-views`, damit die
|
||
Wasserstandsmarke gesetzt ist, **bevor** der neue Aufräum-Task um 03:20 das
|
||
erste Mal läuft. Ohne Marke bricht er von selbst ab — es geht also nichts
|
||
verloren, der erste Lauf wäre nur wirkungslos.
|
||
3. `php artisan config:cache` (neue Konfigurationsdatei).
|
||
|
||
### Was offen bleibt
|
||
|
||
| Thema | Status |
|
||
|---|---|
|
||
| Aufbewahrung des Ereignis-Logs: 395 Tage | **Meine Festlegung, deine Entscheidung.** Bestimmt die Tiefe von Roadmap 1c.1 und ist nur *vor* dem ersten Lauf frei wählbar. |
|
||
| Die 166 Baseline-Einträge | Kein Fehler, aber auch keine Sichtprüfung wert — Zusammensetzung und Begründung siehe Schritt 5. Wer sie weiter senken will, fängt bei den Closure-Generics in `app/Services/Web/` an. |
|
||
| Roadmap-Entscheidungen E1–E6 | Unberührt — das sind fachliche Fragen, keine Code-Befunde. |
|
||
|
||
---
|
||
|
||
# Teil 2 — Roadmap Block 0 (Messbarkeit)
|
||
|
||
## 0.1 und 0.2 — Basiswerte
|
||
|
||
Zwei Auswertungen als Artisan-Befehle, Ergebnisse und Ableitungen in
|
||
**[`basiswerte-block0.md`](basiswerte-block0.md)**.
|
||
|
||
```
|
||
php artisan press-releases:view-distribution [--months=3] [--from=] [--to=] [--json]
|
||
php artisan press-releases:rejection-rate [--months=3] [--from=] [--to=] [--json]
|
||
```
|
||
|
||
**Zwei Befunde, die den Plan verändert haben:**
|
||
|
||
1. **Der Gesamtmedian taugt nicht als Basiswert.** `hits` ist ein mitlaufender
|
||
Zähler, und die Aufrufe wachsen nach der Veröffentlichung wochenlang weiter
|
||
(⌀ 75 → 128 → 179 → 231 in den ersten vier Wochen, ohne Plateau). Über ein
|
||
Drei-Monats-Fenster misst man damit die Altersmischung, nicht die Reichweite.
|
||
Der Report weist deshalb zusätzlich **Alterskohorten** aus; der Basiswert ist
|
||
die Kohorte „28–55 Tage", nicht die Gesamtzahl.
|
||
2. **openPR ist keine zweite Quelle.** Es ist der Presse-API-Verteiler von
|
||
connektar, also derselbe Zulauf. In den Daten hat genau ein API-Token je
|
||
einen Zugriff gemacht. Roadmap-Text korrigiert.
|
||
|
||
**E2 ist damit entschieden: Rohzahlen.** Begründung in `basiswerte-block0.md`.
|
||
Kurzfassung: kein Bodensatz (unter 25 Aufrufen liegen 2 % bzw. 0 %),
|
||
Verteilungen nahezu symmetrisch, und eine Einordnung gegen den
|
||
Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch.
|
||
|
||
---
|
||
|
||
## 0.3 bis 0.7 — Konversionsleiter
|
||
|
||
### Warum eine eigene Tabelle statt Umami
|
||
|
||
Die zu messende Strecke führt über zwei Domains und einen Magic Link. Ein
|
||
cookiefreies Analytics-Tool verliert die Sitzung genau an der Stelle, an der
|
||
die Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine
|
||
Seitenaufrufe, sondern Zustandsänderungen in der Datenbank, bei denen die
|
||
Identität ohnehin bekannt ist.
|
||
|
||
**Rollenteilung bleibt:** Ereignistabelle für den Trichter (Block 1), Umami für
|
||
Seitenaufrufe und Suchbegriffe (Block 2). Keine Vermischung.
|
||
|
||
### 0.3 — `conversion_events`
|
||
|
||
Spalten: `type`, `source`, `company_id`, `press_release_id`, `created_at`.
|
||
Keine Personen, keine Klartextadressen, keine IP, kein User-Agent. Der rote
|
||
Faden durch den Trichter ist die **Firma**, nicht der Mensch — für „an welcher
|
||
Stufe bricht es ab" reicht das vollständig.
|
||
|
||
Die Leiter als Enum `ConversionEventType`, mit Stufe **und** Diagnose am Fall:
|
||
|
||
| # | Stufe | Ein Abbruch hier bedeutet |
|
||
|---|---|---|
|
||
| 1 | Mail-Link geklickt | Betreff oder Anlass trägt nicht |
|
||
| 2 | Magic Link eingelöst | Der Verwaltungszugang wird nicht verstanden |
|
||
| 3 | Newsroom bearbeitet | Kein Anreiz, das Profil zu pflegen |
|
||
| 4 | Konto angelegt | Der Nutzen des Kontos ist nicht erkennbar |
|
||
| 5 | Erste Buchung | Preis oder Angebot passt nicht |
|
||
|
||
### 0.4 — Herkunft auf dem Token
|
||
|
||
Neue Spalte `magic_links.source` (bewusst eine Spalte statt eines Eintrags in
|
||
`payload`: Die Herkunft wird gruppiert und gefiltert, nicht nur gelesen).
|
||
`MagicLinkGenerator::createLoginLink()` nimmt sie entgegen.
|
||
|
||
Die Mail-Links tragen `?src=<Herkunft>&pr=<Meldung>`. `ConversionSource::parse()`
|
||
fällt bei unbekannten Werten auf `organic` zurück statt zu scheitern — der
|
||
Parameter steht in fremder Hand.
|
||
|
||
**Zur Meldungs-ID im Klartext:** Sie ist fälschbar. Vertretbar, weil sie nichts
|
||
freischaltet, sondern ein Ereignis in der internen Statistik einer Firma
|
||
zuordnet; wer sie fälscht, verfälscht nur die eigene Auswertung. Sobald der
|
||
Verwaltungszugang über Magic Link steht (1b.2), tragen die Mail-Links ein
|
||
Token, das Herkunft und Meldung fälschungssicher mitführt — der `pr`-Parameter
|
||
entfällt dann.
|
||
|
||
### 0.5 — Schreibstellen
|
||
|
||
| Stufe | Wo |
|
||
|---|---|
|
||
| 1 | `RecordMailLinkClick` — globale Middleware. Bewusst global statt je Route: Die Mail-Links zeigen auf Hub, Spokes und öffentliche Detailseiten, eine vergessene Route wäre eine unsichtbare Lücke. Nur `mail_*`-Herkünfte lösen aus, nur GET. |
|
||
| 2 | `MagicLinkConsumeController`, Herkunft vom Token |
|
||
| 3 | **Enum und Auswertung stehen, Schreibstelle folgt mit 1b.3** (abgesprochen) |
|
||
| 4 | `RecordAuthActivity::recordRegistered()` |
|
||
| 5 | `ProcessStripeWebhook` an beiden Zahlungspfaden (`mirrorPaidInvoice`, `fulfillSinglePurchase`), dedupliziert über `recordOnce()` je Firma |
|
||
|
||
Zwei Regeln für alle Schreibstellen: **Fehler werden geschluckt** (die Messung
|
||
begleitet einen Vorgang, sie ist nicht der Vorgang) und **kein Personenbezug**.
|
||
|
||
### 0.6 — Auswertung
|
||
|
||
```
|
||
php artisan conversion:funnel [--months=3] [--from=] [--to=] [--json]
|
||
```
|
||
|
||
Zählt je Stufe die **eindeutigen Firmen**, nicht die Ereignisse: Ein doppelter
|
||
Klick ist kein doppelter Interessent, und nur mit derselben Einheit auf allen
|
||
Stufen ist ein Übergang überhaupt eine Quote. Ereignisse ohne Firmenbezug
|
||
stehen in einer eigenen Spalte daneben statt im Nenner.
|
||
|
||
### 0.7 — Aufbewahrung
|
||
|
||
**395 Tage**, derselbe Horizont wie beim Aufruf-Ereignis-Log (Entscheidung
|
||
01.09.2026). `config/conversion.php`, Befehl `conversion:purge-events`, täglich
|
||
03:25 im Scheduler.
|
||
|
||
> **Offen und bewusst nicht von mir geschrieben:** Der Absatz in der
|
||
> Datenschutzerklärung. Rechtstext gehört nicht aus einer Codeänderung heraus
|
||
> formuliert. Inhaltlich zu führen ist: Zweck (interne Reichweiten- und
|
||
> Konversionsstatistik), Umfang (Ereignistyp, Herkunft, Firmen- und
|
||
> Meldungsbezug — kein Personenbezug, keine IP), Aufbewahrung (395 Tage),
|
||
> Rechtsgrundlage.
|
||
|
||
### Angepasster Bestandstest
|
||
|
||
`EmailBrandingTest` verglich die Mail-URL roh gegen das gerenderte HTML. Die
|
||
Links tragen jetzt zwei Parameter, Blade escapt das trennende `&` korrekt zu
|
||
`&` — der Vergleich läuft nun über `e()`. Verhalten unverändert richtig,
|
||
nur die Erwartung war zu eng.
|
||
|
||
---
|
||
|
||
# Teil 3 — Roadmap Block 1a (Newsroom-Konsolidierung)
|
||
|
||
## 1a.1 — Trockenlauf-Bericht
|
||
|
||
```
|
||
php artisan companies:merge-report [--threshold=] [--limit=15] [--json]
|
||
```
|
||
|
||
**Live-Ergebnis vom 11.09.2026, Schwellwert 3, nach der Tippfehler-Korrektur:**
|
||
|
||
| Klasse | Adressen | Firmen | fällt weg | davon mit PM |
|
||
|---|---|---|---|---|
|
||
| Dublette | 12.812 | 27.832 | **15.020** | 14.633 |
|
||
| Namensvariante | 1.615 | 4.540 | **2.925** | 1.087 |
|
||
| Vorschlag | 3.632 | 9.907 | – | 2.211 |
|
||
| Sammeladresse | 1.085 | 6.706 | – | 1.072 |
|
||
| nie veröffentlicht | 23 | 5.965 | – | 0 |
|
||
|
||
**17.945 Firmen** würden automatisch zu bestehenden Newsrooms verschmelzen —
|
||
16 % des Bestands von 111.646.
|
||
|
||
**Drei Befunde aus den Live-Läufen mit Schwellwert 2, 3 und 4:**
|
||
|
||
1. **Die Klasse „nie veröffentlicht" fehlte im Regelwerk.** 23 Adressen mit
|
||
5.965 Firmen und null Veröffentlichungen, angeführt von `yahoo@yahoo.com`
|
||
(4.376 Firmen). Dahinter steht keine Firma und keine Agentur. Hätte 1a.3
|
||
ohne diesen Trockenlauf gebaut, wären 4.376 Unbeteiligte zu einem Newsroom
|
||
verschmolzen — der teuerste Fehler dieses Blocks, nicht sauber
|
||
rückabwickelbar. Die Namensanzahl allein trennt diese Adressen NICHT von
|
||
großen Agenturen (`pr@deutsche-stadtauskunft.ag`: 133 Namen, 134 Firmen mit
|
||
Meldungen); trennscharf ist erst die Veröffentlichung.
|
||
2. **Tippfehler galten als verschiedene Firmen.** `info@js-research.de` stand
|
||
mit 204 Firmen an zweiter Stelle der Agenturliste — eine Firma, siebzehnmal
|
||
leicht anders geschrieben. Normalisierte Namen werden jetzt zu
|
||
Ähnlichkeitsclustern zusammengefasst (Levenshtein ≤ 2 bzw. 15 %, oder drei
|
||
gemeinsame Anfangswörter). Namensvarianten stiegen dadurch von 730 auf 1.615
|
||
Adressen, Sammeladressen sanken von 1.430 auf 1.085.
|
||
3. **„& Co. KG" passte nie.** Rechtsformen wurden nach dem Entfernen der
|
||
Satzzeichen abgeglichen — „gmbh & co kg" traf nie auf „gmbh co kg". Jetzt
|
||
wortweise vom Ende her gestrippt.
|
||
|
||
**E1 entschieden: Schwellwert 3.** Bei 2 wird jede zweite Schreibweise zur
|
||
„Agentur" (ProCoReX, Welcon — je eine Firma mit Rechtsformwechsel). Bei 4
|
||
wandern 768 echte Mehrkunden-Adressen wie `presse@agentur-zb.de` in die
|
||
Handarbeit. Konfiguriert in `config/newsroom.php`.
|
||
|
||
**Zu 3.3:** 1.085 Sammeladressen mit 6.706 Firmen, davon 1.072 mit Meldungen —
|
||
namentlich benannte PR-Agenturen. Das Agenturmodell ist kein Zukunftsfeature,
|
||
sondern ein vorhandenes Datenproblem.
|
||
|
||
## 1a.2 — Persistierte Klassifizierung
|
||
|
||
```
|
||
php artisan companies:classify-addresses [--threshold=] [--dry-run]
|
||
```
|
||
|
||
Tabelle `company_email_groups`, eine Zeile je Adresse — die Klassifizierung
|
||
ist eine Eigenschaft der Adresse, nicht der Firma. Bewusst eigene Tabelle statt
|
||
Spalten auf `companies` (FULLTEXT → jedes ADD COLUMN ein Rebuild).
|
||
|
||
Die Klassifizierungslogik liegt in `CompanyAddressClassifier` und wird von
|
||
Bericht **und** Persistierung benutzt — beide müssen dieselbe Antwort geben,
|
||
sonst zeigt der Bericht etwas anderes, als die Zusammenführung später tut.
|
||
|
||
**`manual_classification`** übersteuert die berechnete Klasse und überlebt
|
||
jede Neuberechnung. Nötig für Grenzfälle, die nur ein Mensch entscheiden kann:
|
||
`presse@pr4you.de` ist eine echte Agentur, sieht in den Daten aber aus wie eine
|
||
Wegwerfadresse. `CompanyEmailGroup::effectiveClassification()` ist die einzige
|
||
Stelle, die 1a.3/1a.4 fragen dürfen. Wer über eine Adresse entscheidet, liest
|
||
nie die berechnete Spalte direkt.
|
||
|
||
**Wiederholbar:** Adressen, unter denen nur noch eine Firma steht, fallen beim
|
||
nächsten Lauf heraus — außer sie tragen eine Handentscheidung.
|
||
|
||
**Laufzeit:** 3,2 s über den ganzen Bestand. Die Ähnlichkeitsprüfung ist
|
||
quadratisch und bei 25 Repräsentanten gedeckelt — ab da steht die Klasse fest,
|
||
und `yahoo@yahoo.com` hätte sonst neun Millionen Levenshtein-Aufrufe gekostet.
|
||
|
||
**Deploy:** `migrate --force`, `config:cache`, dann einmal
|
||
`companies:classify-addresses`. Der Befehl gehört vorerst nicht in den
|
||
Scheduler — die Klassifizierung soll sich nicht über Nacht ändern, während
|
||
jemand im Admin darüber entscheidet. Nach 1a.4 neu bewerten.
|
||
|
||
**Live-Trockenlauf vom 11.09.2026** (nach Migration, Schwellwert 3):
|
||
|
||
| Klasse | Adressen | zusammenführbar |
|
||
|--------------------|---------:|-----------------|
|
||
| Dublette | 12 812 | ja |
|
||
| Namensvariante | 1 615 | ja |
|
||
| Vorschlag | 3 632 | nein |
|
||
| Sammeladresse | 1 085 | nein |
|
||
| nie veröffentlicht | 23 | nein |
|
||
| **gesamt** | **19 167** | |
|
||
|
||
Deckungsgleich mit dem Bericht aus 1a.1 — Bericht und Persistierung rechnen
|
||
dieselbe Logik. 14 427 Adressen (75 %) sind zusammenführbar; hinter ihnen
|
||
stehen die 17 945 Firmen aus 1a.1. Der scharfe Lauf am selben Tag schrieb
|
||
dieselben 19 167 Zeilen nach `company_email_groups` — die Tabelle ist auf Live
|
||
gefüllt, 1a.3 kann darauf filtern.
|