presseportale/dev/optimize/system-optimierungen-analyse-claude.md
Kevin Adametz a82964df2b Optimierung Phase 0: Korrektheit & Security
Konsolidierter Entwicklungsplan aus den zwei Analyse-Dokumenten plus
Umsetzung der Phase-0-Massnahmen (Korrektheit/Security, je mit Tests):

0.1 BasicAuth: timing-sicherer Vergleich (hash_equals, beide Felder ohne
    Short-Circuit); Enabled-Check zur Laufzeit via config('auth.basic.*')
    statt env() im Bootstrap-Closure (greift jetzt unter config:cache);
    Config-Block + .env.example. Fund: auth.basic.* war nie als Config
    definiert, Gate war faktisch unkonfigurierbar.
0.2 Legacy-Invoice-"latest"-Abfrage zentralisiert (LegacyInvoice::
    scopeLatestFirst + User::latestLegacyInvoice); beide Consumer N+1-frei
    umgestellt; unterschiedliches null-Verhalten bewusst erhalten.
0.3 Payment-Option-Eligibility vereinheitlicht (period-aware, strikt):
    UserPaymentOption::scopeActiveNow/grandfatheredNow + User::
    hasActivePaymentOption; hasActiveBooking/hasUnlimitedPressReleaseQuota
    und ApiAccessEligibilityService darauf umgestellt; $hasGrandfathered-
    Mislabel behoben.
0.4 Theme->Portal-Vokabular: totes, projektfremdes ThemeMiddleware
    geloescht; Mapping in Portal::fromTheme() zentralisiert; fremden
    b2in-Default im ThemeServiceProvider auf Portal-Default korrigiert.
0.5 InvoiceNumberGenerator: Erst-Insert race-sicher via insertOrIgnore
    (Unique-Index auf circle existiert bereits). CreditWalletService war
    bereits abgesichert (Unique-Index + createOrFirst).

