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();
+});