- VatResolver::normalizeCountryCode()/isEuCountry() als Single Source of Truth;
resolve(), VatIdValidationService und StripeCheckoutService nutzen jetzt die
zentrale EU-Pruefung statt eigener config-Lookups.
- User::findByEmail() buendelt die LOWER(email)-Lookups (ContactAccessService,
SocialAuthService). Bleibt bewusst case-insensitiv, da E-Mails nicht
normalisiert gespeichert werden; der indexfreundliche Direktvergleich
braucht erst eine Schreib-Normalisierung (offen, dokumentiert).
- config('billing.default_currency') ersetzt 8 verstreute 'EUR'/'eur'-Literale
(Stripe-Calls via strtolower). Vereinheitlicht nebenbei die zuvor uneinheitlich
gespeicherte SinglePurchase.currency.
- Neuer CustomerAccountProvisioner::provisionVerifiedCustomer() ist die
gemeinsame Provisioning-Methode (verifiziert + aktiv + customer-Rolle) fuer
ContactAccessService und SocialAuthService.
- Tests: UserFindByEmailTest, erweiterte VatResolverTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
15 KiB
Markdown
111 lines
15 KiB
Markdown
# 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=<Name>`.
|
||
- 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 `<x-portal.pr-presubmit-check>`** — 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()`; `<x-portal.pr-presubmit-check>`.
|
||
**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)
|
||
|
||
- [ ] **3.1 `Company::scopeAccessibleBy()` / `CompanyPolicy`** — identische `owner_user_id == me OR whereHas('users', me)`-Closure in 4 Controllern + Überlappung mit `User::accessibleCompanyIds()` und `CustomerCompanyContext::accessibleCompanyQuery()`. Policy wird in `ContactAccessService:229` bereits referenziert. → konsequent über Gate/Policy.
|
||
- [ ] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `findOwnedPressRelease` doppelt (PressRelease/Image-Controller); Relation-Liste (`company:…`, `category.translations`, `images`) mehrfach. → expliziter Scope (`ownedByApiUser`) + `withApiResourceRelations()`. Unique-Slug-Reimplementierung (`PressReleaseController:201-220`) durch bestehendes `HasUniqueSlug` ersetzen.
|
||
- [ ] **3.3 `DomainContext`-Service** — Host→Config-Auflösung 4× (`ThemeServiceProvider::boot`, `ThemeMiddleware`, `SetDomainUrl`, `SetCurrentPortal`). **Vorher prüfen, ob `SetDomainUrl` toter Code ist** (scheint nicht in `bootstrap/app.php` registriert). `CurrentPortalContext` mutable static → request-scoped Binding.
|
||
- [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`.
|
||
- [ ] **3.6 Auth-Gates** — `EnsureUserIsAdmin/Customer`, `EnsureApiUserIsActive` von Hand-Rolling → `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware. Impersonation-Redirect aus dem Admin-Gate in eigene Middleware.
|
||
|
||
---
|
||
|
||
## 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 → `<x-admin.billing-fields>`, `<x-admin.company-link-picker>`, `<x-admin.role-checkboxes>`. Kontakt → `ValidatesContactData`-Concern + `<x-contact-fields>`.
|
||
- [ ] **5.3 Formatierungs-Components/Enum** — `Portal::badgeColor()` + `<x-portal.portal-badge>`; `<x-portal.status-badge>`; `PressReleaseStatus`-Enum-Methoden; `<x-portal.money>`, `<x-portal.datetime>`, `Category::localizedName()`, `Contact::displayName()`.
|
||
- [ ] **5.4 Seiten-Struktur-Components** — `<x-portal.page-header>`, `<x-portal.stat-cards>`, `<x-portal.table-panel>`, `<x-portal.table-pagination>`, `<x-portal.empty-state>`, `<x-portal.searchable-select>`. (Muster: bestehende `<x-portal.stat-card>`/`components.portal.pagination`.)
|
||
- [ ] **5.5 Flash/Auth-Components** — `<x-portal.alert :dismissible>` + `<x-session-status>` app-weit; `<x-password-input>`, `<x-form-field>`/`<x-email-input>`; `ensureNotRateLimited()/throttleKey()`-Trait (login/magic-link divergieren); `<x-auth.divider-actions>`.
|
||
|
||
---
|
||
|
||
## 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-`<article>`s, obwohl datengetriebenes `<x-web.pricing-teaser>` (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; `<x-web.container>` 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`, `<x-portal.stat-card>`/`components.portal.pagination`.
|
||
</content>
|
||
</invoke>
|