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