diff --git a/app/Models/User.php b/app/Models/User.php index ed0c201c..61d18b42 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -132,6 +132,11 @@ class User extends Authenticatable implements MustVerifyEmail private ?int $cachedOpenSinglePurchaseCount = null; + /** + * @var list|null + */ + private ?array $cachedAccessibleCompanyIds = null; + /** * Findet einen User case-insensitive anhand der E-Mail. Single Source of * Truth für die E-Mail-basierte Auflösung (Magic-Link, Social-Login, @@ -452,9 +457,19 @@ class User extends Authenticatable implements MustVerifyEmail */ public function accessibleCompanyIds(): array { - return $this->ownedCompanies()->pluck('companies.id') - ->merge($this->companies()->pluck('companies.id')) + if ($this->cachedAccessibleCompanyIds !== null) { + return $this->cachedAccessibleCompanyIds; + } + + // toBase() statt der Relation-pluck(): Eloquents Builder::pluck() + // hydratisiert sonst pro ID ein vollständiges Company-Model (bei + // Power-Usern mit zehntausenden Firmen ein massives Speicher-/Zeit-Leck). + // toBase() wendet die Global Scopes weiter an, liefert aber nur die + // nackten ID-Werte ohne Model-Hydration. + return $this->cachedAccessibleCompanyIds = $this->ownedCompanies()->toBase()->pluck('companies.id') + ->merge($this->companies()->toBase()->pluck('companies.id')) ->unique() + ->map(fn ($id): int => (int) $id) ->values() ->all(); } diff --git a/tests/Feature/PressReleaseAccessScopeTest.php b/tests/Feature/PressReleaseAccessScopeTest.php index 76d248d8..a7ff8b99 100644 --- a/tests/Feature/PressReleaseAccessScopeTest.php +++ b/tests/Feature/PressReleaseAccessScopeTest.php @@ -67,6 +67,32 @@ test('accessibleBy excludes press releases of strangers and unrelated companies' expect($ids)->not->toContain($foreignPr->id); }); +test('accessibleCompanyIds returns owned and member company ids without duplicates', function () { + $user = User::factory()->create(); + $owned = Company::factory()->create(['owner_user_id' => $user->id]); + $member = Company::factory()->create(); + $member->users()->attach($user->id, ['role' => 'member']); + Company::factory()->create(); // fremde Firma + + $ids = $user->accessibleCompanyIds(); + + expect($ids)->toEqualCanonicalizing([$owned->id, $member->id]); +}); + +test('accessibleCompanyIds does not hydrate Company models', function () { + $user = User::factory()->create(); + Company::factory()->count(3)->create(['owner_user_id' => $user->id]); + + $hydrated = 0; + Company::retrieved(function () use (&$hydrated): void { + $hydrated++; + }); + + $user->accessibleCompanyIds(); + + expect($hydrated)->toBe(0); +}); + test('accessibleBy bypasses the portal global scope so own PMs are visible cross-portal', function () { $user = User::factory()->create(); $pr = PressRelease::factory()->create([