presseportale/dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md
2026-09-18 10:56:21 +00:00

236 lines
23 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.

# Entwicklungsplan zum Audit vom 11.09.2026
**Stand:** 18.09.2026 (Entscheidungen getroffen, Umsetzung läuft)
**Grundlage:** [AUDIT.md](AUDIT.md) (geprüfter Commit `5d0570b`), abgeglichen mit HEAD `24c1bbf` auf `technik/betriebsfixes-2026-09`
**Zweck:** Nur die Befunde, die am aktuellen Code belegt sind, in eine abarbeitbare Reihenfolge bringen. Jeder Punkt hat einen konkreten Fix, einen Regressionstest und eine Aufwandsklasse.
---
## 1. Ergebnis des Abgleichs
Der Audit hält. Von 28 Befunden sind 25 am aktuellen Stand bestätigt, zwei nur teilweise (F08, F17), einer ist zwar richtig, aber praktisch ohne Relevanz (F21). Kein Befund wurde durch die Commits seit dem 11.09. behoben, obwohl mehrere der betroffenen Dateien angefasst wurden (`ContactAccessService`, `ManualInvoiceService`, `PressReleaseService`, `NewsroomDetailPageData`, `press-kits/show`).
Was über den Audit hinausgeht und bei der Prüfung aufgefallen ist:
- **F05 ist schlimmer als beschrieben.** Auf einer Kopie der Live-Daten liefert der Newsletter-Versand an alle 59.705 berechtigten Nutzer genau 500 eindeutige Empfänger, davon 480 doppelt. Die übrigen 59.205 werden nie erreicht. Der Grund ist `chunkById` hinter einer `created_at`-Sortierung; Laravel entfernt nur eine vorhandene `id`-Sortierung, nicht die führende.
- **F15 hat einen zweiten Fehler durch die Mail-Sperrliste vom 15.09.** Ist ein Empfänger gesperrt, bricht `GateOutgoingMail` den Versand ab, der Job wertet den Rückgabewert nicht aus und markiert den Empfänger als „gesendet“. Nach einer Pause durch den Circuit Breaker landen Empfänger endgültig auf „Failed“, und `queue:retry` holt sie nicht nach.
- **F04 hat eine Fundstelle, die der Audit nicht kennt.** Die öffentliche Meldungsseite (`ReleaseDetailPageData.php:76-80`) fällt auf `Contact::withoutGlobalScopes()` zurück und zeigt damit gelöschte Kontakte mit Name, Mail und Telefon öffentlich an.
- **F04 im Kundenpanel ist ein sichtbarer Bug.** Nach „Kontakt löschen“ im Pressefach bleibt der Kontakt in der Liste und ist weiter editierbar (`press-kits/show.blade.php:273-306`).
- **F22 ist gemessen.** `LOWER(email)` auf `companies` (114.100 Zeilen) ergibt einen Vollscan mit 108.424 geschätzten Zeilen; der direkte Vergleich nutzt `companies_email_idx` mit 1 Zeile. Die Abfrage läuft bei jedem Login. Die Kollation ist bereits `utf8mb4_unicode_ci`, `LOWER()` bringt semantisch nichts.
### Befundtabelle
| ID | Audit | Prüfung an HEAD | Eigene Einordnung | Block |
|---|---|---|---|---|
| F01 | P1 | bestätigt, Route aktiv, Kette vollständig | **kritisch**, sofort | 1 |
| F02 | P1 | bestätigt | mittel | 1 |
| F03 | P1 | bestätigt, `showRecoveryCodes` ohne `#[Locked]` | mittel, Fix klein | 1 |
| F04 | P1 | bestätigt, plus zwei Fundstellen mehr, eine falsch benannt | mittel (PII öffentlich, Panel-Bug) | 3 |
| F05 | P1 | bestätigt und auf Live-Kopie bewiesen | **kritisch**, vor der nächsten Kampagne | 2 |
| F06 | P1 | bestätigt, aber latent: kein Datensatz hat `grandfathered_until` | niedrig heute, Fix klein | 5 |
| F07 | P1 | bestätigt, `content_hash` wird nur für Duplikate genutzt | mittel, Fenster: Admin-/Korrektur-Bearbeitung im Status Review | 4 |
| F08 | P2 | teilweise: ohne Snapshot kein Aufruf möglich, nur Stale-Session-Fall | niedrig, Fix klein | 1 |
| F09 | P2 | bestätigt per Tinker | niedrig bis mittel, Einzeiler | 1 |
| F10 | P2 | bestätigt, keinerlei Entzugslogik, Pivot ohne Herkunft | mittel, macht F01 dauerhaft | 1 |
| F11 | P2 | bestätigt, nur negative `per_page` unbegrenzt, `status[]` gibt 500 | niedrig bis mittel | 3 |
| F12 | P2 | bestätigt, Canonical-Arbeit vom 18.09. hat die Report-Controller nicht erfasst | mittel-niedrig | 3 |
| F13 | P2 | bestätigt, bis 1 h Stale | mittel | 3 |
| F14 | P2 | bestätigt | mittel, zusammen mit F05 | 2 |
| F15 | P2 | bestätigt, plus Sperrlisten-Fehler | mittel | 2 |
| F16 | P2 | bestätigt, aktuell 4 fällige Fälle, alle nicht abrechenbar | niedrig | 5 |
| F17 | P2 | teilweise: nur der Score-Job kann 60 s reißen | niedrig bis mittel, Einzeiler | 4 |
| F18 | P2 | bestätigt | mittel (verpasster Cron = Monat ohne Kontingent) | 4 |
| F19 | P2 | bestätigt, kein `Isolatable` | niedrig, Einzeiler | 4 |
| F20 | P2 | bestätigt, Runbook verspricht Alarm, der nie kommt | niedrig bis mittel, Einzeiler | 4 |
| F21 | P2 | bestätigt, Importer ist stillgelegt | **nicht aufgenommen** | – |
| F22 | P2 | bestätigt und gemessen | mittel, Einzeiler je Stelle | 3 |
| F23 | P2 | bestätigt | niedrig bis mittel | 3 |
| F24 | P2 | bestätigt | klein | 6 |
| F25 | P2 | bestätigt; die irreführenden Tests werden in den Blöcken 2 und 3 ersetzt | die breite MySQL-/Worker-Matrix ist **aufgeschoben** | 6 |
| F26 | P2 | bestätigt, kein Fokus-Plugin geladen | niedrig | 6 |
| F27 | P3 | bestätigt | klein | 6 |
| F28 | P3 | bestätigt, Link auf `dev/frontend/hub-flux/PROGRESS.md` ist tot | klein | 6 |
Abschnitt 10 des Audits (unbelegte Beobachtungen) wurde nicht geprüft und ist nicht im Plan. Die drei übrigen Dateien im Ordner sind keine Audit-Befunde: Bildkennzeichnung ist eine eigene Spezifikation mit Feedback-Audit (noch nicht umgesetzt), Kontakterreichbarkeit ist seit den Commits `ada41cb` ff. umgesetzt.
---
## 2. Aufwandsskala und Arbeitsregeln
| Kürzel | Bedeutung |
|---|---|
| S | eine Stunde bis ein halber Tag |
| M | ein bis drei Tage |
| L | länger oder abhängig von einer Entscheidung |
Für jeden Punkt gilt: zuerst der Regressionstest, der am heutigen Code rot ist, dann der Fix, dann `php artisan test --compact` für die betroffene Datei, am Blockende die gesamte Suite und PHPStan. Kein Block wird ohne Deploy abgeschlossen. Blöcke 1 und 2 sind voneinander unabhängig und können parallel laufen; Block 1 hat Vorrang, weil er ausnutzbar ist.
---
## Block 1 — Identität und Rechte (F01, F10, F03, F02, F09, F08)
Der Kern: Firmenrechte entstehen aus einer E-Mail-Übereinstimmung, die nie auf Verifikation geprüft wird, und werden nie wieder entzogen. Alles andere in diesem Block sind Nebenwege, die dieselbe Regel unterlaufen.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 1.1 | Fortify-Feature `updateProfileInformation` in `config/fortify.php:150` abschalten. Die Kunden-UI nutzt den Endpunkt nicht; sie hat den `pending_email`-Prozess. Test: `PUT /user/profile-information` liefert 404. | S | E1 |
| 1.2 | `ContactAccessService::syncMembershipsForEmail()` verlangt `hasVerifiedEmail()` und `is_active`. Gilt für alle Aufrufer, nicht nur den Login-Listener. Test: unverifizierter Nutzer mit passender Firmenadresse erhält keinen Pivot; nach Verifikation schon. | S | keine |
| 1.3 | Herkunft am Pivot speichern: Spalte `company_user.granted_via` (`auto`, `manual`, `owner`). Migration klein, Tabelle klein. Bestehende Zeilen: `manual`, wenn sie nicht durch den Sync erklärbar sind (siehe 1.4). | S | E2 |
| 1.4 | Bestandsprüfung als Befehl mit `--dry-run`: alle `responsible`-Pivots, deren Nutzer-Mail heute weder zur Firma noch zu einem Kontakt der Firma passt. Ausgabe als Liste, keine Löschung. Danach Entscheidung je Zeile. | S | 1.3 |
| 1.5 | Entzug beim Login-Sync: `auto`-Pivots mit `responsible`, die keinen aktuellen E-Mail-Treffer mehr haben, auf `member` senken. `manual` und `owner` bleiben unberührt. Test: Kontakt löschen oder Adresse ändern, erneut anmelden, Schreibrecht ist weg. | M | 1.3, E2 |
| 1.6 | `security.blade.php`: `#[Locked]` auf `showRecoveryCodes`; die Codes nur im Render nach einer Action ausgeben, nicht aus einer Property. Test: `set('showRecoveryCodes', true)` scheitert. | S | keine |
| 1.7 | Erneuter Nachweis vor `disableTwoFactorAuthentication()`, `regenerateRecoveryCodes()` und `updateEmail()`: aktueller TOTP-Code oder Recovery-Code. Das funktioniert auch für Konten ohne Passwort. Test: ohne Code 422, mit Code Erfolg. | M | E3 |
| 1.8 | `GoogleController.php:50`: 2FA-Zweig aus `MagicLinkConsumeController.php:73-81` übernehmen; danach die drei Login-Abschlüsse in einen Service ziehen. Test in `GoogleLoginTest`: Konto mit TOTP endet als Gast auf der Challenge. | S | keine |
| 1.9 | `AppServiceProvider.php:125`: `Gate::before` nur für `is_super_admin && is_active`. Test: deaktivierter Superadmin mit Session bekommt 403 auf Admin- und Kundenroute. | S | keine |
| 1.10 | `routes/admin.php:101-104`: Nutzer-Detailroute unter `can:users:manage` stellen, zusätzlich `authorize()` in beiden Grant-Actions. Test: Rechteentzug bei offener Komponente, danach `grantCredits` scheitert. | S | keine |
**Entscheidungen**
- **E1** Fortify-Profiländerung abschalten (empfohlen) oder auf den `pending_email`-Prozess umbiegen. Abschalten ist ein Einzeiler und schließt den Einstieg vollständig; niemand nutzt den Endpunkt.
- **E2** Entzugsregel: auto-vergebene `responsible` ohne aktuellen Treffer werden beim nächsten Login auf `member` gesenkt (empfohlen), oder komplett entfernt. Absenken ist reversibel und verliert keine manuellen Rechte.
- **E3** Nachweisform für Sicherheitsaktionen: TOTP-/Recovery-Code (empfohlen, funktioniert für alle Kontotypen) oder Passwort mit Sonderweg für passwortlose Konten.
**Abnahme:** Die Kette aus F01 (fremde Adresse setzen, neu anmelden, zurückwechseln) ist als Test in `ContactAccessTest` abgebildet und hinterlässt keinen Pivot. Alle Login-Wege (Passwort, Magic Link, Google) landen bei aktivem TOTP auf der Challenge. Ergebnis von 1.4 ist gesichtet.
---
## Block 2 — Newsletter (F05, F14, F15)
Vor der nächsten Kampagne. Der Versand erreicht heute nur die ersten 500 und verschickt 480 davon doppelt.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 2.1 | `CampaignSender.php:39`: Empfängerquery vor `chunkById` mit `reorder()` auf reine ID-Reihenfolge stellen. Die Sortierung ist eine UI-Sortierung der Vorschau, keine Versandreihenfolge. Test: 1.001 Nutzer mit gemischten `created_at`, jede ID genau einmal in den Empfängerzeilen. | S | E4 |
| 2.2 | Unique-Index `(newsletter_campaign_id, user_id)` auf `newsletter_campaign_recipients`. Tabelle ist klein, kein FULLTEXT, ALTER ist sofort. | S | 2.1 |
| 2.3 | Kampagnenstart atomar: `UPDATE … SET status='sending' WHERE id=? AND status='draft'`, nur bei einer betroffenen Zeile weitermachen. Empfänger mit `insertOrIgnore`. Test: zwei Instanzen derselben Kampagne, nur eine startet. | S | 2.2 |
| 2.4 | Versandjob: atomarer Claim (`UPDATE … SET status='sending' WHERE id=? AND status='pending'`), Abschluss von Empfängerstatus und Zähler in einer Transaktion. `$tries` und `$backoff` am Job setzen statt am Worker. | M | keine |
| 2.5 | Rückgabewert von `Mail::send()` auswerten: Bei Abbruch durch die Sperrliste Empfänger auf `suppressed` statt `sent`, Zähler nicht erhöhen. Neuer Status-Wert im Enum. Test: gesperrte Adresse endet nicht als „gesendet“. | S | 2.4 |
| 2.6 | Befehl `newsletter:resume {campaign}`: alle `pending`-Empfänger einer `sending`-Kampagne neu einplanen, `failed` optional zurücksetzen. Ersetzt den unwirksamen `queue:retry`-Hinweis des Circuit Breakers. | S | 2.4 |
**Entscheidung**
- **E4** Versandreihenfolge: keine fachliche Bedeutung (empfohlen, dann reicht 2.1) oder „neueste zuerst“ als Anforderung (dann Cursor über `created_at, id`, Aufwand M).
**Abnahme:** Test über die 500er-Grenze in `NewsletterCampaignTest`; Doppelstart erzeugt keine zweite Empfängerzeile; gesperrte Adressen erscheinen in der Statistik als gesperrt.
---
## Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben (F04, F13, F22, F12, F11, F23)
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 3.1 | `LOWER(email)` entfernen in `ContactAccessService.php:132,146`, `User.php:186`, `ImportNewsletterBounces.php:131,174`. B-Tree-Index auf `contacts.email` (6 MB, FULLTEXT vorhanden, Rebuild dauert Sekunden). Test: Groß-/Kleinschreibung und Leerzeichen bleiben korrekt; EXPLAIN auf dem Dev-Dump zeigt `ref`. | S | keine |
| 3.2 | `ReleaseDetailPageData.php:76-80` und `press-kits/show.blade.php:273-306`: `withoutGlobalScopes()` durch `withoutGlobalScope(PortalScope::class)` ersetzen. Test: gelöschter Kontakt erscheint weder öffentlich noch im Pressefach. | S | keine |
| 3.3 | Dasselbe für API v1 (`PressReleaseController.php:36,238,257`, `CompanyController.php:17,34`), Vorschau (`PressReleasePreviewController.php:33-42`), Medien-Controller und `PressRelease.php:194,207,219,430,440,730`. Den Test `PressReleasePreviewTest.php:237-247` auf `delete()` umstellen. Test je Modell: PM, Firma, Kontakt. | M | 3.2 |
| 3.4 | Firmen-Observer oder Aufruf in `companies/edit.blade.php`: `WebCaches`-Flush bei `delete()` und bei Wechsel von `is_active`. Kontakt-Speichern und -Löschen im Pressefach flushen den Newsroom. `NewsroomDetailPageData.php:41` prüft vor dem Cache-Treffer auch `is_active` und `deleted_at`. Test: Cache warm, Firma deaktivieren, nächster Aufruf 404. `GoneTombstoneTest.php:162` so umbauen, dass der Cache vorher warm ist. | S | keine |
| 3.5 | Gemeinsamer URL-Resolver für Meldeverfahren (`PressReleaseReportController.php:325`, `ReportFormController.php:102`): Host gegen `config/domains.php` prüfen, Portal und Sprache aus Host und Pfad, dann Slug. Bei Duplikaten die kanonische PM bevorzugen (`canonical_press_release_id is null desc`, wie in `RedirectPortalOnlyPagesFromHub.php:73`). Fremder Host: kein Treffer. Test: gleicher Slug in beiden Portalen und beiden Sprachen. | M | keine |
| 3.6 | API v1: `per_page` als `integer|min:1|max:100`, `status` als `in:` über das Enum, `language` als `in:de,en`, `category_id` zusätzlich `is_active` und Portal-Match. Test: `per_page=-1`, `status[]=x` liefern 422. | S | keine |
| 3.7 | `PressReleaseAttachmentStorage.php:66` und `ImageService.php:85`: Rückgabewert von `put()` prüfen, bei `false` Exception. Logo-Tausch in `press-kits/show.blade.php:153` und `companies/edit.blade.php:157`: erst speichern, dann DB, dann altes Logo löschen. Test mit gemocktem Storage, der `false` liefert. | S | keine |
**Abnahme:** Kein `withoutGlobalScopes()` ohne Argument mehr außerhalb von Admin-Verwaltung und Importern (Architekturtest in Pest, der die Trefferliste einfriert). Login-Pfad ohne Vollscan.
---
## Block 4 — KI-Prüfung und Scheduler (F07, F18, F20, F17, F19)
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 4.1 | `ClassifyPressRelease` und `ScorePressRelease` bekommen den `content_hash` zum Zeitpunkt der Einplanung im Konstruktor. Vor dem Speichern Vergleich mit dem aktuellen Hash; bei Abweichung Ergebnis verwerfen, ins `ki_audits` protokollieren, neuen Lauf einplanen. Hash ebenfalls in `ki_audits` speichern. | M | keine |
| 4.2 | `PressReleaseService::publish()` und `PublishScheduledPressReleases` prüfen, dass `classified_at` nach der letzten Inhaltsänderung liegt oder der Audit-Hash zum aktuellen passt; sonst nicht veröffentlichen, sondern neu prüfen. Wenn der Re-Check einer terminierten PM rot wird, Kunde und Admin benachrichtigen statt still überspringen. Test: Text während der Analyse ändern, altes Ergebnis darf nicht freigeben. | M | 4.1 |
| 4.3 | Kontingent periodisieren: Spalte `users.press_release_quota_period` (`YYYY-MM`). `consumePublishSlot()` und `pressReleaseQuotaRemaining()` setzen den Zähler auf null, wenn die Periode nicht dem aktuellen Monat entspricht. Der Cron wird zum Aufräumer, nicht zur einzigen Wahrheit. Test: Periode wechselt ohne Cron, doppelter Reset löscht nichts. | M | E5 |
| 4.4 | `PublishScheduledPressReleases.php:117`: `FAILURE`, wenn `$failed > 0`. Fachliche Ablehnungen (Blacklist, rot) bleiben `SUCCESS`. Test auf Exit-Code. | S | keine |
| 4.5 | `ScorePressRelease`: `public int $timeout = 120`. `retry_after` in `config/queue.php` auf 150. Runbook ergänzen. | S | keine |
| 4.6 | `AggregatePressReleaseViews implements Isolatable`, Schedule mit `--isolated`. | S | keine |
**Entscheidung**
- **E5** Kontingent bleibt an den Kalendermonat gebunden (empfohlen, entspricht dem heutigen Verhalten) oder wechselt auf die Stripe-Abo-Periode. Nur Ersteres ist in diesem Plan.
---
## Block 5 — MAN-Abrechnung (F06, F16)
Beide latent. Aufnehmen, weil die Fixes klein sind und der Fehler beim ersten Datensatz mit Vertragsende ohne Vorwarnung eintritt.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 5.1 | `ManualInvoiceService::duePaymentOptions()` und die Sperr-Nachprüfung berücksichtigen `grandfathered_until`: keine Rechnung für eine Periode, die nach dem Vertragsende beginnt. Test: gestern beendet, heute endend, unbefristet. | S | E6 |
| 5.2 | Ablauf-Befehl im Scheduler: Vereinbarungen mit überschrittenem `grandfathered_until` auf beendet setzen und `GrandfatheringTerminated` senden. Heute passiert das nur manuell. | S | 5.1 |
| 5.3 | `GenerateManualInvoices`: nicht abrechenbare Vereinbarungen aus dem Fenster ausschließen (Zustandsmarke `billing_hold_reason` an der Vereinbarung) und in Seiten weiterlaufen, bis kein abrechenbarer Kandidat mehr da ist. Die Admin-Zahlungsseite zeigt die Hold-Fälle bereits; nur die Marke fehlt. Test: 50 unvollständige vor einer vollständigen. | M | keine |
**Entscheidung**
- **E6** Verhalten am Vertragsende: letzte Teilperiode wird noch abgerechnet oder nicht. Empfehlung: keine Rechnung mehr, sobald `current_period_end` nach `grandfathered_until` liegt.
---
## Block 6 — Entwicklung und Qualitätsgates (F24, F27, F28, F26, F25)
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 6.1 | `lint.yml`: `vendor/bin/pint --test` statt Schreibmodus, `contents: write` entfernen, auskommentierten Commit-Schritt löschen. | S | keine |
| 6.2 | `composer.json` Script `dev`: `npm run dev:all` statt `npm run dev`. | S | keine |
| 6.3 | `docs/README.md`: Stand-Absatz und toten Link auf `dev/archive/frontend/hub-flux/PROGRESS.md` korrigieren, Hinweis „historisch, aktueller Einstieg ist `dev/README.md`“. Kein Umbau der Doku. | S | keine |
| 6.4 | Login-Modal in `site-header.blade.php:190`: Fokus beim Öffnen auf das erste Element, Rückgabe beim Schließen, Tab-Begrenzung. Ohne neue Abhängigkeit als kleine Alpine-Direktive; alternativ `@alpinejs/focus` (Abhängigkeit, Freigabe nötig). | S | keine |
| 6.5 | Architekturtest: `withoutGlobalScopes()` ohne Argument nur in einer Whitelist erlaubt (schließt Block 3 ab). | S | 3.3 |
| 6.6 | Aufgeschoben: MySQL-/Worker-Integrationstests als zweite CI-Stufe. Sinnvoll erst nach Block 2 und 4, wenn es konkrete Sperr- und Timeout-Fälle zu beweisen gibt. | L | 2, 4 |
---
## Nicht aufgenommen
- **F21 Legacy-Import.** Befund korrekt (Vollladen der Pivot-Tabellen, kein Transaktionsschutz, Wiederanlauf ändert UUID und Slug). Der Importer läuft seit dem 16.07. nicht mehr und ist nicht im Scheduler. Falls das Altsystem noch einmal einen Delta-Lauf braucht: `importRow()` in `DB::transaction` und vorhandene PM über `(legacy_portal, legacy_id)` statt über die Importmap erkennen. Bis dahin steht nur diese Notiz.
- **Audit Abschnitt 10** (CSP, Proxy, Bildpixelgrenzen, Retention, PDF, Queue-Priorisierung). Nicht geprüft, nicht belegt. Einzelne Punkte können nach Block 6 als eigene Prüfaufträge folgen.
- **Große Volt-Dateien zerlegen.** Richtig, aber kein Fehler. Läuft nebenbei, wenn ein Block ohnehin in `press-kits/show` oder `press-releases/show` arbeitet.
---
## Reihenfolge und Deploys
Tatsächlich umgesetzt am 18.09.2026 in fünf Commits auf `technik/betriebsfixes-2026-09`. Block 3 und 4 teilen sich Dateien (User-Modell, PressReleaseService) und wurden deshalb zusammen committet.
| Deploy | Inhalt | Commit | Migrationen |
|---|---|---|---|
| A | Block 1, Punkte 1.1, 1.2, 1.6, 1.8, 1.9, 1.10 | `0ab53bf` | keine |
| B | Block 2 komplett | `a7f991f` | Unique-Index `newsletter_campaign_recipients` (räumt Doppelungen vorher weg) |
| C | Block 1, Punkte 1.3, 1.4, 1.5, 1.7 | `62202a4` | `company_user.granted_via` |
| D | Block 3 und Block 4 | `f235156` | `contacts_email_idx`; `ki_audits.content_hash/discarded_at`; `users.press_release_quota_period` (COPY, Sekunden) |
| E | Block 5 und Block 6 | `375f4df` | `user_payment_options.billing_hold_reason/-at` |
Migrationen berühren `press_releases` und `press_release_search` nicht.
### Live-Schritte nach dem Deploy
1. `php artisan migrate --force` (sechs Migrationen, alle kurz; `users` als einziges COPY, nachts einspielen).
2. `php artisan company-memberships:audit` – Liste sichten (Tabelle: nicht erklärbare `responsible` seit Go-Live; CSV vollständig in `storage/app`). Danach `--classify`, damit erklärbare Rechte als `auto` gelten und der Login-Sync sie zurücknehmen kann.
3. `php artisan schedule:list` – neuer Task `billing:expire-grandfathering` um 04:15 muss erscheinen.
4. Queue-Worker neu starten (Job-Timeouts und `retry_after` 150 greifen erst dann).
5. Kunden mit aktiver 2FA sehen auf der Sicherheitsseite ein neues Code-Feld vor „Deaktivieren“ und „Neue Codes“; E-Mail-Änderung fragt nach Passwort oder Code.
6. Nächste Newsletter-Kampagne erst nach diesem Deploy starten.
## Entscheidungen
Am 18.09.2026 alle sechs nach Empfehlung entschieden. Umsetzung ab demselben Tag.
| Nr. | Frage | Entscheidung |
|---|---|---|
| E1 | Fortify-Profiländerung abschalten oder umbiegen | abschalten |
| E2 | Auto-Rechte bei Wegfall der Grundlage senken oder entfernen | auf `member` senken |
| E3 | Nachweis vor Sicherheitsaktionen | TOTP- oder Recovery-Code |
| E4 | Newsletter-Versandreihenfolge fachlich relevant | nein |
| E5 | Kontingentperiode | Kalendermonat |
| E6 | Letzte MAN-Periode am Vertragsende | keine Rechnung mehr |
## Umsetzungsprotokoll
| Datum | Punkte | Commit | Bemerkung |
|---|---|---|---|
| 18.09.2026 | 1.1, 1.2, 1.6, 1.8, 1.9, 1.10 | `0ab53bf` | Fortify-Profilendpunkt aus, Sync verlangt verifiziertes aktives Konto, Google-2FA, `#[Locked]`, Gate nur für aktive Super-Admins, Route unter `can:users:manage` |
| 18.09.2026 | 2.1–2.6 | `a7f991f` | Auf der Dev-Kopie 470 doppelte Empfängerzeilen, die Migration räumt sie weg; Zusatzbefund: Sperrlisten-Adressen galten als „gesendet“; `newsletter:resume` |
| 18.09.2026 | 1.3, 1.4, 1.5, 1.7 | `62202a4` | `granted_via`, `company-memberships:audit`, Entzug beim Login, TOTP-/Recovery-Nachweis; passwortlose Konten (66.000 im Bestand) können die Adresse weiter ändern |
| 18.09.2026 | 3.1–3.7, 6.5 | `f235156` | 28 Dateien auf `withoutGlobalScope(PortalScope::class)`, 39 Ausnahmen per Architekturtest eingefroren; `EmailQuery` (MySQL direkt, SQLite LOWER); Firmen-Observer; `ReportedUrlResolver` |
| 18.09.2026 | 4.1–4.6 | `f235156` | Hash-Bindung in beiden Jobs und im Scheduler; Kontingentperiode; Exit-Code; Timeout 120 / retry_after 150; Aggregations-Lock |
| 18.09.2026 | 5.1–5.3 | `375f4df` | Vertragsende in Auswahl und Sperre; `billing:expire-grandfathering` mit Mail; Sperrvermerk und Seitenlauf |
| 18.09.2026 | 6.1–6.4 | `375f4df` | `pint --test`, `dev:all`, Doku-Einstieg, Fokusführung ohne neue Abhängigkeit |
Offen aus dem Plan: 6.6 (MySQL-/Worker-Integrationstests) bleibt aufgeschoben. Nachträglich zwei zufallsabhängige Tests festgezurrt (Kategorie-Portal in den API-Tests, Titel in der MAN-PDF-Adresse). Gesamtsuite nach allen Commits: 1.891 Tests grün, einer übersprungen; PHPStan ohne Meldung.