diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index fc7649f6..eaada7b4 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\HtmlString; @@ -287,6 +289,79 @@ class PressRelease extends Model ->all(); } + /** + * Seitenweise, für $user zugängliche PMs – über dieselbe index-freundliche + * UNION wie {@see accessibleStatusCountsFor()} statt des OR-Filters aus + * {@see scopeAccessibleBy()}. + * + * Warum nicht `accessibleBy()->paginate()`? Der OR-Ausdruck entartet bei + * beiden Extremen: bei leeren/sparsamen Treffermengen scannt der Zeilen- + * Fetch den created_at-Index vollständig (~1 s für 0 Treffer), und der + * Paginator-`count(*)` macht ohnehin einen Full-Scan (~1 s, EXPLAIN + * type=ALL). Stattdessen: schmale UNION (nur Sortierspalten) liefert die + * Seiten-IDs über die user_id-/company_id-Indizes, danach werden nur die + * 25 Modelle der Seite hydratisiert. Ergebnis: ~100 ms für normale, <500 ms + * für die größten Konten (Benchmark 02.07.). + * + * @param (Closure(Builder): mixed)|null $constrain Filter, die auf + * beide UNION-Zweige angewandt werden (Firmen-/Status-/Portal-/Suchfilter) + * @return LengthAwarePaginator + */ + public static function accessiblePageFor( + User $user, + ?Closure $constrain = null, + string $sortColumn = 'created_at', + string $sortDir = 'desc', + int $perPage = 25, + ): LengthAwarePaginator { + $sortColumn = in_array($sortColumn, ['created_at', 'title', 'status'], true) ? $sortColumn : 'created_at'; + $sortDir = $sortDir === 'asc' ? 'asc' : 'desc'; + + $branch = function (Closure $where) use ($constrain): Builder { + $query = static::query()->withoutGlobalScopes() + ->select('id', 'created_at', 'title', 'status'); + $where($query); + + if ($constrain !== null) { + $constrain($query); + } + + return $query; + }; + + $authored = $branch(fn (Builder $q) => $q->where('user_id', $user->id)); + $viaCompany = $branch(fn (Builder $q) => $q->whereIn( + 'company_id', + Company::accessibleBy($user)->select('companies.id'), + )); + + $accessible = DB::query()->fromSub($authored->union($viaCompany), 'accessible_press_releases'); + + $page = Paginator::resolveCurrentPage(); + $total = (clone $accessible)->count(); + + $pageIds = $accessible + ->orderBy($sortColumn, $sortDir) + ->orderBy('id', $sortDir) + ->forPage($page, $perPage) + ->pluck('id'); + + $models = static::query()->withoutGlobalScopes() + ->whereKey($pageIds) + ->with('company:id,name') + ->orderBy($sortColumn, $sortDir) + ->orderBy('id', $sortDir) + ->get(); + + return new LengthAwarePaginator( + $models, + $total, + $perPage, + $page, + ['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'], + ); + } + /** * Bewusst enger als {@see scopeAccessibleBy()}: Der API-Zugriff meint nur * die eigene Autorschaft (user_id), nicht den breiteren Firmenzugriff. diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index dd2df8a0..2c5dd591 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -135,17 +135,16 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class PressReleaseStatus::Archived->value => $statusCountsRaw[PressReleaseStatus::Archived->value] ?? 0, ]; - $prs = PressRelease::accessibleBy($user) - ->tap($applyCompanyFilter) - ->with('company:id,name') - ->when(filled($this->search), function ($q): void { - $term = $this->search; - $q->where('title', 'like', '%' . $term . '%'); - }) - ->when($this->statusFilter !== 'all', fn($q) => $q->where('status', $this->statusFilter)) - ->when($this->portalFilter !== 'all', fn($q) => $q->where('portal', $this->portalFilter)) - ->orderBy(in_array($this->sortBy, ['title', 'status', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir) - ->paginate(25); + // Der Firmen-Scope plus die Suchleiste/Status-/Portal-Filter werden in + // beide UNION-Zweige gezogen (siehe PressRelease::accessiblePageFor()). + $applyListFilters = function ($query) use ($applyCompanyFilter): void { + $applyCompanyFilter($query); + $query->when(filled($this->search), fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter)); + }; + + $prs = PressRelease::accessiblePageFor($user, $applyListFilters, $this->sortBy, $this->sortDir); return [ 'pressReleases' => $prs, diff --git a/tests/Feature/PressReleaseAccessiblePageTest.php b/tests/Feature/PressReleaseAccessiblePageTest.php new file mode 100644 index 00000000..3a25bc42 --- /dev/null +++ b/tests/Feature/PressReleaseAccessiblePageTest.php @@ -0,0 +1,79 @@ +create(); + $company = Company::factory()->create(); + $user->companies()->attach($company->id, ['role' => 'member']); + + $authored = PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => null]); + $viaCompany = PressRelease::factory()->create(['company_id' => $company->id]); + // erfüllt beide Zweige – darf nur einmal gezählt werden + $both = PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => $company->id]); + + $page = PressRelease::accessiblePageFor($user); + + expect($page->total())->toBe(3) + ->and($page->pluck('id')->sort()->values()->all()) + ->toBe(collect([$authored->id, $viaCompany->id, $both->id])->sort()->values()->all()); +}); + +test('excludes releases the user cannot access', function () { + $user = User::factory()->create(); + $stranger = PressRelease::factory()->create(); + + $page = PressRelease::accessiblePageFor($user); + + expect($page->total())->toBe(0) + ->and($page->contains($stranger))->toBeFalse(); +}); + +test('applies the constrain closure to both union branches', function () { + $user = User::factory()->create(); + PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Published->value]); + PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]); + + $page = PressRelease::accessiblePageFor( + $user, + fn ($query) => $query->where('status', PressReleaseStatus::Published->value), + ); + + expect($page->total())->toBe(1) + ->and($page->first()->status)->toBe(PressReleaseStatus::Published); +}); + +test('respects sort column and direction', function () { + $user = User::factory()->create(); + $a = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Alpha']); + $z = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Zulu']); + + $asc = PressRelease::accessiblePageFor($user, null, 'title', 'asc'); + $desc = PressRelease::accessiblePageFor($user, null, 'title', 'desc'); + + expect($asc->first()->id)->toBe($a->id) + ->and($desc->first()->id)->toBe($z->id); +}); + +test('paginates with the given page size', function () { + $user = User::factory()->create(); + PressRelease::factory()->count(7)->create(['user_id' => $user->id]); + + $page = PressRelease::accessiblePageFor($user, null, 'created_at', 'desc', 5); + + expect($page->total())->toBe(7) + ->and($page->perPage())->toBe(5) + ->and($page->count())->toBe(5); +}); + +test('falls back to created_at for an invalid sort column', function () { + $user = User::factory()->create(); + PressRelease::factory()->create(['user_id' => $user->id]); + + $page = PressRelease::accessiblePageFor($user, null, 'title; drop table', 'sideways'); + + expect($page->total())->toBe(1); +});