# Entwicklungsplan: System-Optimierung Pressekonto > Konsolidiert aus `system-optimierung-analyse-codex.md` und `system-optimierungen-analyse-claude.md` (Stand 18.06.2026). > Reihenfolge nach **Risiko/Nutzen**: erst Korrektheit & Security, dann risikoarme Deduplikation, dann größere Extraktionen. ## Arbeitsregeln (für jeden Schritt) - **Test-First bei Korrektheits-/Verhaltensänderungen** (Phase 0): erst einen Pest-Test schreiben, der das Soll-Verhalten festschreibt, dann ändern. - **Test-After bei reiner Extraktion** (Phasen 1–5): bestehende Feature-Tests vor und nach dem Schritt laufen lassen — sie müssen grün bleiben. - Tests gezielt laufen: `vendor/bin/sail artisan test --compact --filter=`. - Nach PHP-Änderungen: `vendor/bin/sail bin pint --dirty --format agent`. - **Web-Frontend** (`resources/views/web/**`, `components/web/**`) gehört dem User → Phase 6, separat committen, vor jedem Löschen bestätigen lassen. - Jeder Schritt = ein eigener, fokussierter Commit. --- ## Phase 0 — Korrektheit & Security (höchste Dringlichkeit, Test-First) Latente Fehlerquellen und Sicherheitslücken. Klein, aber wichtig. - [x] **0.1 BasicAuth absichern** — `!=` → `hash_equals()` (timing-sicher, beide Felder ohne Short-Circuit). Enabled-Check von `env()` (brach unter `config:cache`/im Bootstrap) in die Middleware zur Laufzeit via `config('auth.basic.*')` verlagert; Config-Block in `config/auth.php` + `.env.example` ergänzt. **Fund:** `auth.basic.user/password` waren nie als Config-Key definiert → Gate war faktisch nicht konfigurierbar; jetzt nur erzwungen, wenn aktiviert UND Zugangsdaten gesetzt. Test: `tests/Feature/BasicAuthMiddlewareTest.php`. - [x] **0.2 Legacy-Invoice-„latest"-Abfrage zentralisiert** — `LegacyInvoice::scopeLatestFirst()` als Single Source of Truth + `User::latestLegacyInvoice()`; `ApiAccessEligibilityService` und `LegacyApiCustomerReporter` (N+1-frei via Eager-Load-Scope) nutzen sie. **Bewusst erhalten:** unterschiedliches `null`-Verhalten (Gate=deny vs. Report=needs_review) ist kontextuell korrekt, keine Vereinheitlichung. Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`. - [x] **0.3 Zahlungsoption-Eligibility vereinheitlicht (period-aware, strikt)** — Entscheidung des Users: überall period-aware. Neue Single Source of Truth: `UserPaymentOption::scopeActiveNow()` (Active in laufender Periode ODER laufender Bestandsschutz) + `scopeGrandfatheredNow()`; `User::hasActivePaymentOption()`. Umgestellt: `User::hasUnlimitedPressReleaseQuota`, `User::hasActiveBooking`, `ApiAccessEligibilityService`. Mislabel behoben: `CustomerStatusSummary::$hasGrandfathered` nutzt jetzt `grandfatheredNow()` (spezifisch Bestandsschutz statt „Active ODER Grandfathered"). Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`; 5 bestehende Tests auf gültiges `grandfathered_until` angepasst (waren via Factory-`optional()` nicht-deterministisch). **Folge der strikten Wahl (geklärt 19.06.):** Ein Grandfathered-User **ohne** `grandfathered_until` zählt nicht mehr als aktiv — User-Entscheidung: `NULL = abgelaufen/ungültig`, Verhalten beibehalten (konsistent mit altem API-Gate), kein weiterer Code-Change. - [x] **0.4 Theme→Portal-Vokabular vereinheitlicht** — **Fund:** `ThemeMiddleware` war **nirgends registriert** und referenzierte projektfremde Themes (`b2in/b2a/stileigentum/style2own` aus einem anderen Projekt) → ersatzlos gelöscht. Theme→Portal-Mapping zentralisiert in `Portal::fromTheme()` (genutzt von `SetCurrentPortal`). Fremden `$defaults`-Fallback im `ThemeServiceProvider` (`b2in`/`build/b2in`) auf echten Portal-Default (`portal`/`build/portal`) korrigiert. Test: `tests/Unit/PortalFromThemeTest.php`. - [x] **0.5 Race-Conditions abgesichert** — **Fund:** Die empfohlenen Unique-Indizes existieren bereits (`credit_wallets.user_id`, `invoice_number_sequences.circle`) → keine Migration nötig. `CreditWalletService::post` ist damit bereits race-sicher (Unique-Index + `firstOrCreate`/`createOrFirst` fängt Unique-Violation; Saldo-Update unter `lockForUpdate` in Transaktion) → kein Change. `InvoiceNumberGenerator::next()` gehärtet: Erst-Insert-Pfad (kein Seeder → produktiv getroffen) crashte bei parallelem Erstaufruf an der Unique-Violation; jetzt `insertOrIgnore` (race-sicher, null-Branch eliminiert). Test ergänzt: `tests/Feature/Billing/InvoiceNumberGeneratorTest.php`. --- ## Phase 1 — Aufräumen / Toter Code (risikoarm, Paket A) Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten. - [x] **1.1 Prototyp `admin/users/table` entfernt** — Route aus `routes/admin.php` (war ohnehin durch `admin/users/{id}` unerreichbar) + `resources/views/livewire/admin/users/table.blade.php` gelöscht; Eintrag aus `routes/ADMIN_ROUTES.md` entfernt. Regressions-Test in `PanelConsolidationTest` (`Route::has('admin.users.table')` ist false). - [x] **1.2 Tote Admin-Wrapper-Views entfernt** — verifiziert: kein `view()`/`@include`/`@extends`/`@livewire` rendert `resources/views/admin/*` außer `admin.dashboard` (via `DashboardController`); Wrapper nutzten alte Route-Parameter (`request()->route('company')`). 16 tote Wrapper-Blades gelöscht; `dashboard.blade.php` + die `.md`-Doku behalten. - [x] **1.3 Tote Helfer entfernt** — `EnsureUserIsAdmin` redundantes `loadMissing('roles')` (Spatie `hasAnyRole()` lädt ohnehin); `statusColor`-Key aus `with()` in admin `edit`/`show` (Blade nutzt nur lokales `$statusClass`); `LegacyInvoicePdfController` Laufzeit-`Schema::hasColumn` (Spalte existiert seit Migration 2026_05_04) + unbenutzter `Schema`-Import. --- ## Phase 2 — Press-Release-Editoren entdoppeln (größter Einzel-Hebel, Paket B) 4 Dateien, ~5.151 Zeilen → **3.920 Zeilen (−1.231, ~24 %)**. In 9 kleinen Commits, jeder mit grüner Suite. - [x] **2.1 Admin-Editoren nutzen `InteractsWithPressReleaseTags`** — `use`-Statement + ~48 Zeilen je Admin-Datei gelöscht (byte-identische Tag-Logik). - [x] **2.2 `InteractsWithPressReleaseScheduling`-Trait** — 8 Methoden (updatedPublishMode/ScheduledDate/ScheduledTime, updated, syncScheduledAt, resolveScheduledAt, validateScheduledAtWhenReady, notifyValidationError) aus allen vier extrahiert (~85 Zeilen je Datei). - [x] **2.3 `formRules()` via `ProvidesPressReleaseFormRules`** — `baseFormRules()` (Inhalt+Scheduling+Embargo); Editoren ergänzen nur `portal`/`companyId`. - [x] **2.4 `presubmitChecks` kanonisiert** — `ProvidesPressReleasePresubmitChecks` mit der besseren Customer-Variante (Firmenname); Admin erhielt dafür `selectedCompany()`. - [x] **2.5 Kontakt-Helfer + `tagSuggestionsFor()` zentralisiert** — Fund: Admin-Inline-Query == `Contact::forCompany()`-Scope → `InteractsWithPressReleaseContacts`; `tagSuggestionsFor` in Tags-Trait. - [x] **2.6 `reprocessAfterContentChange()` im `PressReleaseService`** — 3× `contentChanged→fresh→reclassify→rescore` zentralisiert. Vollständige createDraft/updateContent **bewusst nicht erzwungen** (divergente Admin/Customer-Attribute, große Testfläche). - [x] **2.7 `Company::scopeSearchByTerm()`** — Admin-Combobox-Suche + `supportsFullTextSearch()` (2×) zentralisiert; Customer behält `CustomerCompanyContext` (join-/user-gescopt). - [x] **2.8 ``** — der presentational (wire:model-freie) Checklisten-Block extrahiert + Render-Test. **Konservativ:** interaktive Panels (Tag-Input/Publish-Schedule/Selects) blieben (wire:model/Alpine-Risiko ohne visuelle QA). - [x] **2.9 `HandlesAdminPressReleaseActions::findAdminPR()`** — ~15× `withoutGlobalScopes()->findOrFail()` zentralisiert. **Status-Badge-Enum bewusst nicht vereinheitlicht** (index `archived/draft→muted` vs. edit/show `→hub` divergieren real — Design-Entscheidung). **Neue Bausteine:** Concerns `InteractsWithPressReleaseScheduling`, `ProvidesPressReleaseFormRules`, `ProvidesPressReleasePresubmitChecks`, `InteractsWithPressReleaseContacts`, `HandlesAdminPressReleaseActions`; `PressReleaseService::reprocessAfterContentChange()`; `Company::scopeSearchByTerm()`; ``. **Offen (Design-Entscheidungen nötig):** kanonische Status-Badge-Farben (2.9); restliche Markup-Component-Extraktion mit visueller QA (2.8). --- ## Phase 3 — Backend-Querschnittslogik zentralisieren (Paket D + Domain) - [x] **3.1 `Company::scopeAccessibleBy()`** — `owner_user_id == me OR whereHas('users', me)` als Scope (Single Source of Truth); `CompanyController` (2×) und `CustomerCompanyContext::accessibleCompanyQuery()` nutzen ihn. Test: `CompanySearchByTermTest` (accessibleBy). - [x] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `PressRelease::scopeOwnedByApiUser()` (bewusst enger als `accessibleBy`: nur Autorschaft) für `findOwnedPressRelease` in PressRelease- und Image-Controller; `apiResourceRelations()` bündelt die 5× wiederholte Relation-Liste. Test: `PressReleaseAccessScopeTest`. **Bewusst nicht ersetzt:** Unique-Slug-Reimplementierung — der API-Fallback (`Str::random(8)`) divergiert vom Trait-`slugFallback()`. - [x] **3.3 `DomainContext` / toter Code** — **Fund:** `SetDomainUrl` war nirgends registriert (toter Code) → gelöscht; die Host→Config-Auflösung läuft real nur noch zentral über `DomainAssetContext::resolve()` (`ThemeServiceProvider`/`SetCurrentPortal`), keine 4-fache Dopplung mehr. **Bewusst nicht angefasst:** `CurrentPortalContext` mutable static → request-scoped Binding (test-gekoppelt, separater Schritt). - [x] **3.4 Performance** — `LogApiUsage` ist jetzt terminable (INSERT nach gesendeter Antwort, kein Queue-Worker nötig; Startzeit über Request-Attribut). Offene Single-Purchase über `User::openSinglePurchaseCount()` per-Request memoisiert (vorher 3× Count + 1× exists pro Status-Widget-Render → jetzt 1× Count), genutzt von Quota-Total/-Remaining, `hasActiveBooking` und `CustomerStatusSummary`. `CustomerCompanyContext::selectedCompany()` nutzt eine einzige `first()`-Query statt exists()+first() (Verwerfen bei Nichterreichbarkeit bleibt). `LegacySubscriptionService::cancelBundleFor` ist ein einziges Bulk-Update (UserPaymentOption ohne Observer → sicher). - [x] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalizeCountryCode()`/`isEuCountry()` als SSOT, genutzt von `resolve()`, `VatIdValidationService` und `StripeCheckoutService` (3×). `User::findByEmail()` bündelt die `LOWER(email)`-Lookups (ContactAccessService 2×, SocialAuthService 1×); bleibt bewusst case-insensitiv, weil E-Mails nicht normalisiert gespeichert werden — der indexfreundliche Direktvergleich braucht erst eine Schreib-Normalisierung (offen). Währung über `config('billing.default_currency')` ('EUR'; für Stripe-Calls `strtolower(...)`), ersetzt 8 verstreute `'EUR'`/`'eur'`-Literale und vereinheitlicht nebenbei die uneinheitliche `SinglePurchase.currency`. Neuer `CustomerAccountProvisioner::provisionVerifiedCustomer()` ist die gemeinsame Provisioning-Methode für ContactAccessService und SocialAuthService (verifiziert + aktiv + customer-Rolle). Tests: `UserFindByEmailTest`, erweiterte `VatResolverTest`. - [x] **3.6 Auth-Gates** — `Gate::define('access-admin'|'access-customer')` als SSOT (AppServiceProvider), nutzbar via `@can`/Policies. `EnsureUserIsAdmin`/`EnsureUserIsCustomer` prüfen jetzt über diese Gates (`Gate::denies(...)`). Der Impersonation-Redirect ist in die eigene Middleware `BlockAdminDuringImpersonation` ausgelagert (Sitzungs-Bedingung statt Rollenfrage), sie läuft vor der Zugriffsprüfung und ist zusätzlich als persistente Livewire-Middleware registriert. Tests: `Auth/AccessGatesTest`, bestehende `UserImpersonationTest` (Redirect bleibt grün). - **Bewusst NICHT auf `can:`-Routen-Alias umgestellt:** `EnsureUserIsAdmin` ist persistente Livewire-Middleware (`Livewire::addPersistentMiddleware`), die konkrete Klassen erwartet — der parametrisierte `can:access-admin`-Alias kann dort nicht stehen. Die Gates sind die SSOT, die Middleware der konkrete Träger. - **`EnsureApiUserIsActive` bleibt eigenständig:** Es ist eine `is_active`-Prüfung (keine Rollenfrage) und liefert eine spezifische JSON-Antwort (`'API access is disabled for inactive users.'`, im API-Contract getestet) statt einer generischen 403 — ein `can:`-Gate würde Vertrag und Meldung ändern. --- ## Phase 4 — Web-Routen entflechten (Paket C) `routes/web.php` > 1100 Zeilen mit kompletter Datenaufbereitung. Schrittweise, gut absicherbar. - [ ] **4.1 Portal-/Domain-Kontext extrahieren** (baut auf 3.3 `DomainContext` auf). - [ ] **4.2 Published-Release-Query zentralisieren** — gleiche Bedingung (Portal `both`, `status=published`, Sprache, `published_at <= now()`) mehrfach, teils mit/ohne `withoutGlobalScope(PortalScope)`. → Scopes `scopePublished`, `scopeForPortalValues`, `scopeForLanguage`, `scopeVisibleInArchive`. - [ ] **4.3 Page-Data in Services** — `HomePageData`/`CategoryPageData`/`NewsroomPageData` für Home/Kategorie/Newsroom/Detail/Suche/Preise. - [ ] **4.4 Routen auf schlanke (Invokable) Controller umstellen** — schrittweise je Seite. --- ## Phase 5 — Admin-CRUD & Auth: Concerns + Blade-Components (risikoarm) - [ ] **5.1 Tabellen-Concerns** — `HasSortableTable` (`sort()` 3× wortgleich), `HasFullTextSearch` (`supportsFullTextSearch()` 6×+ + `applyFuzzySearch`), `HasResettableFilters` (`updatedXxxFilter()`→`resetPage`). `stats()` mit `SUM(CASE WHEN is_active)` → `AdminPerformanceCache::activeInactiveStats()`; Count-Hydration → `hydrateRelationCounts()`. - [ ] **5.2 User create/edit entdoppeln** — `UserFormRules`-Provider + `UserUpsertService`/`SaveUserAction` (`save()` trägt ~175 Zeilen). Markup → ``, ``, ``. Kontakt → `ValidatesContactData`-Concern + ``. - [ ] **5.3 Formatierungs-Components/Enum** — `Portal::badgeColor()` + ``; ``; `PressReleaseStatus`-Enum-Methoden; ``, ``, `Category::localizedName()`, `Contact::displayName()`. - [ ] **5.4 Seiten-Struktur-Components** — ``, ``, ``, ``, ``, ``. (Muster: bestehende ``/`components.portal.pagination`.) - [ ] **5.5 Flash/Auth-Components** — `` + `` app-weit; ``, ``/``; `ensureNotRateLimited()/throttleKey()`-Trait (login/magic-link divergieren); ``. --- ## Phase 6 — Web-Frontend (separat, mit User abstimmen) > `resources/views/web/**` + `components/web/**` gehören dem User. Beratend; eigener Commit. „unused"-Zählungen stammen aus `grep` — dynamische Auflösung vor Löschen prüfen. - [ ] **6.1 Pricing-Cards** — `web/pressekonto.blade.php` codet 3× ~67-Zeilen-`
`s, obwohl datengetriebenes `` (iteriert `Plan::active()`) existiert. → einsetzen (~200 Zeilen → eine Schleife). - [ ] **6.2 Release-Cards vereinheitlichen** — `press-release-card` ≈ `presseecho-release-card` (~95 %, letztere 0 Verwendungen). → eine Komponente mit `variant`-Prop oder löschen. Industrie-Icon-Map (3×) → `CategoryService`/Config; Release-Normalisierung (3×) → `ReleaseCardData::from()`. - [ ] **6.3 Footer/Header/Toter Code** — `site-footer` ≈ `hub/site-footer`, Top-Utility-Bar; tote Legacy-Cards in `web/examples/` nach Bestätigung entfernen; Inline-SVGs → `components/web/icons/`-Set; `` für `max-w-layout`-Shell. --- ## Reihenfolge-Empfehlung (TL;DR) 1. **Phase 0** zuerst — Korrektheit/Security, Test-First. 2. **Phase 1** — schnelles Aufräumen, kein Risiko. 3. **Phase 2** — größter Dedup-Gewinn (PR-Editoren), inkrementell. 4. **Phase 3 + 4** — Backend/Web-Architektur (mittlerer Aufwand, hoher struktureller Gewinn). 5. **Phase 5** — Admin/Auth-Components. 6. **Phase 6** — Web-Frontend separat mit dem User. **Beibehalten (gute Muster):** `PressRelease::accessibleBy`, `PortalScope`, `HasUniqueSlug`, Form Requests + Eloquent Resources, `AdminPerformanceCache`, `InteractsWithPressReleaseTags`, ``/`components.portal.pagination`.