Die zwischen Benutzer-Anlegen und -Bearbeiten identischen Validierungsbloecke liegen jetzt in ProvidesUserFormRules: userIdentityRules(?int $ignore) (E-Mail- Unique ignoriert beim Bearbeiten den eigenen Datensatz), userRolesAndCompaniesRules() und userBillingRules(). Beide Formulare nutzen die Array-Spreads; die nur im Bearbeiten genutzten Profil-/Kontakt-Regeln bleiben dort inline. Ungenutzten Rule-Import aus dem Anlegen-Formular entfernt. Offen (mit User abzustimmen, UI-/Divergenz-lastig): gemeinsamer Save-Service und die Blade-Feld-Components. 31 User-Tests gruen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
Entwicklungsplan: System-Optimierung Pressekonto
Konsolidiert aus
system-optimierung-analyse-codex.mdundsystem-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.
- 0.1 BasicAuth absichern —
!=→hash_equals()(timing-sicher, beide Felder ohne Short-Circuit). Enabled-Check vonenv()(brach unterconfig:cache/im Bootstrap) in die Middleware zur Laufzeit viaconfig('auth.basic.*')verlagert; Config-Block inconfig/auth.php+.env.exampleergänzt. Fund:auth.basic.user/passwordwaren nie als Config-Key definiert → Gate war faktisch nicht konfigurierbar; jetzt nur erzwungen, wenn aktiviert UND Zugangsdaten gesetzt. Test:tests/Feature/BasicAuthMiddlewareTest.php. - 0.2 Legacy-Invoice-„latest"-Abfrage zentralisiert —
LegacyInvoice::scopeLatestFirst()als Single Source of Truth +User::latestLegacyInvoice();ApiAccessEligibilityServiceundLegacyApiCustomerReporter(N+1-frei via Eager-Load-Scope) nutzen sie. Bewusst erhalten: unterschiedlichesnull-Verhalten (Gate=deny vs. Report=needs_review) ist kontextuell korrekt, keine Vereinheitlichung. Test:tests/Feature/ApiAccessEligibilityServiceTest.php. - 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::$hasGrandfatherednutzt jetztgrandfatheredNow()(spezifisch Bestandsschutz statt „Active ODER Grandfathered"). Test:tests/Feature/ApiAccessEligibilityServiceTest.php; 5 bestehende Tests auf gültigesgrandfathered_untilangepasst (waren via Factory-optional()nicht-deterministisch). Folge der strikten Wahl (geklärt 19.06.): Ein Grandfathered-User ohnegrandfathered_untilzählt nicht mehr als aktiv — User-Entscheidung:NULL = abgelaufen/ungültig, Verhalten beibehalten (konsistent mit altem API-Gate), kein weiterer Code-Change. - 0.4 Theme→Portal-Vokabular vereinheitlicht — Fund:
ThemeMiddlewarewar nirgends registriert und referenzierte projektfremde Themes (b2in/b2a/stileigentum/style2ownaus einem anderen Projekt) → ersatzlos gelöscht. Theme→Portal-Mapping zentralisiert inPortal::fromTheme()(genutzt vonSetCurrentPortal). Fremden$defaults-Fallback imThemeServiceProvider(b2in/build/b2in) auf echten Portal-Default (portal/build/portal) korrigiert. Test:tests/Unit/PortalFromThemeTest.php. - 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::postist damit bereits race-sicher (Unique-Index +firstOrCreate/createOrFirstfängt Unique-Violation; Saldo-Update unterlockForUpdatein Transaktion) → kein Change.InvoiceNumberGenerator::next()gehärtet: Erst-Insert-Pfad (kein Seeder → produktiv getroffen) crashte bei parallelem Erstaufruf an der Unique-Violation; jetztinsertOrIgnore(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.
- 1.1 Prototyp
admin/users/tableentfernt — Route ausroutes/admin.php(war ohnehin durchadmin/users/{id}unerreichbar) +resources/views/livewire/admin/users/table.blade.phpgelöscht; Eintrag ausroutes/ADMIN_ROUTES.mdentfernt. Regressions-Test inPanelConsolidationTest(Route::has('admin.users.table')ist false). - 1.2 Tote Admin-Wrapper-Views entfernt — verifiziert: kein
view()/@include/@extends/@livewirerendertresources/views/admin/*außeradmin.dashboard(viaDashboardController); Wrapper nutzten alte Route-Parameter (request()->route('company')). 16 tote Wrapper-Blades gelöscht;dashboard.blade.php+ die.md-Doku behalten. - 1.3 Tote Helfer entfernt —
EnsureUserIsAdminredundantesloadMissing('roles')(SpatiehasAnyRole()lädt ohnehin);statusColor-Key auswith()in adminedit/show(Blade nutzt nur lokales$statusClass);LegacyInvoicePdfControllerLaufzeit-Schema::hasColumn(Spalte existiert seit Migration 2026_05_04) + unbenutzterSchema-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.
- 2.1 Admin-Editoren nutzen
InteractsWithPressReleaseTags—use-Statement + ~48 Zeilen je Admin-Datei gelöscht (byte-identische Tag-Logik). - 2.2
InteractsWithPressReleaseScheduling-Trait — 8 Methoden (updatedPublishMode/ScheduledDate/ScheduledTime, updated, syncScheduledAt, resolveScheduledAt, validateScheduledAtWhenReady, notifyValidationError) aus allen vier extrahiert (~85 Zeilen je Datei). - 2.3
formRules()viaProvidesPressReleaseFormRules—baseFormRules()(Inhalt+Scheduling+Embargo); Editoren ergänzen nurportal/companyId. - 2.4
presubmitCheckskanonisiert —ProvidesPressReleasePresubmitChecksmit der besseren Customer-Variante (Firmenname); Admin erhielt dafürselectedCompany(). - 2.5 Kontakt-Helfer +
tagSuggestionsFor()zentralisiert — Fund: Admin-Inline-Query ==Contact::forCompany()-Scope →InteractsWithPressReleaseContacts;tagSuggestionsForin Tags-Trait. - 2.6
reprocessAfterContentChange()imPressReleaseService— 3×contentChanged→fresh→reclassify→rescorezentralisiert. Vollständige createDraft/updateContent bewusst nicht erzwungen (divergente Admin/Customer-Attribute, große Testfläche). - 2.7
Company::scopeSearchByTerm()— Admin-Combobox-Suche +supportsFullTextSearch()(2×) zentralisiert; Customer behältCustomerCompanyContext(join-/user-gescopt). - 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). - 2.9
HandlesAdminPressReleaseActions::findAdminPR()— ~15×withoutGlobalScopes()->findOrFail()zentralisiert. Status-Badge-Enum bewusst nicht vereinheitlicht (indexarchived/draft→mutedvs. edit/show→hubdivergieren 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()—owner_user_id == me OR whereHas('users', me)als Scope (Single Source of Truth);CompanyController(2×) undCustomerCompanyContext::accessibleCompanyQuery()nutzen ihn. Test:CompanySearchByTermTest(accessibleBy). - 3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren —
PressRelease::scopeOwnedByApiUser()(bewusst enger alsaccessibleBy: nur Autorschaft) fürfindOwnedPressReleasein 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(). - 3.3
DomainContext/ toter Code — Fund:SetDomainUrlwar nirgends registriert (toter Code) → gelöscht; die Host→Config-Auflösung läuft real nur noch zentral überDomainAssetContext::resolve()(ThemeServiceProvider/SetCurrentPortal), keine 4-fache Dopplung mehr. Bewusst nicht angefasst:CurrentPortalContextmutable static → request-scoped Binding (test-gekoppelt, separater Schritt). - 3.4 Performance —
LogApiUsageist jetzt terminable (INSERT nach gesendeter Antwort, kein Queue-Worker nötig; Startzeit über Request-Attribut). Offene Single-Purchase überUser::openSinglePurchaseCount()per-Request memoisiert (vorher 3× Count + 1× exists pro Status-Widget-Render → jetzt 1× Count), genutzt von Quota-Total/-Remaining,hasActiveBookingundCustomerStatusSummary.CustomerCompanyContext::selectedCompany()nutzt eine einzigefirst()-Query statt exists()+first() (Verwerfen bei Nichterreichbarkeit bleibt).LegacySubscriptionService::cancelBundleForist ein einziges Bulk-Update (UserPaymentOption ohne Observer → sicher). - 3.5 Kleinere Zentralisierungen —
VatResolver::normalizeCountryCode()/isEuCountry()als SSOT, genutzt vonresolve(),VatIdValidationServiceundStripeCheckoutService(3×).User::findByEmail()bündelt dieLOWER(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 überconfig('billing.default_currency')('EUR'; für Stripe-Callsstrtolower(...)), ersetzt 8 verstreute'EUR'/'eur'-Literale und vereinheitlicht nebenbei die uneinheitlicheSinglePurchase.currency. NeuerCustomerAccountProvisioner::provisionVerifiedCustomer()ist die gemeinsame Provisioning-Methode für ContactAccessService und SocialAuthService (verifiziert + aktiv + customer-Rolle). Tests:UserFindByEmailTest, erweiterteVatResolverTest. - 3.6 Auth-Gates —
Gate::define('access-admin'|'access-customer')als SSOT (AppServiceProvider), nutzbar via@can/Policies.EnsureUserIsAdmin/EnsureUserIsCustomerprüfen jetzt über diese Gates (Gate::denies(...)). Der Impersonation-Redirect ist in die eigene MiddlewareBlockAdminDuringImpersonationausgelagert (Sitzungs-Bedingung statt Rollenfrage), sie läuft vor der Zugriffsprüfung und ist zusätzlich als persistente Livewire-Middleware registriert. Tests:Auth/AccessGatesTest, bestehendeUserImpersonationTest(Redirect bleibt grün).- Bewusst NICHT auf
can:-Routen-Alias umgestellt:EnsureUserIsAdminist persistente Livewire-Middleware (Livewire::addPersistentMiddleware), die konkrete Klassen erwartet — der parametrisiertecan:access-admin-Alias kann dort nicht stehen. Die Gates sind die SSOT, die Middleware der konkrete Träger. EnsureApiUserIsActivebleibt eigenständig: Es ist eineis_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 — eincan:-Gate würde Vertrag und Meldung ändern.
- Bewusst NICHT auf
Phase 4 — Web-Routen entflechten (Paket C)
routes/web.php > 1100 Zeilen mit kompletter Datenaufbereitung. Schrittweise, gut absicherbar.
- 4.1 Portal-/Domain-Kontext extrahieren — Die je Route-Closure ~6× wiederholte Host→Portal-Ableitung liegt jetzt in
WebPortalContext::fromRequest()(liefertportal,language,portalValues,domainConfig). Die Domain-/Theme-Anwendung (config +View::share+ Vite) ist alsDomainAssetContext::applyWebDomainConfig()zentralisiert (vorher die$applyWebDomainConfig-Closure inroutes/web.php, jetzt ein dünner Delegations-Wrapper, damit Bare-Root- und Edition-Home-Route unverändert bleiben). Abgesichert durch die 92Web/-Tests, die jede Seite über den neuen Kontext laufen lassen. - 4.2 Published-Release-Query zentralisieren — vier komponierbare Scopes auf
PressRelease:scopePublished()(Status + gesetztes Datum, ohne Zeitschranke → für Statistik-Fenster),scopeVisibleInArchive()(published()+published_at <= now()),scopeForPortalValues(array),scopeForLanguage(string). ~19 dupliziierte Query-Blöcke inroutes/web.php(Home, Kategorie, Detail, Veröffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) darüber zentralisiert; jedeswithoutGlobalScope(PortalScope)an seiner Stelle erhalten (Klausel-Reihenfolge ist für das Ergebnis irrelevant, da reine AND-Verknüpfung). Test:Web/PublishedReleaseScopeTest; die 89 bestehendenWeb/-Tests bleiben grün. - 4.3 Page-Data in Services — acht
App\Services\Web\*PageData-Klassen (HomePageData,CategoryPageData,ReleaseDetailPageData,VeroeffentlichenPageData,SuchePageData,NewsroomsPageData,NewsroomDetailPageData,PreisePageData) bündeln die komplette Datenaufbereitung; sie bekommen denWebPortalContext(bzw. Portal+Sprache) und liefern das View-Daten-Array. - 4.4 Routen auf schlanke (Invokable) Controller umstellen — acht
App\Http\Controllers\Web\*Controller(je Seite ein Commit) lösen denWebPortalContextauf, rufen die Page-Data und geben die View zurück.routes/web.phpvon ~1080 auf 233 Zeilen geschrumpft (nur noch Routing + Legacy-301-Redirects + Bare-Root/Hub). Hinweis: Bei{slug}-Routen führt die Controller-Signaturstring $editionvorstring $slug(positionsgebundene Route-Parameter-Auflösung in Laravel). Verhalten unverändert; die 92Web/-Tests bleiben grün.
Phase 5 — Admin-CRUD & Auth: Concerns + Blade-Components (risikoarm)
- 5.1 Tabellen-Concerns —
HasSortableTable::sort()(5 byte-identische Kopien zentralisiert;$sortBy/$sortDir-Defaults bleiben in der Komponente, da sie divergieren) undHasFullTextSearch::supportsFullTextSearch()(9 byte-identische Kopien — 4 Index-Listen + 5 Firmen-Comboboxen; nicht mehr genutzteDB-Imports entfernt).AdminPerformanceCache::activeInactiveStats(Builder)bündelt den identischenSUM(CASE WHEN is_active)-Block der Benutzer- und Firmenliste. Tests: erweiterteAdminPerformanceCacheTest; abgesichert durch 99Admin/-Tests. Bewusst zurückgestellt:HasResettableFilters(dieupdatedXxxFilter()divergieren — manche normalisieren zusätzlich Werte; ein generischerupdated()-Hook wäre eine Verhaltensänderung) und eine gemeinsamehydrateRelationCounts()(Count-Hydration unterscheidet sich je Tabelle real). - [~] 5.2 User create/edit entdoppeln — Teil 1 erledigt:
ProvidesUserFormRules-Concern bündelt die zwischen Anlegen/Bearbeiten identischen Validierungsblöcke (userIdentityRules(?int $ignore),userRolesAndCompaniesRules(),userBillingRules()); die nur im Bearbeiten genutzten Profil-/Kontakt-Regeln bleiben dort inline. Beide Formulare nutzen die Spreads, 31 User-Tests grün. Offen (bewusst, mit User abzustimmen): gemeinsamerUserUpsertService/SaveUserActionfür die ~175-Zeilen-save()(Anlegen/Bearbeiten divergieren stark — Bearbeiten trägt zusätzlich Profil, Kontakte, Passwort, 2FA), sowie die Blade-Feld-Components (billing-fields,company-link-picker,role-checkboxes,contact-fields) — Markup-Extraktion braucht visuelle QA (analog zur zurückgestellten 2.8). - 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 ausgrep— dynamische Auflösung vor Löschen prüfen.
- 6.1 Pricing-Cards —
web/pressekonto.blade.phpcodet 3× ~67-Zeilen-<article>s, obwohl datengetriebenes<x-web.pricing-teaser>(iteriertPlan::active()) existiert. → einsetzen (~200 Zeilen → eine Schleife). - 6.2 Release-Cards vereinheitlichen —
press-release-card≈presseecho-release-card(~95 %, letztere 0 Verwendungen). → eine Komponente mitvariant-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 inweb/examples/nach Bestätigung entfernen; Inline-SVGs →components/web/icons/-Set;<x-web.container>fürmax-w-layout-Shell.
Reihenfolge-Empfehlung (TL;DR)
- Phase 0 zuerst — Korrektheit/Security, Test-First.
- Phase 1 — schnelles Aufräumen, kein Risiko.
- Phase 2 — größter Dedup-Gewinn (PR-Editoren), inkrementell.
- Phase 3 + 4 — Backend/Web-Architektur (mittlerer Aufwand, hoher struktureller Gewinn).
- Phase 5 — Admin/Auth-Components.
- 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.