Tests: 4 neue Dateien, 5 bestehende auf gueltiges grandfathered_until
angepasst. Volle Suite gruen (793 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:09:02 +00:00

225 lines
27 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.

# System-Optimierungs-Analyse (Pressekonto)
> Erstellt von Claude Code am 2026-06-18. Read-only-Analyse — **es wurden keine Dateien geändert.**
> Fokus: Code-Doppelungen, geteilter/wiederverwendbarer Code, Effizienz- und Best-Practice-Potenzial über Controller, Services, Models, Middleware, Livewire/Volt-Komponenten und Blade-Views.
## Inhaltsverzeichnis
1. [Management Summary](#1-management-summary)
2. [Top-Prioritäten (höchster Hebel)](#2-top-prioritäten-höchster-hebel)
3. [Backend: Controller, Services, Models, Middleware](#3-backend-controller-services-models-middleware)
4. [Livewire/Volt: Press-Release-Editoren](#4-livewirevolt-press-release-editoren)
5. [Livewire/Volt: Admin-CRUD, Customer, Settings, Auth](#5-livewirevolt-admin-crud-customer-settings-auth)
6. [Web-Frontend (gehört dem User, separat committen)](#6-web-frontend-gehört-dem-user-separat-committen)
7. [Vorgeschlagene neue Bausteine (Traits/Components/Services)](#7-vorgeschlagene-neue-bausteine)
---
## 1. Management Summary
Das System ist **architektonisch bereits sauber** aufgesetzt: eine umfangreiche Service-Schicht (`app/Services/**`), Domain-/Theme-System, Concerns-Verzeichnis, geteilte Portal-Komponenten (`x-portal.stat-card`, `components.portal.pagination`) und ein `PressReleaseService` existieren. Die Probleme liegen nicht in der Struktur, sondern in **Duplikation an den Rändern**:
- **Die größten Brocken sind die vier Press-Release-Editoren** (admin + customer, create + edit) mit zusammen ~5.500 Zeilen Blade/PHP, die zu ~80–95 % identische Validierung, Scheduling-Logik, Persistierung und Formular-Markup enthalten.
- **Querschnittslogik ist mehrfach implementiert**: Firmenzugriff/Ownership (3–4×), Zahlungsoption-Eligibility (4×), VAT-Normalisierung (3×), Volltextsuche-Fallback (6×), Tabellen-Sortierung/Filter (3×).
- **Diverging-Risk (Korrektheit!):** Mehrere dieser Duplikate sind bereits leicht auseinandergedriftet (z. B. Legacy-Invoice-Eligibility liefert bei `null` mal *deny*, mal *needs_review*). Das ist nicht nur Wartungslast, sondern eine latente Fehlerquelle.
- **Domain/Theme/Portal-Auflösung** ist über 4 Stellen (Provider + 3 Middleware) verteilt und teils mit unterschiedlichen Theme-Vokabularen.
- **Wenige echte Performance-/Security-Punkte**: synchrones API-Logging pro Request, Query-Sturm im Status-Widget, timing-unsicherer Basic-Auth-Vergleich, Race-Conditions bei Wallet-/Rechnungsnummern-Erstellung.
Mehrheit der Findings ist **risikoarm** (reine Extraktion ohne Verhaltensänderung) bei hohem Zeilen-Einsparpotenzial.
---
## 2. Top-Prioritäten (höchster Hebel)
| # | Maßnahme | Bereich | Severity | Aufwand |
|---|----------|---------|----------|---------|
| 1 | `InteractsWithPressReleaseScheduling`-Trait + Admin-Editoren nutzen bestehenden Tags-Trait | PR-Editoren | Hoch | Niedrig |
| 2 | Persistierung + `formRules()` der PR-Editoren in `PressReleaseService` / geteilten Trait verlagern | PR-Editoren | Hoch | Mittel |
| 3 | `Company::scopeAccessibleBy()` / `CompanyPolicy` — Firmenzugriff zentralisieren | Backend | Hoch | Mittel |
| 4 | Eine `User::hasActivePaymentOption()` + `latestLegacyInvoice()` (Eligibility-Divergenz beheben) | Backend | Hoch (Korrektheit) | Mittel |
| 5 | Domain/Theme/Portal-Auflösung in einen `DomainContext`-Service konsolidieren | Middleware | Hoch | Mittel |
| 6 | Concerns: `HasSortableTable`, `HasFullTextSearch`, `HasResettableFilters` | Admin-CRUD | Hoch | Niedrig |
| 7 | `<x-portal.alert>` + `<x-session-status>` + `<x-password-input>` / `<x-form-field>` | Admin/Auth | Hoch | Niedrig |
| 8 | `Portal::badgeColor()` + `<x-portal.portal-badge>` / `<x-portal.status-badge>` | Querschnitt | Hoch | Niedrig |
| 9 | Synchrones `LogApiUsage` → terminable/queued; Status-Widget-Queries memoisieren | Performance | Hoch | Mittel |
| 10 | `BasicAuthMiddleware` → `hash_equals()`; `BASIC_AUTH_ENABLED` aus `env()` in Config | Security | Hoch | Niedrig |
---
## 3. Backend: Controller, Services, Models, Middleware
### 3.1 N+1 / Query-Effizienz
- **Status-Widget-Query-Sturm (Hoch)** — `app/Services/Customer/CustomerStatusSummary.php:35-66` feuert pro Request viele Einzelqueries (`currentPlan`, `subscription`, `exists()`, `hasActiveBooking()` mit 3 Sub-Queries, offene Single-Purchases). Da das Widget always-on im Header sitzt, summiert sich das. → Sub-Ergebnisse memoisieren/eager-loaden und durchreichen.
- **Offene Single-Purchase 3× gezählt (Mittel)** — gleiche `COUNT`-Query in `CustomerStatusSummary.php:61`, `User.php:215`, `User.php:229`. → Einmal berechnen (wie `currentPlan()` memoisiert in `User.php:128-162`).
- **`LegacySubscriptionService::cancelBundleFor` (Mittel)** — `app/Services/Billing/LegacySubscriptionService.php:45-54` macht ein `$post->update()` pro Iteration. → Bulk-`update()` oder `DB::transaction`.
- **`selectedCompany()` doppelte Query (Mittel)** — `app/Services/Customer/CustomerCompanyContext.php:49` + `153-158`: `exists()` und `findFor()` führen dieselbe Query aus. → Einmal `first()`.
- **`LogApiUsage` synchroner INSERT pro Request (Hoch)** — `app/Http/Middleware/LogApiUsage.php:35-46` schreibt bei jedem API-Response. → Terminable Middleware oder Queued Job.
### 3.2 Duplizierte Querschnittslogik
- **Firmen-Ownership-Query 3–4× (Hoch)** — identische `owner_user_id == me OR whereHas('users', me)`-Closure in `Api/V1/PressReleaseController.php:182-191`, `CompanyController.php:18-21` & `:39-42`, `PressReleaseImageController.php:87`; überlappt zusätzlich `User::accessibleCompanyIds()` (`User.php:406-413`) und `CustomerCompanyContext::accessibleCompanyQuery()` (`:143-151`). → Ein `Company::scopeAccessibleBy()` **oder** `CompanyPolicy` (die in `ContactAccessService.php:229` schon referenziert wird).
- **Zahlungsoption-Eligibility 4× (Hoch)** — gleicher Status-Set, leicht abweichende Periodenlogik in `Api/ApiAccessEligibilityService.php:28-46`, `CustomerStatusSummary.php:47-52`, `User::hasUnlimitedPressReleaseQuota()` (`User.php:191-196`), `User::hasActiveBooking()` (`User.php:256-261`). Variable `$hasGrandfathered` in `CustomerStatusSummary.php:47` matcht faktisch Active *oder* Grandfathered (Mislabel). → `UserPaymentOption`-Scope + eine `User::hasActivePaymentOption()`.
- **Legacy-Invoice-Eligibility divergiert (Hoch — Korrektheit)** — `ApiAccessEligibilityService::hasPaidLegacyInvoice()` (`:48-56`) behandelt `null` als *deny*, `LegacyApiCustomerReporter::classification()` (`:131-142`) als *needs_review*. Beide re-implementieren „latest by invoice_date then id". → Ein `User::latestLegacyInvoice()` + eine Eligibility-Methode.
- **VAT-ID-Normalisierung + EL/GR-Mapping 3× (Mittel)** — `VatResolver.php:30,56,63`, `VatIdValidationService.php:37,44,69`, `StripeCheckoutService.php:48,54,56`. → `VatResolver::normalize()` / `isEuCountry()` zentral; `StripeCheckoutService` konsumiert statt selbst herzuleiten.
- **Case-insensitive Email-Lookup via `LOWER()` (Mittel)** — `whereRaw('LOWER(email) = ?')` in `ContactAccessService.php:50,130-148,176`, `SocialAuthService.php:32`; defeated den Email-Index. → Beim Schreiben normalisieren + `User::findByEmail()`-Scope.
- **Lazy-Account-Erstellung doppelt (Mittel)** — `ContactAccessService::createAccount()` (`:188-208`) ≈ `SocialAuthService::createUser()` (`:65-82`). → gemeinsame Provisioning-Methode.
- **Currency-Literal inkonsistent (Mittel)** — `'EUR'` in `CheckoutController.php:66`, `CreditTopupService.php:59`, `ManualInvoiceService.php:125` vs. `'eur'` in `ExtraPmPurchaseService.php:50`, `ProofPdfService.php:69`. → `config('billing.default_currency')`.
### 3.3 Fat Controller / Validierung / Model
- **`Api/V1/PressReleaseController` (Mittel)** — `:201-220` re-implementiert Unique-Slug, obwohl `PressRelease` bereits `HasUniqueSlug` mit `slugScopeAttributes()` nutzt (`PressRelease.php:27,32`). Create/Update/Reclassify-Orchestrierung (`:44-122`) gehört in `PressReleaseService`.
- **`LegalRequestController` (Mittel)** — Inline-Validation `:42-57` → `StoreLegalRequestRequest` (Projekt nutzt `app/Http/Requests/Api/V1/*` bereits). Portal-aus-Host `:103-122` dupliziert Portal-Detection; „max 1 open per PM+type"-Regel `:71-91` → `LegalRequestService`.
- **`CheckoutController::singlePm` (Niedrig)** — baut `SinglePurchase` inline `:61-67` (hardcoded `'EUR'`) → Service-Methode analog `CreditTopupService::startTopup`.
- **Fat `User` Model (Mittel)** — `app/Models/User.php` mischt Auth, Relations, Stripe-Adresse, Plan/Tier, Quota-Mathe, Booking-Gates. → Trait `HasBillingProfile`/`InteractsWithQuota` (`:86-262, 352-355`).
### 3.4 Authorization → Policies/Gates
- **Firmenzugriff umgeht bestehende `CompanyPolicy` (Hoch)** — `CustomerCompanyContext.php:143-151,244-249` + 3 API-Controller (s. 3.2). → über `Gate`/Policy.
- **Domain-Auth-Middleware → Gates (Mittel)** — `EnsureUserIsAdmin.php:28`, `EnsureUserIsCustomer.php:13`, `EnsureApiUserIsActive.php:18` rollen `$user?->method()`+abort von Hand; API-Active-Check dupliziert `is_active` aus `canAccessAdmin/Customer` (`User.php:379,392`). → `Gate::define('access-admin'…)` + `can:`-Middleware.
- **Impersonation-Logik im Admin-Gate (Mittel)** — `EnsureUserIsAdmin.php:20-26` mischt Impersonation-Redirect in den Access-Check. → eigene Middleware. Zusätzlich totes `loadMissing('roles')` `:16-18` (Niedrig).
### 3.5 Middleware-Overlap (Domain/Theme/Portal/Edition)
- **Vierfach duplizierte Host→Config-Auflösung (Hoch)** — `ThemeServiceProvider::boot` (`:29-88`), `ThemeMiddleware::handle` (`:16-56`), `SetDomainUrl::handle` (`:22-58`), `SetCurrentPortal::resolvePortal` (`:36-55`) lesen alle Host/`app.theme` und schreiben Config; `SetDomainUrl` ruft dieselben `URL::forceRootUrl/forceScheme/useAssetOrigin` erneut. → ein `DomainContext`-Service. *(Hinweis: `SetDomainUrl` scheint nicht in `bootstrap/app.php` registriert — vor Refactor prüfen, ob toter Code.)*
- **Disjunkte Theme-Vokabulare (Hoch — Korrektheits-Smell)** — `ThemeMiddleware.php:22-53` kennt `b2in/b2a/stileigentum/style2own`, `SetCurrentPortal.php:50-54` nur `presseecho/businessportal24` (Rest → `null`-Portal). → Theme→Portal-Map an *einer* Stelle (`Portal`-Enum oder `config/domains.php`).
- **`ThemeMiddleware` mutiert `REQUEST_URI` (Mittel)** — `:43-52` schreibt URIs um (fragil), `:33-38` re-implementiert `?theme=`-Override mit anderer Whitelist als der Provider. → Route-Prefixes (wie `SetEdition`) + ein Override-Pfad.
- **`CurrentPortalContext` mutable static (Mittel)** — `app/Services/CurrentPortalContext.php:15` hält `private static ?Portal` pro Request (Octane-/Test-Risiko). → request-scoped Container-Binding.
### 3.6 Weitere Korrektheit/Security
- **`BasicAuthMiddleware` (Hoch — Security)** — `:45` nutzt `!=` (timing-unsicher) → `hash_equals()`. `bootstrap/app.php:58` liest `env('BASIC_AUTH_ENABLED')` zur Laufzeit → unter `config:cache` `null` ⇒ Basic-Auth still deaktiviert. → Config-Key.
- **`CreditWalletService::post` Race (Mittel)** — `:88-89` `firstOrCreate()` (unlocked) dann separates `lockForUpdate()->find()`. → Unique-Index auf `credit_wallets.user_id` + locked Upsert.
- **`InvoiceNumberGenerator` Erstzeilen-Kollision (Niedrig/Mittel)** — `:41-63` `->first()` (kein Lock) dann `insert()`; zwei parallele Erstaufrufe ⇒ doppelte `MAN-00001`. → Unique-Index auf `circle` + Retry.
- **`LegacyInvoicePdfController` Laufzeit-`Schema::hasColumn` (Niedrig)** — `:26` Schema-Introspektion pro Download. → nach Migration entfernen.
- **`ExtraPmPurchaseService` magisches `* 100` (Niedrig)** — `:50` Credit→Cents außerhalb `CreditPricingService`. → in Pricing-Service.
---
## 4. Livewire/Volt: Press-Release-Editoren
> Vier Dateien, ~5.500 Zeilen, ~80–95 % Überschneidung. Größter einzelner Optimierungs-Hebel im Projekt.
> `admin/press-releases/{create,edit,index,show}.blade.php`, `customer/press-releases/{create,edit,index,show}.blade.php`.
### Hoch
- **H1 — Scheduling-Maschinerie 4× wortgleich.** `updatedPublishMode/ScheduledDate/ScheduledTime()`, `syncScheduledAt()`, `resolveScheduledAt()`, `validateScheduledAtWhenReady()`, `updated()`, `notifyValidationError()` und der `scheduled*`-Block in `formRules()` sind identisch (admin/create:62-89,169-255; admin/edit:121-143,215-339; customer/create:123-145,216-302; customer/edit:163-185,253-339). → **`InteractsWithPressReleaseScheduling`-Trait** (spiegelt bestehendes `InteractsWithPressReleaseTags`). ~150 Zeilen × 3.
- **H2 — `formRules()` ~95 % identisch** (admin/create:152-194; admin/edit:198-240; customer/create:197-241; customer/edit:237-278). Einziger Unterschied: admin `portal`+`companyId exists`, customer `companyId integer`. → `baseRules()` im Trait, Komponenten überschreiben nur `portal`/`companyId`.
- **H3 — `presubmitChecks` 4× dupliziert, mit Drift.** 6-Punkt-Checkliste (admin/create:380-425; admin/edit:574-619; customer/create:583-632; customer/edit:561-608). `customer/create` nutzt `trans_choice(...)` + `selectedCompany()?->name`, die anderen drei `__(':n Zeichen')` + statischen Text — versehentliche Copy-Paste-Drift. → eine kanonische Version im Trait.
- **H4 — Kontakt-Helfer dupliziert, divergente Query-Styles.** `defaultContactIdFor()`, `companyContacts()`, `companyContact()` in allen vier; admin nutzt `Contact::withoutGlobalScopes()`, customer `Contact::forCompany()`. `tagSuggestionsFor()` identisch. → `tagSuggestionsFor()` in Tags-Trait; Kontakt-Helfer in Trait/Scope.
- **H5 — Admin-Editoren nutzen `InteractsWithPressReleaseTags` NICHT.** Customer (`use ...` in create:28, edit:29), Admin inlined `addTag/removeTag/tagsArray` + `tags`-Computed (admin/create:113-144,374-378,430-442; admin/edit:162-193,...). → `use InteractsWithPressReleaseTags;` + ~35 Zeilen je Datei löschen.
### Mittel
- **M1 — Persistierung dupliziert; Customer zeigt das bessere Muster.** Customer zentralisiert in `persistDraft()`/`persistEdit()` + `pressReleaseAttributes()` (customer/create:521-543); Admin inlined volle `PressRelease::create([...])`/`update([...])` (admin/create:310-328; admin/edit:355-370). Slug + `HtmlSanitizer::clean()` + `contacts()->sync()` + `contentChanged`→reclassify/rescore (admin/edit:383-388; customer/edit:435-440,497-502) überall wiederholt. → `PressReleaseService::createDraft()` / `updateContent()`.
- **M2 — Firmen-Combobox-Suche dupliziert, zwei Backends.** Admin baut Query inline in `with()` (admin/create:344-360, admin/edit:526-542), customer delegiert an `CustomerCompanyContext::searchCompaniesFor()`. `supportsFullTextSearch()` kopiert (admin/create:493-496, admin/edit:687-690, admin/index:384-388). → `Company`-Scope `searchByTerm()`.
- **M3 — Formular-Markup ~85 % identisch** (Firma-Selektor, Titel mit Char-Meter `@php`, Untertitel, Fließtext-Meter, Boilerplate, Tag-Input-Chips, Veröffentlichungs-Panel, Pressekontakt, Pre-Submit-Check). → Blade-Components: `<x-portal.pr-char-meter>`, `<x-portal.pr-tag-input>`, `<x-portal.pr-presubmit-check>`, `<x-portal.pr-publish-schedule>`, `<x-portal.pr-contact-select>`, `<x-portal.pr-boilerplate-panel>`. **Größte Roh-Zeilenersparnis.**
- **M4 — Listenzeilen admin vs. customer dupliziert** (admin/index:836-968 ≈ customer/index:421-531). Filter-Chips mit inline-`<svg>` ~9× in admin/index:662-801, 3× customer/index:320-376. → `<x-portal.pr-table-row>` + `<x-portal.filter-chip>`.
- **M5 — Show-Seiten duplizieren Status-Timeline + Rejection-Banner** (admin/show:223-251,431-472 ≈ customer/show:345-370,738-771). `statusColor`/`$statusClass`-`match` an 4+ Stellen. → Mappings auf `PressReleaseStatus`-Enum (`badgeClass()`/`color()`) + `<x-portal.pr-status-timeline>`, `<x-portal.pr-rejection-banner>`.
- **M6 — Status-Aktionen dupliziert** (`publish/reject/archive` in admin/index:163-215, admin/edit:446-486, admin/show:25-68). `withoutGlobalScopes()->findOrFail($id)` ~10× in Admin-Code. → `HandlesAdminPressReleaseActions`-Trait + `findAdminPR()`.
### Niedrig
- **L1** — `categoryOptions()` admin gecacht (`AdminPerformanceCache`), customer uncached pro Render (customer/create:557-561, customer/edit:531-535). → gemeinsamer Helfer + Cache.
- **L2** — Kategorie-Anzeigename `->translations->firstWhere('locale','de')?->name ?? ...` ~10× → `Category::localizedName()`.
- **L3** — Kontakt-Name `trim(first.' '.last) ?: ...` mehrfach → `Contact::displayName()`.
- **L4** — `statusColor` aus `with()` (admin/edit:558-564, admin/show:111-117) scheinbar ungenutzt (Blade nutzt lokales `$statusClass`). → prüfen/entfernen.
- **L5** — Image-/Attachment-Manager teilen `getPressRelease()`/`canChange*()` (images-manager:257-269, attachments-manager:214-239). Attachments aktuell deaktiviert → bei Reaktivierung `ManagesPressReleaseMedia`-Trait; `canChange*` gehört in `PressReleasePolicy`.
---
## 5. Livewire/Volt: Admin-CRUD, Customer, Settings, Auth
### Tabellen-Plumbing → Concerns (Hoch)
- **`sort($column)` wortgleich** in `admin/users:39-48`, `companies/index:45-54`, `contacts/index:51-60`. → `HasSortableTable` (`$sortBy`/`$sortDir`/`sort()` + Whitelist-Helfer).
- **`supportsFullTextSearch()` 6×+ byte-gleich** (users/edit:627-631, users/create:216-220, companies/index:169-173, contacts/index:262-266, contacts/edit:143-147, companies/show:178-182) + „whereFullText else multi-LIKE"-Block mehrfach. → `HasFullTextSearch` + `applyFuzzySearch($query,$columns,$term)`.
- **`updatedXxxFilter()` nur `resetPage()`** (users:285-313 = 6 Methoden, companies/index:175-227, contacts/index:62-90). → `HasResettableFilters` + `filterProperties()`-Array.
- **`stats()` mit `SUM(CASE WHEN is_active...)`** strukturidentisch (users:187-203, companies/index:122-141). → `AdminPerformanceCache::activeInactiveStats()`.
- **Count-Hydration** (users:205-218 `hydrateCompanyCounts`, companies/index:143-167 `hydrateCounts`). → `hydrateRelationCounts($paginator, $map)`.
### Create vs. Edit Duplikation (Hoch)
- **User create/edit teilen Rule-Sets + Billing/Company-Logik** (create:110-130 ⊂ edit:377-421; `billingIsComplete()` create:222-229 = edit:708-715; `availableRoles()` create:209-214 = edit:620-625). `save()` trägt ~175 Zeilen Business-Logik (edit:375-550). → `UserFormRules`-Provider + `UserUpsertService`/`SaveUserAction`.
- **Billing-/Company-/Role-Markup dupliziert** (Rechnungsadresse create:386-436 ≈ edit:1177-1228; Company-Combobox create:322-376 ≈ edit:1003-1069; Rollen create:261-313 ≈ edit:842-995). → `<x-admin.billing-fields>`, `<x-admin.company-link-picker>`, `<x-admin.role-checkboxes>`.
- **Kontakt-Validierung/Markup** (users/edit:406-412, contacts/edit, customer/profile:147-168, press-kits/show:169-175). → `ValidatesContactData`-Concern + `<x-contact-fields>` (admin+customer geteilt).
### Formatierungs-Helfer → Components/Enum (Hoch/Mittel)
- **`portalBadgeColor(?Portal)` 3–4×** (companies/index:229-237, companies/show:184-192, contacts/index:383-391, contacts/edit:170-178). → `Portal::badgeColor()` + `<x-portal.portal-badge>`.
- **Status-Badge (ok/err-dot)** (companies/index:519-524, users:558-564, customer press-kits). → `<x-portal.status-badge :active>`.
- **`pressReleaseStatusColor()`** (users:161-170) → auf `PressReleaseStatus`-Enum.
- **`formatEuro()`** (customer/bookings:36-39) → `<x-portal.money>` / Helfer.
- **Datum `?->format('d.m.Y H:i') ?? '–'`** überall → `<x-portal.datetime :value>`.
### Seiten-Struktur → Components (Mittel)
- **Page-Header-Block** (users:318-337, users/create:234-253, users/edit:720-746, companies/index:301-320, contacts/index:395-421, payments/index:158-180, companies/show:215-268, companies/edit:199-220). → `<x-portal.page-header :eyebrow :title :subtitle>` mit Action-Slot.
- **KPI-Grid-Wrapper** (users:340-353, companies/index:323-335). → `<x-portal.stat-cards :stats>`.
- **Filter-Panel + flux:table + Pagination-Footer** (users:599-601, companies/index:588-590, payments 4×). → `<x-portal.table-panel>` / `<x-portal.table-pagination :paginator>`.
- **Empty-State-Zelle** (users:582-594, companies/index:569-584). → `<x-portal.empty-state>`.
- **Combobox-Pattern** (`flux:select variant=combobox` + Clear-Button: users/create:322-352, users/edit:1003-1043,1074-1121, companies/index:369-445, companies/show:399-432, contacts/edit:211-243). → `<x-portal.searchable-select>`.
### Flash/Notification (Hoch)
- **Auto-Dismiss-Toast** (`x-data="{show:true}" x-init=setTimeout`) in users/edit:748-754, contacts/index:423-437, payments/index:182-196; Session-Varianten companies/show:196-213.
- **`@if(session('status'))`** in auth/login:128-132, register:57-61, forgot-password:30-34, verify-email:33-37, magic-link:87-91; settings nutzen `<x-action-message>`. → ein `<x-portal.alert :type :dismissible>` + `<x-session-status>` app-weit.
### Auth & Settings (Hoch/Mittel)
- **Password show/hide-Feld** (auth/login:134-180, register:63-143, confirm-password:40-72, reset-password:73-135) → `<x-password-input>` (Hoch).
- **Email-Input + `@error`-Markup** in allen Auth-Forms → `<x-form-field>`/`<x-email-input>`.
- **Rate-Limiting inline & divergent** (auth/login:100-124, magic-link:54-83) → `ensureNotRateLimited()`/`throttleKey()`-Trait.
- **Heavy Auth-Logik in Volt** (2FA/Activation/Redirect: auth/login:28-95, settings/profile:19-73) → Fortify-Actions/Response-Contracts (architektonisch).
- **Divider + Secondary-Action** (auth/login:196-218 ≈ register:174-192) → `<x-auth.divider-actions>`.
### Customer-Bereich (Mittel/Niedrig)
- Portal-Pill/Status-Badge wiederholt (press-kits/index:669-685,778-786, dashboard:352-358, press-kits/show:298-307) → dieselben `<x-portal.portal-badge>`/`<x-portal.status-badge>`.
- Company-Logo-Logik (`logoVariant/logoInitials/logoUrl`, press-kits/index:221-259,645-650,760-766) → `<x-portal.company-logo :company>`.
- Inline-Edit-Form-Toggles (`showCompanyForm`/`showContactForm`) in customer/profile, press-kits/show → `HasInlineFormModals`-Concern.
- Onboarding-Step-Grid (dashboard:382-398, press-kits/index:611-630) → `<x-portal.step-guide :steps>`.
> **Bereits gute Wiederverwendung:** `<x-portal.stat-card>` und `components.portal.pagination` werden konsistent genutzt — als Muster für die neuen Components übernehmen.
---
## 6. Web-Frontend (gehört dem User, separat committen)
> **Hinweis:** `resources/views/web/**` und `resources/views/components/web/**` gehören laut Projekt-Memory dem User und werden separat committet. Findings sind beratend; nicht in Feature-Commits bündeln. *(Vor jedem Löschen vom User bestätigen lassen — „unused"-Zählungen stammen aus `grep` über `x-web.<name>` und prüfen keine dynamische Komponenten-Auflösung.)*
### Hoch
- **Pricing-Cards 3× handcodiert** in `web/pressekonto.blade.php:401-466,469-535,538-~610` — fast identische ~67-Zeilen-`<article>`s, derselbe Check/Dash/Cross-SVG ~15×. **Es existiert bereits ein datengetriebenes `<x-web.pricing-teaser>`** (`components/web/pricing-teaser.blade.php`, iteriert `Plan::active()`), das diese Seite ignoriert. → `pricing-teaser` einsetzen; ~200 Zeilen → eine Schleife.
- **`press-release-card` vs. `presseecho-release-card` ~95 % identisch** (je ~160 Zeilen; nur Default-`$contentTypeClasses`, Gradient-Klassen, Badge unterscheiden sich). `presseecho-release-card` hat **0 Verwendungen**. → vereinheitlichen auf eine Komponente mit `variant`-Prop oder löschen.
### Mittel
- **Industrie-Icon-Map 3× dupliziert** (press-release-card:15-31, presseecho-release-card:15-31, highlight-card:5-22). → in `CategoryService` (zentralisiert bereits `getIconPath()`) oder Config.
- **Release-Normalisierung 3×** (feed-item:8-50, feed-top-item:6-36, focus-side-item:8-33): gleiche Kategorie-/Firma-/Datum-/Bild-`@php`-Blöcke; „verified"-SVG verbatim. → View-Model `ReleaseCardData::from($release)` + `<x-web.icons.verified-badge>`.
- **Footer standard vs. hub** (site-footer:1-82 ≈ hub/site-footer:1-82) → ein `<x-web.site-footer>` mit `columns`+`variant`.
- **Top-Utility-Bar** inline in site-header:47-93 vs. komponentisiert in hub/top-utility-bar:1-55 → gemeinsame `<x-web.top-utility-bar>`.
- **Tote, array-getriebene Legacy-Cards** (0 Produktiv-Verwendung, nur `web/examples/`): `presseecho-release-card`, `press-release-list-item`, `dossier-card`, `article-card`, `highlight-card`+`highlights-slider`, `main-navigation`. → nach Bestätigung mitsamt `web/examples/` entfernen.
- **Inline-SVGs** (CTA-Pfeil `M2 6h8…` in 5 Dateien, Building-Icon, verified-Check) → kleines `components/web/icons/`-Set.
- **Inline-Array-`@foreach`-Sektionen** in `veroeffentlichen.blade.php:196-211,227-249,265-280,293-310,383-405` → `<x-web.criteria-item>` + statische Arrays in View-Data.
### Niedrig
- Layout-Shell `max-w-layout mx-auto px-8` ~20× in pressekonto.blade.php → `<x-web.container>` oder `.layout-container`-Utility (wo `.card`/`.badge` schon liegen).
- Such-Form-Block doppelt in site-header (Desktop :110-130, Mobile :196-226) → `<x-web.search-form :variant>`.
- Sidebar-Familie (`sidebar-widget` + `-categories/-companies/-newsletter/-rss`) ist **gut faktorisiert**, aktuell aber 0 Produktiv-Verwendung — vor Behalten prüfen.
---
## 7. Vorgeschlagene neue Bausteine
**Traits/Concerns** (`app/Livewire/Concerns/`, `app/Models/Concerns/`)
- `InteractsWithPressReleaseScheduling`, `HandlesAdminPressReleaseActions`, `ManagesPressReleaseMedia`
- `HasSortableTable`, `HasFullTextSearch`, `HasResettableFilters`, `ValidatesContactData`, `HasInlineFormModals`
- `User`: `HasBillingProfile` / `InteractsWithQuota`
**Services** (`app/Services/**`)
- `PressReleaseService::createDraft()` / `updateContent()` (Persistierung + reclassify/rescore)
- `UserUpsertService`, `LegalRequestService`
- `DomainContext` (konsolidiert Theme/Portal/Domain-Auflösung)
- Zentralisierung in bestehenden Services: `VatResolver::normalize/isEuCountry`, `User::hasActivePaymentOption/latestLegacyInvoice/findByEmail`
**Query-Scopes / Enum-Methoden**
- `Company::scopeAccessibleBy()`, `Company::scopeSearchByTerm()`, `Contact::scopeForCompany()` (vereinheitlicht), `UserPaymentOption`-Active-Scope
- `Portal::badgeColor()`, `PressReleaseStatus::badgeClass()/color()`, `Category::localizedName()`, `Contact::displayName()`
**Policies/Gates**
- `CompanyPolicy` durchgängig nutzen; `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware
**Blade-Components**
- Portal: `pr-char-meter`, `pr-tag-input`, `pr-presubmit-check`, `pr-publish-schedule`, `pr-contact-select`, `pr-boilerplate-panel`, `pr-table-row`, `pr-status-timeline`, `pr-rejection-banner`, `filter-chip`, `alert`, `page-header`, `stat-cards`, `table-panel`, `table-pagination`, `searchable-select`, `empty-state`, `portal-badge`, `status-badge`, `money`, `datetime`, `company-logo`, `step-guide`
- Admin: `billing-fields`, `company-link-picker`, `role-checkboxes`
- Auth/Shared: `password-input`, `form-field`, `session-status`, `contact-fields`, `auth.divider-actions`
---
### Empfohlenes Vorgehen
1. **Risikoarme Extraktionen zuerst** (Traits H1/H5, Concerns 6, Components 7/8 oben) — reine Deduplikation ohne Verhaltensänderung, jeweils mit bestehenden Pest-Tests absichern.
2. **Korrektheits-Divergenzen** (Eligibility 3.2, Theme-Vokabular 3.5, Security 3.6) — vor Refactor jeweils einen Test schreiben, der das aktuelle Soll-Verhalten festschreibt.
3. **Persistierung in Services** (M1, UserUpsertService) — mittlerer Aufwand, größter architektonischer Gewinn.
4. **Web-Frontend** separat mit dem User abstimmen.