From c7bd940f28703b1e6cfd5201a9c6878eb06b6aa5 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 19 Jun 2026 08:09:02 +0000 Subject: [PATCH] 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) --- .env.example | 6 + app/Enums/Portal.php | 15 + app/Http/Middleware/BasicAuthMiddleware.php | 18 +- app/Http/Middleware/SetCurrentPortal.php | 11 +- app/Http/Middleware/ThemeMiddleware.php | 57 --- app/Models/LegacyInvoice.php | 10 + app/Models/User.php | 34 +- app/Models/UserPaymentOption.php | 30 ++ app/Providers/ThemeServiceProvider.php | 7 +- .../Api/ApiAccessEligibilityService.php | 31 +- .../Api/LegacyApiCustomerReporter.php | 3 +- .../Billing/InvoiceNumberGenerator.php | 21 +- .../Customer/CustomerStatusSummary.php | 6 +- bootstrap/app.php | 8 +- config/auth.php | 18 + dev/optimize/entwicklungsplan.md | 108 ++++++ .../system-optimierung-analyse-codex.md | 326 ++++++++++++++++++ .../system-optimierungen-analyse-claude.md | 225 ++++++++++++ .../ApiAccessEligibilityServiceTest.php | 97 ++++++ tests/Feature/BasicAuthMiddlewareTest.php | 59 ++++ tests/Feature/Billing/BookingsPageTest.php | 1 + .../Billing/InvoiceNumberGeneratorTest.php | 13 + tests/Feature/Billing/LegacyBundleTest.php | 1 + tests/Feature/Customer/DashboardTest.php | 1 + tests/Feature/CustomerStatusSummaryTest.php | 1 + tests/Feature/PressReleaseQuotaTest.php | 1 + tests/Unit/PortalFromThemeTest.php | 15 + 27 files changed, 990 insertions(+), 133 deletions(-) delete mode 100644 app/Http/Middleware/ThemeMiddleware.php create mode 100644 dev/optimize/entwicklungsplan.md create mode 100644 dev/optimize/system-optimierung-analyse-codex.md create mode 100644 dev/optimize/system-optimierungen-analyse-claude.md create mode 100644 tests/Feature/ApiAccessEligibilityServiceTest.php create mode 100644 tests/Feature/BasicAuthMiddlewareTest.php create mode 100644 tests/Unit/PortalFromThemeTest.php diff --git a/.env.example b/.env.example index 716532e2..fbfad7fe 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database +# HTTP Basic Auth Gate (Staging-Schutz). Nur erzwungen, wenn aktiviert +# UND BASIC_AUTH_USER/BASIC_AUTH_PASSWORD gesetzt sind. +BASIC_AUTH_ENABLED=true +BASIC_AUTH_USER= +BASIC_AUTH_PASSWORD= + PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 diff --git a/app/Enums/Portal.php b/app/Enums/Portal.php index 35765993..6ab62b8a 100644 --- a/app/Enums/Portal.php +++ b/app/Enums/Portal.php @@ -8,6 +8,21 @@ enum Portal: string case Businessportal24 = 'businessportal24'; case Both = 'both'; + /** + * Mappt ein Domain-Theme (aus config/domains.php, gesetzt vom + * ThemeServiceProvider) auf das zugehörige Portal. Admin-/Hub-Themes + * (portal, pressekonto, main, …) liefern null → kein Portal-Filter. + * Single Source of Truth für die Theme→Portal-Auflösung. + */ + public static function fromTheme(?string $theme): ?self + { + return match ($theme) { + 'presseecho' => self::Presseecho, + 'businessportal24' => self::Businessportal24, + default => null, + }; + } + public function label(): string { return match ($this) { diff --git a/app/Http/Middleware/BasicAuthMiddleware.php b/app/Http/Middleware/BasicAuthMiddleware.php index eef26711..adae5096 100644 --- a/app/Http/Middleware/BasicAuthMiddleware.php +++ b/app/Http/Middleware/BasicAuthMiddleware.php @@ -15,6 +15,11 @@ class BasicAuthMiddleware */ public function handle(Request $request, Closure $next): Response { + // Gate global deaktiviert (z. B. Produktion/Tests) + if (! config('auth.basic.enabled', true)) { + return $next($request); + } + // Skip Basic Auth für Livewire-Requests komplett // Diese sind bereits durch Laravel Session/CSRF geschützt $path = $request->path(); @@ -38,11 +43,20 @@ class BasicAuthMiddleware return $next($request); } - // Credentials from .env file + // Zugangsdaten aus der Config (gespeist aus .env). Ist nichts + // konfiguriert, wird der Gate nicht erzwungen. $user = config('auth.basic.user'); $pass = config('auth.basic.password'); - if ($request->getUser() != $user || $request->getPassword() != $pass) { + if (($user === null || $user === '') && ($pass === null || $pass === '')) { + return $next($request); + } + + // Timing-sicherer Vergleich beider Felder (kein Short-Circuit). + $userValid = hash_equals((string) $user, (string) $request->getUser()); + $passValid = hash_equals((string) $pass, (string) $request->getPassword()); + + if (! $userValid || ! $passValid) { return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']); } diff --git a/app/Http/Middleware/SetCurrentPortal.php b/app/Http/Middleware/SetCurrentPortal.php index 2542a3d5..6da29dc4 100644 --- a/app/Http/Middleware/SetCurrentPortal.php +++ b/app/Http/Middleware/SetCurrentPortal.php @@ -44,13 +44,8 @@ class SetCurrentPortal } } - // Domain-basierte Auflösung via ThemeServiceProvider - $theme = config('app.theme', 'main'); - - return match ($theme) { - 'presseecho' => Portal::Presseecho, - 'businessportal24' => Portal::Businessportal24, - default => null, // Admin/Portal-Domain → kein automatischer Filter - }; + // Domain-basierte Auflösung via ThemeServiceProvider; Mapping zentral + // im Portal-Enum (Admin/Portal-Domain → null, kein automatischer Filter). + return Portal::fromTheme(config('app.theme', 'main')); } } diff --git a/app/Http/Middleware/ThemeMiddleware.php b/app/Http/Middleware/ThemeMiddleware.php deleted file mode 100644 index 134d3126..00000000 --- a/app/Http/Middleware/ThemeMiddleware.php +++ /dev/null @@ -1,57 +0,0 @@ -getHost(); - $path = $request->path(); - - // Theme-Switching über Subdomains - if (str_contains($host, 'b2in')) { - config(['app.theme' => 'b2in']); - } elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) { - config(['app.theme' => 'b2a']); - } elseif (str_contains($host, 'stileigentum')) { - config(['app.theme' => 'stileigentum']); - } elseif (str_contains($host, 'style2own')) { - config(['app.theme' => 'style2own']); - } - - // Theme-Switching über URL-Parameter (für Testing) - if ($request->has('theme')) { - $theme = $request->get('theme'); - if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) { - config(['app.theme' => $theme]); - } - } - - // Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup) - if (str_starts_with($path, 'b2in/')) { - config(['app.theme' => 'b2in']); - $request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad - } elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) { - config(['app.theme' => 'b2a']); - $request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad - } elseif (str_starts_with($path, 'stileigentum/')) { - config(['app.theme' => 'stileigentum']); - $request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad - } elseif (str_starts_with($path, 'style2own/')) { - config(['app.theme' => 'style2own']); - $request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad - } - - return $next($request); - } -} diff --git a/app/Models/LegacyInvoice.php b/app/Models/LegacyInvoice.php index 2acf1e59..545df100 100644 --- a/app/Models/LegacyInvoice.php +++ b/app/Models/LegacyInvoice.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\Portal; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -52,4 +53,13 @@ class LegacyInvoice extends Model { return $this->belongsTo(User::class); } + + /** + * Neueste Rechnung zuerst: nach Rechnungsdatum, dann ID als Tiebreaker. + * Single Source of Truth für die „latest legacy invoice"-Ordnung. + */ + public function scopeLatestFirst(Builder $query): Builder + { + return $query->orderByDesc('invoice_date')->orderByDesc('id'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 6b945803..404dc150 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Enums\Portal; use App\Enums\RegistrationType; use App\Enums\Tier; -use App\Enums\UserPaymentOptionStatus; use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -188,12 +187,17 @@ class User extends Authenticatable implements MustVerifyEmail return $this->cachedHasUnlimitedQuota = true; } - return $this->cachedHasUnlimitedQuota = $this->userPaymentOptions() - ->whereIn('status', [ - UserPaymentOptionStatus::Active->value, - UserPaymentOptionStatus::Grandfathered->value, - ]) - ->exists(); + return $this->cachedHasUnlimitedQuota = $this->hasActivePaymentOption(); + } + + /** + * Period-aware Prüfung auf eine aktuell gültige Zahlungsoption + * (aktives Abo in laufender Periode oder laufender Bestandsschutz). + * Single Source of Truth für API-Gate und Buchungs-/Kontingent-Checks. + */ + public function hasActivePaymentOption(): bool + { + return $this->userPaymentOptions()->activeNow()->exists(); } /** @@ -253,12 +257,7 @@ class User extends Authenticatable implements MustVerifyEmail return true; } - return $this->userPaymentOptions() - ->whereIn('status', [ - UserPaymentOptionStatus::Active->value, - UserPaymentOptionStatus::Grandfathered->value, - ]) - ->exists(); + return $this->hasActivePaymentOption(); } /** @@ -369,6 +368,15 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(LegacyInvoice::class); } + /** + * Neueste Legacy-Rechnung des Users (nach Rechnungsdatum, dann ID). + * Zentralisiert die zuvor mehrfach reimplementierte „latest"-Abfrage. + */ + public function latestLegacyInvoice(): ?LegacyInvoice + { + return $this->legacyInvoices()->latestFirst()->first(); + } + public function filterPresets(): HasMany { return $this->hasMany(UserFilterPreset::class); diff --git a/app/Models/UserPaymentOption.php b/app/Models/UserPaymentOption.php index 05aa1580..a783a0fc 100644 --- a/app/Models/UserPaymentOption.php +++ b/app/Models/UserPaymentOption.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\UserPaymentOptionStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -58,4 +59,33 @@ class UserPaymentOption extends Model { return $this->hasMany(UserPayment::class); } + + /** + * Aktuell gültige Zahlungsoption (period-aware): aktives Abo innerhalb der + * laufenden Periode ODER laufender Bestandsschutz. Single Source of Truth + * für „aktive Zahlungsoption" in API-Gate, Buchungs- und Kontingent-Checks. + */ + public function scopeActiveNow(Builder $query): Builder + { + return $query->where(function (Builder $query): void { + $query + ->where(function (Builder $active): void { + $active + ->where('status', UserPaymentOptionStatus::Active->value) + ->whereDate('current_period_start', '<=', now()) + ->whereDate('current_period_end', '>=', now()); + }) + ->orWhere(fn (Builder $grandfathered): Builder => $grandfathered->grandfatheredNow()); + }); + } + + /** + * Laufender Bestandsschutz (Grandfathered mit gültigem grandfathered_until). + */ + public function scopeGrandfatheredNow(Builder $query): Builder + { + return $query + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->whereDate('grandfathered_until', '>=', now()); + } } diff --git a/app/Providers/ThemeServiceProvider.php b/app/Providers/ThemeServiceProvider.php index 326d70dc..1c767bf8 100644 --- a/app/Providers/ThemeServiceProvider.php +++ b/app/Providers/ThemeServiceProvider.php @@ -31,11 +31,12 @@ class ThemeServiceProvider extends ServiceProvider $host = Request::getHost(); $themeOverride = Request::get('theme'); + // Fallback für unbekannte Hosts: Portal-/Admin-Theme dieses Projekts. $defaults = [ 'name' => config('app.name'), - 'theme' => 'b2in', - 'view_prefix' => 'b2in', - 'assets_dir' => 'build/b2in', + 'theme' => 'portal', + 'view_prefix' => 'portal', + 'assets_dir' => 'build/portal', 'url' => config('app.url'), 'domain_name' => config('app.domain_name'), ]; diff --git a/app/Services/Api/ApiAccessEligibilityService.php b/app/Services/Api/ApiAccessEligibilityService.php index 5781e931..520cd96e 100644 --- a/app/Services/Api/ApiAccessEligibilityService.php +++ b/app/Services/Api/ApiAccessEligibilityService.php @@ -2,7 +2,6 @@ namespace App\Services\Api; -use App\Enums\UserPaymentOptionStatus; use App\Models\User; class ApiAccessEligibilityService @@ -18,40 +17,16 @@ class ApiAccessEligibilityService return 'Ihr Benutzerkonto ist nicht aktiv. API-Tokens können nur für aktive Benutzer erstellt werden.'; } - if ($this->hasActivePaymentOption($user) || $this->hasPaidLegacyInvoice($user)) { + if ($user->hasActivePaymentOption() || $this->hasPaidLegacyInvoice($user)) { return null; } return 'API-Tokens werden erst freigeschaltet, wenn ein aktiver Zahlungsstatus oder freigegebener Bestandsschutz vorliegt.'; } - private function hasActivePaymentOption(User $user): bool - { - return $user->userPaymentOptions() - ->where(function ($query): void { - $query - ->where(function ($active): void { - $active - ->where('status', UserPaymentOptionStatus::Active->value) - ->whereDate('current_period_start', '<=', now()) - ->whereDate('current_period_end', '>=', now()); - }) - ->orWhere(function ($grandfathered): void { - $grandfathered - ->where('status', UserPaymentOptionStatus::Grandfathered->value) - ->whereDate('grandfathered_until', '>=', now()); - }); - }) - ->exists(); - } - private function hasPaidLegacyInvoice(User $user): bool { - $latestInvoice = $user->legacyInvoices() - ->latest('invoice_date') - ->latest('id') - ->first(['id', 'status']); - - return $latestInvoice?->status === 'paid'; + // Bewusstes Verhalten dieses Gates: ohne Legacy-Rechnung kein Zugang. + return $user->latestLegacyInvoice()?->status === 'paid'; } } diff --git a/app/Services/Api/LegacyApiCustomerReporter.php b/app/Services/Api/LegacyApiCustomerReporter.php index adc9615f..dcbdc9c0 100644 --- a/app/Services/Api/LegacyApiCustomerReporter.php +++ b/app/Services/Api/LegacyApiCustomerReporter.php @@ -73,8 +73,7 @@ class LegacyApiCustomerReporter 'paid_at', 'total_cents', ]) - ->latest('invoice_date') - ->latest('id'), + ->latestFirst(), ]) ->where(function ($query): void { $query->where('registration_type', RegistrationType::ApiUser->value) diff --git a/app/Services/Billing/InvoiceNumberGenerator.php b/app/Services/Billing/InvoiceNumberGenerator.php index 9e3f4e90..c5dc8ce5 100644 --- a/app/Services/Billing/InvoiceNumberGenerator.php +++ b/app/Services/Billing/InvoiceNumberGenerator.php @@ -39,22 +39,21 @@ class InvoiceNumberGenerator } $number = DB::transaction(function () use ($circle): int { + // Sequenz-Zeile race-sicher sicherstellen: insertOrIgnore nutzt den + // Unique-Index auf `circle`, sodass parallele Erstaufrufe nicht in + // doppelte Zeilen (und damit doppelte Nummern) laufen. + DB::table('invoice_number_sequences')->insertOrIgnore([ + 'circle' => $circle, + 'next_number' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $sequence = DB::table('invoice_number_sequences') ->where('circle', $circle) ->lockForUpdate() ->first(); - if (! $sequence) { - DB::table('invoice_number_sequences')->insert([ - 'circle' => $circle, - 'next_number' => 2, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return 1; - } - DB::table('invoice_number_sequences') ->where('id', $sequence->id) ->update(['next_number' => $sequence->next_number + 1, 'updated_at' => now()]); diff --git a/app/Services/Customer/CustomerStatusSummary.php b/app/Services/Customer/CustomerStatusSummary.php index 4706ce80..8a4a8948 100644 --- a/app/Services/Customer/CustomerStatusSummary.php +++ b/app/Services/Customer/CustomerStatusSummary.php @@ -3,7 +3,6 @@ namespace App\Services\Customer; use App\Enums\Tier; -use App\Enums\UserPaymentOptionStatus; use App\Models\Plan; use App\Models\User; @@ -45,10 +44,7 @@ class CustomerStatusSummary } $hasGrandfathered = $user->userPaymentOptions() - ->whereIn('status', [ - UserPaymentOptionStatus::Active->value, - UserPaymentOptionStatus::Grandfathered->value, - ]) + ->grandfatheredNow() ->exists(); return new self( diff --git a/bootstrap/app.php b/bootstrap/app.php index a49f3152..98003984 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -54,10 +54,10 @@ return Application::configure(basePath: dirname(__DIR__)) 'ability' => CheckForAnyAbility::class, ]); - // BasicAuth ganz am Ende, nach Session-Middleware - if (env('BASIC_AUTH_ENABLED', true)) { - $middleware->append(BasicAuthMiddleware::class); - } + // BasicAuth ganz am Ende, nach Session-Middleware. Immer anhängen; + // ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand + // von config('auth.basic.*') (greift auch unter `config:cache`). + $middleware->append(BasicAuthMiddleware::class); // Trust all proxies (for Traefik/Docker setup) $middleware->trustProxies( at: '*', diff --git a/config/auth.php b/config/auth.php index 9daae001..bbe4c241 100644 --- a/config/auth.php +++ b/config/auth.php @@ -114,4 +114,22 @@ return [ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + /* + |-------------------------------------------------------------------------- + | HTTP Basic Auth Gate (Staging-Schutz) + |-------------------------------------------------------------------------- + | + | Optionaler vorgelagerter Basic-Auth-Schutz für die gesamte Web-App + | (z. B. Staging). Wird nur erzwungen, wenn aktiviert UND Zugangsdaten + | gesetzt sind. Diese Werte über config() statt env() zu lesen stellt + | sicher, dass der Schutz auch unter `config:cache` aktiv bleibt. + | + */ + + 'basic' => [ + 'enabled' => env('BASIC_AUTH_ENABLED', true), + 'user' => env('BASIC_AUTH_USER'), + 'password' => env('BASIC_AUTH_PASSWORD'), + ], + ]; diff --git a/dev/optimize/entwicklungsplan.md b/dev/optimize/entwicklungsplan.md new file mode 100644 index 00000000..8a0328c1 --- /dev/null +++ b/dev/optimize/entwicklungsplan.md @@ -0,0 +1,108 @@ +# 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=`. +- Nach PHP-Änderungen: `vendor/bin/sail bin pint --dirty --format agent`. +- **Web-Frontend** (`resources/views/web/**`, `components/web/**`) gehört dem User → Phase 6, separat committen, vor jedem Löschen bestätigen lassen. +- Jeder Schritt = ein eigener, fokussierter Commit. + +--- + +## Phase 0 — Korrektheit & Security (höchste Dringlichkeit, Test-First) + +Latente Fehlerquellen und Sicherheitslücken. Klein, aber wichtig. + +- [x] **0.1 BasicAuth absichern** — `!=` → `hash_equals()` (timing-sicher, beide Felder ohne Short-Circuit). Enabled-Check von `env()` (brach unter `config:cache`/im Bootstrap) in die Middleware zur Laufzeit via `config('auth.basic.*')` verlagert; Config-Block in `config/auth.php` + `.env.example` ergänzt. **Fund:** `auth.basic.user/password` waren nie als Config-Key definiert → Gate war faktisch nicht konfigurierbar; jetzt nur erzwungen, wenn aktiviert UND Zugangsdaten gesetzt. Test: `tests/Feature/BasicAuthMiddlewareTest.php`. +- [x] **0.2 Legacy-Invoice-„latest"-Abfrage zentralisiert** — `LegacyInvoice::scopeLatestFirst()` als Single Source of Truth + `User::latestLegacyInvoice()`; `ApiAccessEligibilityService` und `LegacyApiCustomerReporter` (N+1-frei via Eager-Load-Scope) nutzen sie. **Bewusst erhalten:** unterschiedliches `null`-Verhalten (Gate=deny vs. Report=needs_review) ist kontextuell korrekt, keine Vereinheitlichung. Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`. +- [x] **0.3 Zahlungsoption-Eligibility vereinheitlicht (period-aware, strikt)** — Entscheidung des Users: überall period-aware. Neue Single Source of Truth: `UserPaymentOption::scopeActiveNow()` (Active in laufender Periode ODER laufender Bestandsschutz) + `scopeGrandfatheredNow()`; `User::hasActivePaymentOption()`. Umgestellt: `User::hasUnlimitedPressReleaseQuota`, `User::hasActiveBooking`, `ApiAccessEligibilityService`. Mislabel behoben: `CustomerStatusSummary::$hasGrandfathered` nutzt jetzt `grandfatheredNow()` (spezifisch Bestandsschutz statt „Active ODER Grandfathered"). Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`; 5 bestehende Tests auf gültiges `grandfathered_until` angepasst (waren via Factory-`optional()` nicht-deterministisch). **Folge der strikten Wahl (geklärt 19.06.):** Ein Grandfathered-User **ohne** `grandfathered_until` zählt nicht mehr als aktiv — User-Entscheidung: `NULL = abgelaufen/ungültig`, Verhalten beibehalten (konsistent mit altem API-Gate), kein weiterer Code-Change. +- [x] **0.4 Theme→Portal-Vokabular vereinheitlicht** — **Fund:** `ThemeMiddleware` war **nirgends registriert** und referenzierte projektfremde Themes (`b2in/b2a/stileigentum/style2own` aus einem anderen Projekt) → ersatzlos gelöscht. Theme→Portal-Mapping zentralisiert in `Portal::fromTheme()` (genutzt von `SetCurrentPortal`). Fremden `$defaults`-Fallback im `ThemeServiceProvider` (`b2in`/`build/b2in`) auf echten Portal-Default (`portal`/`build/portal`) korrigiert. Test: `tests/Unit/PortalFromThemeTest.php`. +- [x] **0.5 Race-Conditions abgesichert** — **Fund:** Die empfohlenen Unique-Indizes existieren bereits (`credit_wallets.user_id`, `invoice_number_sequences.circle`) → keine Migration nötig. `CreditWalletService::post` ist damit bereits race-sicher (Unique-Index + `firstOrCreate`/`createOrFirst` fängt Unique-Violation; Saldo-Update unter `lockForUpdate` in Transaktion) → kein Change. `InvoiceNumberGenerator::next()` gehärtet: Erst-Insert-Pfad (kein Seeder → produktiv getroffen) crashte bei parallelem Erstaufruf an der Unique-Violation; jetzt `insertOrIgnore` (race-sicher, null-Branch eliminiert). Test ergänzt: `tests/Feature/Billing/InvoiceNumberGeneratorTest.php`. + +--- + +## Phase 1 — Aufräumen / Toter Code (risikoarm, Paket A) + +Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten. + +- [ ] **1.1 Prototyp `admin/users/table` entfernen** — Route in `routes/admin.php` + `resources/views/livewire/admin/users/table.blade.php`. Filtert auf nicht existentes `users.status`, liest `$user->role` (statt Spatie), englische Demo-Menüs → kann SQL-Fehler/falsche UI erzeugen. Test für entfernte Route anpassen/ergänzen. +- [ ] **1.2 Tote Admin-Wrapper-Views prüfen & entfernen** — `resources/views/admin/*` werden außer `admin.dashboard` (via `DashboardController`) nicht produktiv gerendert. Wrapper mit alten Parametern (`request()->route('pressRelease')` vs. aktuelle `{id}`-Route) entfernen oder klar als Legacy markieren. `admin.dashboard` behalten. +- [ ] **1.3 Tote Helfer entfernen** — `EnsureUserIsAdmin:16-18` totes `loadMissing('roles')`; `statusColor` aus `with()` (admin/edit:558-564, admin/show:111-117) prüfen/entfernen; `LegacyInvoicePdfController:26` Laufzeit-`Schema::hasColumn` nach Migration entfernen. + +--- + +## Phase 2 — Press-Release-Editoren entdoppeln (größter Einzel-Hebel, Paket B) + +4 Dateien, ~5.500 Zeilen, ~80–95 % Überschneidung. In kleinen, risikoarmen Schritten. + +- [ ] **2.1 Admin-Editoren nutzen `InteractsWithPressReleaseTags`** (H5) — Customer nutzt den Trait bereits, Admin inlined `addTag/removeTag/tagsArray`+`tags`-Computed. → `use`-Statement + ~35 Zeilen je Datei löschen. +- [ ] **2.2 `InteractsWithPressReleaseScheduling`-Trait** (H1) — `updatedPublishMode/ScheduledDate/ScheduledTime`, `syncScheduledAt`, `resolveScheduledAt`, `validateScheduledAtWhenReady`, `notifyValidationError` + `scheduled*`-Block sind 4× wortgleich. Spiegelt bestehenden Tags-Trait. +- [ ] **2.3 `formRules()` zentralisieren** (H2) — ~95 % identisch; einziger Unterschied admin `portal`+`companyId exists` vs. customer `companyId integer`. → `baseRules()` im Trait, Komponenten überschreiben nur abweichende Keys. +- [ ] **2.4 `presubmitChecks` kanonisieren** (H3) — 4× dupliziert mit Copy-Paste-Drift (`trans_choice` vs. `__`). → eine Version im Trait. +- [ ] **2.5 Kontakt-Helfer + `tagSuggestionsFor()` zentralisieren** (H4) — `defaultContactIdFor/companyContacts/companyContact` in allen vier, divergente Query-Styles. → Trait/Scope. +- [ ] **2.6 Persistierung in `PressReleaseService`** (M1) — Customer hat bereits `persistDraft/persistEdit/pressReleaseAttributes`, Admin inlined volle `create/update`. Slug + `HtmlSanitizer::clean()` + `contacts()->sync()` + `contentChanged`→reclassify/rescore überall wiederholt. → `PressReleaseService::createDraft()` / `updateContent()`. +- [ ] **2.7 Firmen-Combobox-Suche** (M2) — Admin baut Query inline, Customer delegiert an `CustomerCompanyContext::searchCompaniesFor()`. `supportsFullTextSearch()` 3× kopiert. → `Company::scopeSearchByTerm()` (siehe auch 3.x). +- [ ] **2.8 Formular-Markup als Blade-Components** (M3, größte Roh-Zeilenersparnis) — `pr-char-meter`, `pr-tag-input`, `pr-presubmit-check`, `pr-publish-schedule`, `pr-contact-select`, `pr-boilerplate-panel`. +- [ ] **2.9 Listen-/Show-Components + Admin-Aktionen** (M4–M6) — `pr-table-row`, `filter-chip`, `pr-status-timeline`, `pr-rejection-banner`; `HandlesAdminPressReleaseActions`-Trait (`findAdminPR()` ersetzt ~10× `withoutGlobalScopes()->findOrFail($id)`); Status-`match` auf `PressReleaseStatus::badgeClass()/color()`. + +--- + +## Phase 3 — Backend-Querschnittslogik zentralisieren (Paket D + Domain) + +- [ ] **3.1 `Company::scopeAccessibleBy()` / `CompanyPolicy`** — identische `owner_user_id == me OR whereHas('users', me)`-Closure in 4 Controllern + Überlappung mit `User::accessibleCompanyIds()` und `CustomerCompanyContext::accessibleCompanyQuery()`. Policy wird in `ContactAccessService:229` bereits referenziert. → konsequent über Gate/Policy. +- [ ] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `findOwnedPressRelease` doppelt (PressRelease/Image-Controller); Relation-Liste (`company:…`, `category.translations`, `images`) mehrfach. → expliziter Scope (`ownedByApiUser`) + `withApiResourceRelations()`. Unique-Slug-Reimplementierung (`PressReleaseController:201-220`) durch bestehendes `HasUniqueSlug` ersetzen. +- [ ] **3.3 `DomainContext`-Service** — Host→Config-Auflösung 4× (`ThemeServiceProvider::boot`, `ThemeMiddleware`, `SetDomainUrl`, `SetCurrentPortal`). **Vorher prüfen, ob `SetDomainUrl` toter Code ist** (scheint nicht in `bootstrap/app.php` registriert). `CurrentPortalContext` mutable static → request-scoped Binding. +- [ ] **3.4 Performance** — `LogApiUsage` synchroner INSERT/Request → terminable/queued. `CustomerStatusSummary:35-66` Query-Sturm (always-on im Header) memoisieren; offene Single-Purchase 3× → einmal berechnen. `CustomerCompanyContext::selectedCompany()` doppelte Query → einmal `first()`. `LegacySubscriptionService::cancelBundleFor` Update-pro-Iteration → Bulk/Transaction. +- [ ] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalize/isEuCountry` (3×), `User::findByEmail()` statt `LOWER()`-Queries (defeated Index, 4×), `config('billing.default_currency')` statt `'EUR'`/`'eur'`-Literale, gemeinsame Account-Provisioning-Methode (`ContactAccessService` ≈ `SocialAuthService`). +- [ ] **3.6 Auth-Gates** — `EnsureUserIsAdmin/Customer`, `EnsureApiUserIsActive` von Hand-Rolling → `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware. Impersonation-Redirect aus dem Admin-Gate in eigene Middleware. + +--- + +## Phase 4 — Web-Routen entflechten (Paket C) + +`routes/web.php` > 1100 Zeilen mit kompletter Datenaufbereitung. Schrittweise, gut absicherbar. + +- [ ] **4.1 Portal-/Domain-Kontext extrahieren** (baut auf 3.3 `DomainContext` auf). +- [ ] **4.2 Published-Release-Query zentralisieren** — gleiche Bedingung (Portal `both`, `status=published`, Sprache, `published_at <= now()`) mehrfach, teils mit/ohne `withoutGlobalScope(PortalScope)`. → Scopes `scopePublished`, `scopeForPortalValues`, `scopeForLanguage`, `scopeVisibleInArchive`. +- [ ] **4.3 Page-Data in Services** — `HomePageData`/`CategoryPageData`/`NewsroomPageData` für Home/Kategorie/Newsroom/Detail/Suche/Preise. +- [ ] **4.4 Routen auf schlanke (Invokable) Controller umstellen** — schrittweise je Seite. + +--- + +## Phase 5 — Admin-CRUD & Auth: Concerns + Blade-Components (risikoarm) + +- [ ] **5.1 Tabellen-Concerns** — `HasSortableTable` (`sort()` 3× wortgleich), `HasFullTextSearch` (`supportsFullTextSearch()` 6×+ + `applyFuzzySearch`), `HasResettableFilters` (`updatedXxxFilter()`→`resetPage`). `stats()` mit `SUM(CASE WHEN is_active)` → `AdminPerformanceCache::activeInactiveStats()`; Count-Hydration → `hydrateRelationCounts()`. +- [ ] **5.2 User create/edit entdoppeln** — `UserFormRules`-Provider + `UserUpsertService`/`SaveUserAction` (`save()` trägt ~175 Zeilen). Markup → ``, ``, ``. Kontakt → `ValidatesContactData`-Concern + ``. +- [ ] **5.3 Formatierungs-Components/Enum** — `Portal::badgeColor()` + ``; ``; `PressReleaseStatus`-Enum-Methoden; ``, ``, `Category::localizedName()`, `Contact::displayName()`. +- [ ] **5.4 Seiten-Struktur-Components** — ``, ``, ``, ``, ``, ``. (Muster: bestehende ``/`components.portal.pagination`.) +- [ ] **5.5 Flash/Auth-Components** — `` + `` app-weit; ``, ``/``; `ensureNotRateLimited()/throttleKey()`-Trait (login/magic-link divergieren); ``. + +--- + +## 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-`
`s, obwohl datengetriebenes `` (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; `` 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`, ``/`components.portal.pagination`. + + diff --git a/dev/optimize/system-optimierung-analyse-codex.md b/dev/optimize/system-optimierung-analyse-codex.md new file mode 100644 index 00000000..2d7b058c --- /dev/null +++ b/dev/optimize/system-optimierung-analyse-codex.md @@ -0,0 +1,326 @@ +# System-Optimierung Analyse + +Stand: 18.06.2026 + +## Ziel + +Analyse des aktuellen Entwicklungsstands mit Fokus auf Controller, Views, Livewire/Volt-Komponenten und wiederverwendbare Code-Strukturen. Schwerpunkt: Code-Dopplungen, tote/alte Strukturen, Query-Wiederholungen und Refactoring-Potenzial. + +## Kurzfazit + +Das System hat bereits gute fachliche Bausteine wie Services, Policies, Model-Scopes, Eloquent Resources und einzelne Livewire-Concerns. Das groesste Optimierungspotenzial liegt nicht in den klassischen Controllern, sondern in: + +- sehr grosser Route-/Page-Logik in `routes/web.php` +- sehr grossen Volt-Dateien mit wiederholter Formular-, Scheduling- und Query-Logik +- alten Admin-Wrapper-Views neben den aktiven Volt-Routen +- einzelnen Demo-/Prototyp-Komponenten, die noch erreichbar sind +- mehrfach eingebetteten API-Zugriffsqueries + +## Priorisierte Findings + +### 1. Erreichbarer Prototyp: `admin/users/table` + +**Dateien** + +- `routes/admin.php` +- `resources/views/livewire/admin/users/table.blade.php` + +`routes/admin.php` routet noch `admin/users/table` auf die Volt-Komponente `admin.users.table`. Diese Komponente wirkt wie ein frueher Flux-Prototyp: + +- filtert auf `users.status`, obwohl die Tabelle laut Schema kein `status`-Feld hat +- sortiert Demo-Spalten wie `date`, `status`, `amount` +- liest `$user->role`, obwohl Rollen ueber Spatie-Relationen laufen +- enthaelt englische Demo-Menupunkte wie `New post`, `Draft`, `Published`, `Archived` + +**Risiko** + +Die Route ist erreichbar und kann zu SQL-Fehlern oder fachlich falscher UI fuehren. + +**Empfehlung** + +Route und Komponente entfernen, falls sie nicht mehr gebraucht werden. Falls eine separate User-Tabelle geplant ist, sollte sie auf Basis der produktiven `admin.users`-Komponente neu aufgebaut werden. + +**Prioritaet:** Hoch + +### 2. Alte Admin-Wrapper-Views neben direkten Volt-Routen + +**Dateien** + +- `resources/views/admin/*` +- `routes/admin.php` +- `app/Http/Controllers/Admin/DashboardController.php` + +Die aktiven Admin-Seiten laufen groesstenteils direkt ueber `Volt::route(...)`. Gleichzeitig existieren alte Wrapper-Views unter `resources/views/admin/*`, die Livewire-Komponenten manuell mounten, z. B.: + +- `resources/views/admin/companies/show.blade.php` +- `resources/views/admin/press-releases/show.blade.php` +- `resources/views/admin/roles/edit.blade.php` + +Ein Suchlauf hat nur einen produktiven `view('admin.*')`-Aufruf ergeben: + +- `DashboardController` rendert `admin.dashboard` + +Viele Wrapper sind dadurch wahrscheinlich tot. Einige verwenden zudem alte Routennamen oder alte Route-Parameter, z. B. `request()->route('pressRelease')`, waehrend die aktuelle Volt-Route `{id}` nutzt. + +**Risiko** + +Verwirrung bei Wartung, falsche Wiederverwendung, alte Parameter-Konventionen, unklare Quelle der Wahrheit. + +**Empfehlung** + +- `admin.dashboard` behalten, solange der DashboardController sie nutzt +- ungenutzte Admin-Wrapper entfernen oder in einen klaren Legacy-/Dokumentationsbereich verschieben +- `resources/views/admin/*.md` separat bewerten, da diese eher Dokumentation/Status darstellen + +**Prioritaet:** Hoch + +### 3. `routes/web.php` enthaelt zu viel Controller- und Query-Logik + +**Datei** + +- `routes/web.php` + +`routes/web.php` hat aktuell ueber 1100 Zeilen und enthaelt komplette Datenaufbereitung fuer: + +- Home-Daten (`$webHomeData`) +- Kategorie-/Branchenseite (`$categoryPage`) +- Pressemitteilungs-Detailseite (`$releaseDetailPage`) +- Veroeffentlichen-Seite (`$veroeffentlichenPage`) +- Suche (`$suchePage`) +- Newsrooms-Index (`$newsroomsIndex`) +- Newsroom-Detail (`$newsroomDetail`) +- Preise (`$preisePage`) +- Legacy-Redirects + +Die Datei wiederholt mehrfach: + +- Domain-/Portal-Erkennung +- `portal/status/language/published_at`-Filter +- Eager-Load-Sets fuer `company`, `category.translations`, `images` +- Most-read Queries +- Newsroom Count Queries +- Statistikfenster wie 7/14/30 Tage + +**Risiko** + +Schwer testbar, schwer zu optimieren, hohe Aenderungskosten, Query-Regeln koennen auseinanderlaufen. + +**Empfehlung** + +In mehreren kleinen Schritten extrahieren: + +1. `WebPortalContext` oder aehnlicher Service fuer DomainKey, Portal, PortalValues, Language und Theme-Konfiguration. +2. `PublishedReleaseQuery` oder lokale Scopes am Model fuer veroeffentlichte Pressemitteilungen. +3. Page-Data-Klassen wie `HomePageData`, `CategoryPageData`, `NewsroomPageData`. +4. Danach Routen auf schlanke Controller oder Invokable Controller umstellen. + +**Prioritaet:** Hoch + +### 4. Wiederholte Published-Release-Bedingungen + +**Dateien** + +- `routes/web.php` +- `app/Models/PressRelease.php` + +Die gleiche fachliche Bedingung fuer oeffentlich sichtbare Pressemitteilungen taucht mehrfach auf: + +- Portal muss aktuelles Portal oder `both` sein +- Status ist `published` +- Sprache passt +- `published_at` ist gesetzt +- `published_at <= now()` + +Teilweise wird `withoutGlobalScope(PortalScope::class)` verwendet, teilweise nicht. Das ist fachlich nachvollziehbar, aber die Logik sollte zentraler formuliert werden. + +**Empfehlung** + +Moegliche Scopes: + +```php +public function scopePublished(Builder $query): Builder +public function scopeForPortalValues(Builder $query, array $portalValues): Builder +public function scopeForLanguage(Builder $query, string $language): Builder +public function scopeVisibleInArchive(Builder $query, array $portalValues, string $language): Builder +``` + +Alternativ ein Query-Objekt, wenn die Kombination mit `withoutGlobalScope(PortalScope::class)` bewusst kontrolliert werden soll. + +**Prioritaet:** Hoch + +### 5. Grosse PM-Editoren mit duplizierter Formular- und Scheduling-Logik + +**Dateien** + +- `resources/views/livewire/admin/press-releases/create.blade.php` +- `resources/views/livewire/admin/press-releases/edit.blade.php` +- `resources/views/livewire/customer/press-releases/create.blade.php` +- `resources/views/livewire/customer/press-releases/edit.blade.php` +- `app/Livewire/Concerns/InteractsWithPressReleaseTags.php` + +Die vier PM-Editoren sind sehr gross und wiederholen viele Muster: + +- Properties fuer Portal, Sprache, Firma, Kategorie, Kontakt, Titel, Text, Keywords +- `updatedPublishMode` +- `updatedScheduledDate` +- `updatedScheduledTime` +- `syncScheduledAt` +- `resolveScheduledAt` +- `validateScheduledAtWhenReady` +- grosse `formRules` +- Teile der Speicher- und Submit-Logik + +Positiv: Fuer Customer-Editoren existiert bereits `InteractsWithPressReleaseTags`. Admin-Editoren duplizieren die Tag-Logik aber noch lokal. + +**Empfehlung** + +In kleinen, risikoarmen Schritten: + +1. Admin-PM-Editoren ebenfalls `InteractsWithPressReleaseTags` nutzen lassen. +2. `InteractsWithPressReleaseScheduling` einfuehren fuer Scheduling-Hooks und Validierung. +3. Gemeinsame Rule-Fragmente in einen Service oder Trait auslagern, z. B. `PressReleaseFormRules`. +4. Wiederkehrende Formularabschnitte als Blade-Komponenten oder partials extrahieren. + +**Prioritaet:** Hoch + +### 6. API-Zugriffsqueries mehrfach eingebettet + +**Dateien** + +- `app/Http/Controllers/Api/V1/PressReleaseController.php` +- `app/Http/Controllers/Api/V1/PressReleaseImageController.php` +- `app/Http/Controllers/Api/V1/CompanyController.php` +- `app/Models/PressRelease.php` +- `app/Models/Company.php` + +Mehrere Controller enthalten fast gleiche Ownership-/Access-Queries: + +- `findOwnedPressRelease` in `PressReleaseController` +- `findOwnedPressRelease` in `PressReleaseImageController` +- Company-Zugriff ueber `owner_user_id` oder `users`-Pivot in `CompanyController` +- dieselbe Company-Zugriffslogik nochmals in `PressReleaseController` + +Es gibt bereits `PressRelease::accessibleBy(User $user)`, allerdings nutzt die API aktuell eine engere `user_id`-Logik. Das kann fachlich korrekt sein, sollte aber explizit zentralisiert werden. + +**Empfehlung** + +- `Company::accessibleBy(User $user)` als Scope einfuehren +- API-spezifischen PressRelease-Scope definieren, z. B. `ownedByApiUser(User $user)`, falls API-Zugriff bewusst nur Autor-Ownership meint +- alternativ Policies konsequenter nutzen + +**Prioritaet:** Mittel bis hoch + +### 7. Wiederholte API-Resource-Eager-Loads + +**Datei** + +- `app/Http/Controllers/Api/V1/PressReleaseController.php` + +Die gleiche Relation-Liste wird mehrfach geladen: + +- `company:id,name,portal,legacy_portal,legacy_id` +- `category.translations` +- `images` + +**Empfehlung** + +Eine private Methode oder ein Query-Scope: + +```php +private function resourceRelations(): array +``` + +oder: + +```php +PressRelease::query()->withApiResourceRelations() +``` + +**Prioritaet:** Mittel + +### 8. Grosse Volt-Dateien als langfristiges Wartungsrisiko + +**Beispiele** + +- `resources/views/livewire/admin/press-releases/edit.blade.php` mit ca. 1394 Zeilen +- `resources/views/livewire/customer/press-releases/create.blade.php` mit ca. 1371 Zeilen +- `resources/views/livewire/customer/press-releases/edit.blade.php` mit ca. 1331 Zeilen +- `resources/views/livewire/admin/users/edit.blade.php` mit ca. 1241 Zeilen +- `resources/views/livewire/admin/press-releases/index.blade.php` mit ca. 1093 Zeilen + +Volt Single-File-Komponenten sind hier legitim, aber bei dieser Groesse vermischen sich Zustand, Query, Validierung, Aktionen und Markup stark. + +**Empfehlung** + +Nicht pauschal auf klassische Livewire-Klassen migrieren, sondern gezielt extrahieren: + +- Concerns fuer wiederkehrende Interaktionen +- Service-Klassen fuer fachliche Aktionen +- kleine Blade-Komponenten fuer wiederkehrende UI-Abschnitte +- Query-Methoden/Scopes fuer Tabellenfilter + +**Prioritaet:** Mittel + +## Positive Muster, die beibehalten werden sollten + +- `PressRelease::accessibleBy(User $user)` ist ein guter Single-Source-Ansatz fuer Kundenzugriff. +- `PortalScope` kapselt Portalfilterung zentral. +- `HasUniqueSlug` reduziert Slug-Duplikate. +- API nutzt Form Requests und Eloquent Resources. +- Dashboard nutzt bereits `AdminPerformanceCache`. +- Es gibt viele Feature-Tests fuer Admin, API, Customer und Web. +- Erste Concerns wie `InteractsWithPressReleaseTags` zeigen die richtige Richtung. + +## Vorgeschlagene Refactoring-Pakete + +### Paket A: Aufraeumen ohne Fachlogik-Aenderung + +1. `admin/users/table` Route entfernen. +2. `resources/views/livewire/admin/users/table.blade.php` entfernen. +3. Ungenutzte `resources/views/admin/*` Wrapper entfernen oder eindeutig als Legacy markieren. +4. Tests fuer fehlende/entfernte Route anpassen oder ergaenzen. + +**Nutzen:** Weniger Altlasten, weniger Risiko durch erreichbare Demo-Komponente. + +### Paket B: Gemeinsame PM-Editor-Concerns + +1. Admin-PM-Editoren auf `InteractsWithPressReleaseTags` umstellen. +2. `InteractsWithPressReleaseScheduling` extrahieren. +3. Gemeinsame Rule-Fragmente zentralisieren. +4. Bestehende PM-Editor-Tests gezielt laufen lassen. + +**Nutzen:** Weniger Dopplung in vier grossen Dateien, geringere Fehlerwahrscheinlichkeit bei Scheduling. + +### Paket C: Web-Routen entflechten + +1. Portal-/Domain-Kontext extrahieren. +2. Published-Release-Query zentralisieren. +3. Home/Kategorie/Newsroom-Daten in Services verschieben. +4. Routen schrittweise auf kleine Controller umstellen. + +**Nutzen:** Bessere Testbarkeit, klarere Verantwortlichkeiten, zentraleres Performance-Tuning. + +### Paket D: API-Zugriff zentralisieren + +1. `Company::accessibleBy(User $user)` einfuehren. +2. API-PressRelease-Zugriff zentral benennen. +3. Eager-Load-Sets fuer API Resources zentralisieren. +4. API-Feature-Tests laufen lassen. + +**Nutzen:** Weniger Query-Duplikate, klarere Zugriffspolitik. + +## Pruefung / Verifikation + +Die Analyse basiert auf statischer Codepruefung, Laravel-Boost-Projektinformationen, versionierter Dokumentationssuche und Schema-Inspektion. Eine Artisan-Routentabelle konnte nicht ausgefuehrt werden, weil Sail gemeldet hat: + +```text +Docker or Podman is not running. +``` + +## Hinweis zum aktuellen Worktree + +Zum Analysezeitpunkt gab es bereits eine nicht von mir angelegte Aenderung: + +- `resources/views/components/layouts/app/sidebar.blade.php` + +Diese Datei wurde fuer die Analyse nicht veraendert. diff --git a/dev/optimize/system-optimierungen-analyse-claude.md b/dev/optimize/system-optimierungen-analyse-claude.md new file mode 100644 index 00000000..ae8b79c9 --- /dev/null +++ b/dev/optimize/system-optimierungen-analyse-claude.md @@ -0,0 +1,225 @@ +# 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 | `` + `` + `` / `` | Admin/Auth | Hoch | Niedrig | +| 8 | `Portal::badgeColor()` + `` / `` | 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: ``, ``, ``, ``, ``, ``. **Größte Roh-Zeilenersparnis.** +- **M4 — Listenzeilen admin vs. customer dupliziert** (admin/index:836-968 ≈ customer/index:421-531). Filter-Chips mit inline-`` ~9× in admin/index:662-801, 3× customer/index:320-376. → `` + ``. +- **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()`) + ``, ``. +- **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). → ``, ``, ``. +- **Kontakt-Validierung/Markup** (users/edit:406-412, contacts/edit, customer/profile:147-168, press-kits/show:169-175). → `ValidatesContactData`-Concern + `` (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()` + ``. +- **Status-Badge (ok/err-dot)** (companies/index:519-524, users:558-564, customer press-kits). → ``. +- **`pressReleaseStatusColor()`** (users:161-170) → auf `PressReleaseStatus`-Enum. +- **`formatEuro()`** (customer/bookings:36-39) → `` / Helfer. +- **Datum `?->format('d.m.Y H:i') ?? '–'`** überall → ``. + +### 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). → `` mit Action-Slot. +- **KPI-Grid-Wrapper** (users:340-353, companies/index:323-335). → ``. +- **Filter-Panel + flux:table + Pagination-Footer** (users:599-601, companies/index:588-590, payments 4×). → `` / ``. +- **Empty-State-Zelle** (users:582-594, companies/index:569-584). → ``. +- **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). → ``. + +### 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 ``. → ein `` + `` 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) → `` (Hoch). +- **Email-Input + `@error`-Markup** in allen Auth-Forms → ``/``. +- **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) → ``. + +### 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 ``/``. +- Company-Logo-Logik (`logoVariant/logoInitials/logoUrl`, press-kits/index:221-259,645-650,760-766) → ``. +- 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) → ``. + +> **Bereits gute Wiederverwendung:** `` 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.` 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-`
`s, derselbe Check/Dash/Cross-SVG ~15×. **Es existiert bereits ein datengetriebenes ``** (`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)` + ``. +- **Footer standard vs. hub** (site-footer:1-82 ≈ hub/site-footer:1-82) → ein `` mit `columns`+`variant`. +- **Top-Utility-Bar** inline in site-header:47-93 vs. komponentisiert in hub/top-utility-bar:1-55 → gemeinsame ``. +- **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` → `` + statische Arrays in View-Data. + +### Niedrig +- Layout-Shell `max-w-layout mx-auto px-8` ~20× in pressekonto.blade.php → `` oder `.layout-container`-Utility (wo `.card`/`.badge` schon liegen). +- Such-Form-Block doppelt in site-header (Desktop :110-130, Mobile :196-226) → ``. +- 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. diff --git a/tests/Feature/ApiAccessEligibilityServiceTest.php b/tests/Feature/ApiAccessEligibilityServiceTest.php new file mode 100644 index 00000000..a3880999 --- /dev/null +++ b/tests/Feature/ApiAccessEligibilityServiceTest.php @@ -0,0 +1,97 @@ +create([ + 'legacy_portal' => 'businessportal24', + 'legacy_id' => fake()->unique()->numberBetween(1, 1_000_000), + 'user_id' => $user->id, + 'legacy_user_id' => $user->id, + 'number' => 'LEG-'.fake()->unique()->numberBetween(1, 1_000_000), + 'total_cents' => 4900, + 'status' => $status, + 'invoice_date' => $invoiceDate, + 'paid_at' => $status === 'paid' ? $invoiceDate : null, + 'imported_at' => now(), + ]); +} + +test('latestLegacyInvoice returns newest by invoice_date then id', function () { + $user = User::factory()->create(); + makeLegacyInvoice($user, 'paid', '2024-01-01'); + $newest = makeLegacyInvoice($user, 'reminder', '2024-06-01'); + makeLegacyInvoice($user, 'paid', '2024-03-01'); + + expect($user->latestLegacyInvoice()->id)->toBe($newest->id); +}); + +test('token gate grants access when latest legacy invoice is paid', function () { + $user = User::factory()->create(['is_active' => true]); + makeLegacyInvoice($user, 'paid', '2024-01-01'); + + expect((new ApiAccessEligibilityService)->canCreateToken($user))->toBeTrue(); +}); + +test('token gate denies when latest legacy invoice is unpaid even if an older one was paid', function () { + $user = User::factory()->create(['is_active' => true]); + makeLegacyInvoice($user, 'paid', '2024-01-01'); + makeLegacyInvoice($user, 'reminder', '2024-06-01'); + + expect((new ApiAccessEligibilityService)->canCreateToken($user))->toBeFalse(); +}); + +test('token gate denies when there is no legacy invoice and no active payment option', function () { + $user = User::factory()->create(['is_active' => true]); + + expect((new ApiAccessEligibilityService)->canCreateToken($user))->toBeFalse(); +}); + +test('hasActivePaymentOption is period-aware for active options', function () { + $user = User::factory()->create(['is_active' => true]); + + UserPaymentOption::factory()->for($user)->create([ + 'status' => UserPaymentOptionStatus::Active->value, + 'current_period_start' => now()->subDay(), + 'current_period_end' => now()->addDay(), + ]); + expect($user->hasActivePaymentOption())->toBeTrue(); + + $user->userPaymentOptions()->update([ + 'current_period_start' => now()->subMonths(2), + 'current_period_end' => now()->subMonth(), + ]); + expect($user->fresh()->hasActivePaymentOption())->toBeFalse(); +}); + +test('hasActivePaymentOption is period-aware for grandfathering', function () { + $user = User::factory()->create(['is_active' => true]); + + $option = UserPaymentOption::factory()->for($user)->create([ + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->addMonth(), + ]); + expect($user->hasActivePaymentOption())->toBeTrue(); + + $option->update(['grandfathered_until' => now()->subDay()]); + expect($user->fresh()->hasActivePaymentOption())->toBeFalse(); +}); + +test('booking and quota gates are period-aware when enforcement is on', function () { + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(['is_active' => true]); + UserPaymentOption::factory()->for($user)->create([ + 'status' => UserPaymentOptionStatus::Active->value, + 'current_period_start' => now()->subMonths(2), + 'current_period_end' => now()->subMonth(), + ]); + + expect($user->hasActiveBooking())->toBeFalse() + ->and($user->hasUnlimitedPressReleaseQuota())->toBeFalse(); +}); diff --git a/tests/Feature/BasicAuthMiddlewareTest.php b/tests/Feature/BasicAuthMiddlewareTest.php new file mode 100644 index 00000000..4a0c395d --- /dev/null +++ b/tests/Feature/BasicAuthMiddlewareTest.php @@ -0,0 +1,59 @@ +set('auth.basic.enabled', true); + config()->set('auth.basic.user', 'staging'); + config()->set('auth.basic.password', 'secret'); +}); + +function handleBasicAuth(Request $request): Response +{ + return (new BasicAuthMiddleware)->handle($request, fn () => response('ok')); +} + +test('rejects request without credentials', function () { + $response = handleBasicAuth(Request::create('/dashboard', 'GET')); + + expect($response->getStatusCode())->toBe(401) + ->and($response->headers->get('WWW-Authenticate'))->toBe('Basic'); +}); + +test('rejects request with wrong password', function () { + $request = Request::create('/dashboard', 'GET', server: [ + 'PHP_AUTH_USER' => 'staging', + 'PHP_AUTH_PW' => 'wrong', + ]); + + expect(handleBasicAuth($request)->getStatusCode())->toBe(401); +}); + +test('passes request with correct credentials', function () { + $request = Request::create('/dashboard', 'GET', server: [ + 'PHP_AUTH_USER' => 'staging', + 'PHP_AUTH_PW' => 'secret', + ]); + + expect(handleBasicAuth($request)->getStatusCode())->toBe(200); +}); + +test('skips gate entirely when disabled', function () { + config()->set('auth.basic.enabled', false); + + expect(handleBasicAuth(Request::create('/dashboard', 'GET'))->getStatusCode())->toBe(200); +}); + +test('skips gate when credentials are not configured', function () { + config()->set('auth.basic.user', null); + config()->set('auth.basic.password', null); + + expect(handleBasicAuth(Request::create('/dashboard', 'GET'))->getStatusCode())->toBe(200); +}); + +test('skips basic auth for api and livewire routes', function () { + expect(handleBasicAuth(Request::create('/api/v1/press-releases', 'GET'))->getStatusCode())->toBe(200) + ->and(handleBasicAuth(Request::create('/livewire/update', 'POST'))->getStatusCode())->toBe(200); +}); diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index c990781c..acff1091 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -125,6 +125,7 @@ test('a grandfathered legacy user sees the bestandsschutz package with portal an 'user_id' => $user->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'current_period_end' => now()->addMonths(3), + 'grandfathered_until' => now()->addYear(), 'legacy_conditions' => [ 'name' => 'Presseverteiler Premium', 'legacy_portal' => 'presseecho', diff --git a/tests/Feature/Billing/InvoiceNumberGeneratorTest.php b/tests/Feature/Billing/InvoiceNumberGeneratorTest.php index 627c9d9e..0ed39718 100644 --- a/tests/Feature/Billing/InvoiceNumberGeneratorTest.php +++ b/tests/Feature/Billing/InvoiceNumberGeneratorTest.php @@ -1,6 +1,7 @@ app(InvoiceNumberGenerator::class)->next('XXX')) ->toThrow(InvalidArgumentException::class); }); + +test('an existing sequence row is not reset by the race-safe ensure-insert', function () { + DB::table('invoice_number_sequences')->insert([ + 'circle' => InvoiceNumberGenerator::CIRCLE_MANUAL, + 'next_number' => 5, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(app(InvoiceNumberGenerator::class)->nextManualNumber())->toBe('MAN-00005'); + expect(DB::table('invoice_number_sequences')->where('circle', 'MAN')->count())->toBe(1); +}); diff --git a/tests/Feature/Billing/LegacyBundleTest.php b/tests/Feature/Billing/LegacyBundleTest.php index c26280d1..8c7b57f3 100644 --- a/tests/Feature/Billing/LegacyBundleTest.php +++ b/tests/Feature/Billing/LegacyBundleTest.php @@ -20,6 +20,7 @@ function grandfatheredPost(User $user, string $portal, int $netCents = 100000): return UserPaymentOption::factory()->create([ 'user_id' => $user->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->addYear(), 'stripe_subscription_id' => null, 'cancelled_at' => null, 'legacy_conditions' => [ diff --git a/tests/Feature/Customer/DashboardTest.php b/tests/Feature/Customer/DashboardTest.php index 47229e82..8de1db55 100644 --- a/tests/Feature/Customer/DashboardTest.php +++ b/tests/Feature/Customer/DashboardTest.php @@ -162,6 +162,7 @@ test('a grandfathered bestandskunde does not see the booking-required banner', f UserPaymentOption::factory()->create([ 'user_id' => $customer->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->addYear(), 'stripe_subscription_id' => null, ]); diff --git a/tests/Feature/CustomerStatusSummaryTest.php b/tests/Feature/CustomerStatusSummaryTest.php index b9f57256..f917bbb5 100644 --- a/tests/Feature/CustomerStatusSummaryTest.php +++ b/tests/Feature/CustomerStatusSummaryTest.php @@ -50,6 +50,7 @@ test('a grandfathered bestandskunde is labelled and counts as active', function UserPaymentOption::factory()->create([ 'user_id' => $user->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->addYear(), 'stripe_subscription_id' => null, ]); diff --git a/tests/Feature/PressReleaseQuotaTest.php b/tests/Feature/PressReleaseQuotaTest.php index efdb3108..130590cc 100644 --- a/tests/Feature/PressReleaseQuotaTest.php +++ b/tests/Feature/PressReleaseQuotaTest.php @@ -89,6 +89,7 @@ test('a grandfathered legacy user has an unlimited quota', function () { UserPaymentOption::factory()->create([ 'user_id' => $user->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->addYear(), ]); expect($user->pressReleaseQuotaRemaining())->toBeNull(); diff --git a/tests/Unit/PortalFromThemeTest.php b/tests/Unit/PortalFromThemeTest.php new file mode 100644 index 00000000..b584417f --- /dev/null +++ b/tests/Unit/PortalFromThemeTest.php @@ -0,0 +1,15 @@ +toBe(Portal::Presseecho) + ->and(Portal::fromTheme('businessportal24'))->toBe(Portal::Businessportal24); +}); + +test('admin and hub themes resolve to no portal filter', function () { + expect(Portal::fromTheme('portal'))->toBeNull() + ->and(Portal::fromTheme('pressekonto'))->toBeNull() + ->and(Portal::fromTheme('main'))->toBeNull() + ->and(Portal::fromTheme(null))->toBeNull(); +});