From 3da335388e929db9733af08a9b698c8f2527c8a4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 18 Jun 2026 13:27:39 +0000 Subject: [PATCH] WS-8: N+1 entschaerfen und PM-Zugriffsmuster konsolidieren - User::currentPlan() und hasUnlimitedPressReleaseQuota() pro Request memoisiert. Beide werden je Render mehrfach abgefragt (Status-Widget auf jeder Seite, Buchungsseite, Quota-Methoden) und loesten bisher jeweils eigene subscription-/Plan-/payment-option-Queries aus. fresh() setzt den Cache zurueck. - show.blade.php: getMyPR() war NICHT memoisiert und wurde pro Request bis zu 7x mit voller Eager-Load-Query aufgerufen -> jetzt memoisiert. Firmen- Kennzahlen direkt per withCount() eager geladen statt nachtraeglichem loadCount(). - Dupliziertes Zugriffsmuster (withoutGlobalScopes + user_id/company_id) aus edit/show/index in PressRelease::scopeAccessibleBy(User) zentralisiert (Single Source of Truth fuer Kunden-PM-Zugriff), mit Tests abgesichert. Co-Authored-By: Claude Opus 4.8 --- app/Models/PressRelease.php | 22 ++++++ app/Models/User.php | 36 +++++++-- .../customer/press-releases/edit.blade.php | 11 +-- .../customer/press-releases/index.blade.php | 12 +-- .../customer/press-releases/show.blade.php | 23 +++--- tests/Feature/PressReleaseAccessScopeTest.php | 73 +++++++++++++++++++ 6 files changed, 139 insertions(+), 38 deletions(-) create mode 100644 tests/Feature/PressReleaseAccessScopeTest.php diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index ebd1006e..d0972f13 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -184,6 +184,28 @@ class PressRelease extends Model return $query->whereHas('boosts', fn (Builder $q) => $q->active()); } + /** + * Beschränkt auf PMs, die dem User gehören (als Autor) oder zu einer ihm + * zugeordneten Firma. Bewusst ohne Portal-GlobalScope: ein Kunde darf + * seine eigenen PMs portalübergreifend sehen und bearbeiten. + * + * Single Source of Truth für den Kunden-Zugriff auf PMs — genutzt von der + * Übersicht, Detail-, Bearbeiten- und Nachweis-Ansicht. + */ + public function scopeAccessibleBy(Builder $query, User $user): Builder + { + return $query->withoutGlobalScopes() + ->where(function (Builder $q) use ($user): void { + $q->where('user_id', $user->id); + + $companyIds = $user->accessibleCompanyIds(); + + if ($companyIds !== []) { + $q->orWhereIn('company_id', $companyIds); + } + }); + } + /** * Display-ready text. Returns sanitized HTML for Phase-7+ PMs and *

/
-wrapped legacy plain text for older imports. diff --git a/app/Models/User.php b/app/Models/User.php index f7f7f74d..6b945803 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -117,25 +117,45 @@ class User extends Authenticatable implements MustVerifyEmail return (bool) $this->billingAddress?->isComplete(); } + /** + * Per-Request-Memoisierung: Tarif und Unlimited-Flag werden pro Render + * mehrfach abgefragt (u. a. vom immer sichtbaren Status-Widget und der + * Buchungsseite). Beide ändern sich innerhalb eines Requests nicht über + * In-App-Aktionen (Abo läuft über Stripe-Redirect, Bestandstarif über + * CLI), daher ist die Memoisierung sicher; `fresh()` liefert eine neue + * Instanz und setzt den Cache zurück. + */ + private bool $currentPlanResolved = false; + + private ?Plan $cachedCurrentPlan = null; + + private ?bool $cachedHasUnlimitedQuota = null; + /** * Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans` * gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo. */ public function currentPlan(): ?Plan { + if ($this->currentPlanResolved) { + return $this->cachedCurrentPlan; + } + + $this->currentPlanResolved = true; + $subscription = $this->subscription(); if (! $subscription?->valid()) { - return null; + return $this->cachedCurrentPlan = null; } $priceId = $subscription->stripe_price; if (! $priceId) { - return null; + return $this->cachedCurrentPlan = null; } - return Plan::query() + return $this->cachedCurrentPlan = Plan::query() ->where('stripe_price_id_monthly', $priceId) ->orWhere('stripe_price_id_yearly', $priceId) ->first(); @@ -160,11 +180,15 @@ class User extends Authenticatable implements MustVerifyEmail */ public function hasUnlimitedPressReleaseQuota(): bool { - if (! config('billing.enforce_booking')) { - return true; + if ($this->cachedHasUnlimitedQuota !== null) { + return $this->cachedHasUnlimitedQuota; } - return $this->userPaymentOptions() + if (! config('billing.enforce_booking')) { + return $this->cachedHasUnlimitedQuota = true; + } + + return $this->cachedHasUnlimitedQuota = $this->userPaymentOptions() ->whereIn('status', [ UserPaymentOptionStatus::Active->value, UserPaymentOptionStatus::Grandfathered->value, diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index b3776835..2179ef7b 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -684,16 +684,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl { // Pro Livewire-Request memoisiert: mount(), with() und save() greifen // sonst jeweils mit einer eigenen Query auf dieselbe PM zu. - $user = auth()->user(); - - return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes() - ->where(function ($q) use ($user): void { - $q->where('user_id', $user->id); - $companyIds = $user->accessibleCompanyIds(); - if ($companyIds !== []) { - $q->orWhereIn('company_id', $companyIds); - } - }) + return $this->cachedPressRelease ??= PressRelease::accessibleBy(auth()->user()) ->findOrFail($this->id); } diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 6bd441f9..003e3cfc 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -110,19 +110,11 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class public function with(): array { - $userId = auth()->id(); $context = app(CustomerCompanyContext::class); $selectedCompanyId = $context->selectedCompanyId(auth()->user()); - // Firmen-Scope: eigene PMs (Autor) ODER PMs der zugeordneten Firmen. - $accessibleCompanyIds = auth()->user()->accessibleCompanyIds(); - $base = PressRelease::withoutGlobalScopes() - ->where(function ($q) use ($userId, $accessibleCompanyIds): void { - $q->where('user_id', $userId); - if ($accessibleCompanyIds !== []) { - $q->orWhereIn('company_id', $accessibleCompanyIds); - } - }) + // Firmen-Scope: eigene PMs (Autor) ODER PMs der zugeordneten Firmen. + $base = PressRelease::accessibleBy(auth()->user()) ->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId)) ->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id')) ->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id')); diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index f6518b1e..1cbd5da6 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -158,7 +158,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $cover = app(PressReleaseCoverImage::class); $user = auth()->user(); - $company = $pr->company?->loadCount(['pressReleases', 'contacts']); + $company = $pr->company; $boostService = app(BoostService::class); $proofService = app(ProofPdfService::class); @@ -246,20 +246,19 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends return implode(' · ', $parts); } + private ?PressRelease $cachedPressRelease = null; + private function getMyPR(): PressRelease { - $user = auth()->user(); - - return PressRelease::withoutGlobalScopes() - ->where(function ($q) use ($user): void { - $q->where('user_id', $user->id); - $companyIds = $user->accessibleCompanyIds(); - if ($companyIds !== []) { - $q->orWhereIn('company_id', $companyIds); - } - }) + // Pro Livewire-Request memoisiert: mount(), with() und die Add-on- + // Aktionen rufen getMyPR() sonst jeweils mit der vollen Eager-Load- + // Query erneut auf. Firmen-Kennzahlen direkt mit eager laden, statt + // sie nachträglich per loadCount() nachzuziehen. + return $this->cachedPressRelease ??= PressRelease::accessibleBy(auth()->user()) ->with([ - 'company:id,name,email,phone,address,portal,logo_path,legacy_portal,is_active,type', + 'company' => fn ($query) => $query + ->select(['id', 'name', 'email', 'phone', 'address', 'portal', 'logo_path', 'legacy_portal', 'is_active', 'type']) + ->withCount(['pressReleases', 'contacts']), 'category.translations', 'contacts' => fn ($query) => $query ->withoutGlobalScopes() diff --git a/tests/Feature/PressReleaseAccessScopeTest.php b/tests/Feature/PressReleaseAccessScopeTest.php new file mode 100644 index 00000000..571b57fb --- /dev/null +++ b/tests/Feature/PressReleaseAccessScopeTest.php @@ -0,0 +1,73 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +test('accessibleBy includes a users own authored press release', function () { + $user = User::factory()->create(); + $pr = PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => null]); + + $ids = PressRelease::accessibleBy($user)->pluck('id'); + + expect($ids)->toContain($pr->id); +}); + +test('accessibleBy includes press releases of an assigned company', function () { + $user = User::factory()->create(); + $company = Company::factory()->presseecho()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + $other = User::factory()->create(); + $pr = PressRelease::factory()->create([ + 'user_id' => $other->id, + 'company_id' => $company->id, + 'portal' => $company->portal->value, + ]); + + $ids = PressRelease::accessibleBy($user)->pluck('id'); + + expect($ids)->toContain($pr->id); +}); + +test('accessibleBy excludes press releases of strangers and unrelated companies', function () { + $user = User::factory()->create(); + $stranger = User::factory()->create(); + $foreignCompany = Company::factory()->presseecho()->create(); + + $foreignPr = PressRelease::factory()->create([ + 'user_id' => $stranger->id, + 'company_id' => $foreignCompany->id, + 'portal' => $foreignCompany->portal->value, + ]); + + $ids = PressRelease::accessibleBy($user)->pluck('id'); + + expect($ids)->not->toContain($foreignPr->id); +}); + +test('accessibleBy bypasses the portal global scope so own PMs are visible cross-portal', function () { + $user = User::factory()->create(); + $pr = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => null, + 'portal' => Portal::Businessportal24->value, + ]); + + // Simuliert eingeschränkten Portal-Kontext (Presseecho); der Scope muss + // die eigene PM dennoch finden, weil er die GlobalScopes entfernt. + CurrentPortalContext::set(Portal::Presseecho); + + $found = PressRelease::accessibleBy($user)->find($pr->id); + + expect($found)->not->toBeNull(); +});