presseportale/dev/optimize/entwicklungsplan.md
Kevin Adametz 89842ac37f Optimierung 5.2 (Teil 1): ProvidesUserFormRules-Concern
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>
2026-06-19 12:41:57 +00:00

19 KiB
Raw Blame History

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.

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

  • 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).
  • 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.
  • 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.

  • 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() via ProvidesPressReleaseFormRules — baseFormRules() (Inhalt+Scheduling+Embargo); Editoren ergänzen nur portal/companyId.
  • 2.4 presubmitChecks kanonisiert — ProvidesPressReleasePresubmitChecks mit der besseren Customer-Variante (Firmenname); Admin erhielt dafür selectedCompany().
  • 2.5 Kontakt-Helfer + tagSuggestionsFor() zentralisiert — Fund: Admin-Inline-Query == Contact::forCompany()-Scope → InteractsWithPressReleaseContacts; tagSuggestionsFor in Tags-Trait.
  • 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).
  • 2.7 Company::scopeSearchByTerm() — Admin-Combobox-Suche + supportsFullTextSearch() (2×) zentralisiert; Customer behält CustomerCompanyContext (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 (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() — owner_user_id == me OR whereHas('users', me) als Scope (Single Source of Truth); CompanyController (2×) und CustomerCompanyContext::accessibleCompanyQuery() nutzen ihn. Test: CompanySearchByTermTest (accessibleBy).
  • 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().
  • 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).
  • 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).
  • 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 — 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 — Die je Route-Closure ~6× wiederholte Host→Portal-Ableitung liegt jetzt in WebPortalContext::fromRequest() (liefert portal, language, portalValues, domainConfig). Die Domain-/Theme-Anwendung (config + View::share + Vite) ist als DomainAssetContext::applyWebDomainConfig() zentralisiert (vorher die $applyWebDomainConfig-Closure in routes/web.php, jetzt ein dünner Delegations-Wrapper, damit Bare-Root- und Edition-Home-Route unverändert bleiben). Abgesichert durch die 92 Web/-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 in routes/web.php (Home, Kategorie, Detail, Veröffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) darüber zentralisiert; jedes withoutGlobalScope(PortalScope) an seiner Stelle erhalten (Klausel-Reihenfolge ist für das Ergebnis irrelevant, da reine AND-Verknüpfung). Test: Web/PublishedReleaseScopeTest; die 89 bestehenden Web/-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 den WebPortalContext (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 den WebPortalContext auf, rufen die Page-Data und geben die View zurück. routes/web.php von ~1080 auf 233 Zeilen geschrumpft (nur noch Routing + Legacy-301-Redirects + Bare-Root/Hub). Hinweis: Bei {slug}-Routen führt die Controller-Signatur string $edition vor string $slug (positionsgebundene Route-Parameter-Auflösung in Laravel). Verhalten unverändert; die 92 Web/-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) und HasFullTextSearch::supportsFullTextSearch() (9 byte-identische Kopien — 4 Index-Listen + 5 Firmen-Comboboxen; nicht mehr genutzte DB-Imports entfernt). AdminPerformanceCache::activeInactiveStats(Builder) bündelt den identischen SUM(CASE WHEN is_active)-Block der Benutzer- und Firmenliste. Tests: erweiterte AdminPerformanceCacheTest; abgesichert durch 99 Admin/-Tests. Bewusst zurückgestellt: HasResettableFilters (die updatedXxxFilter() divergieren — manche normalisieren zusätzlich Werte; ein generischer updated()-Hook wäre eine Verhaltensänderung) und eine gemeinsame hydrateRelationCounts() (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): gemeinsamer UserUpsertService/SaveUserAction fü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 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.