diff --git a/database/migrations/2026_07_20_120000_add_feed_order_index_to_press_releases_table.php b/database/migrations/2026_07_20_120000_add_feed_order_index_to_press_releases_table.php new file mode 100644 index 00000000..656635c5 --- /dev/null +++ b/database/migrations/2026_07_20_120000_add_feed_order_index_to_press_releases_table.php @@ -0,0 +1,43 @@ +hasIndex('press_releases', 'press_releases_feed_order_idx')) { + $table->index( + ['language', 'status', 'deleted_at', 'published_at'], + 'press_releases_feed_order_idx' + ); + } + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table): void { + if ($this->hasIndex('press_releases', 'press_releases_feed_order_idx')) { + $table->dropIndex('press_releases_feed_order_idx'); + } + }); + } + + private function hasIndex(string $table, string $indexName): bool + { + return in_array($indexName, array_column(Schema::getIndexes($table), 'name'), true); + } +}; diff --git a/resources/views/livewire/web/press-release-feed.blade.php b/resources/views/livewire/web/press-release-feed.blade.php index 2e6add98..de7e5618 100644 --- a/resources/views/livewire/web/press-release-feed.blade.php +++ b/resources/views/livewire/web/press-release-feed.blade.php @@ -4,8 +4,10 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Support\EditorialClock; +use App\Support\WebPageCache; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Carbon; use Livewire\Attributes\Locked; use Livewire\Volt\Component; @@ -15,6 +17,11 @@ new class extends Component { use WithPagination; + private const TIMEFRAMES = ['all', 'today', 'week']; + + /** Nur die vorderen Seiten cachen (Schutz vor unbegrenztem Key-Raum). */ + private const CACHED_PAGES = 3; + public string $timeframe = 'all'; #[Locked] @@ -35,21 +42,71 @@ new class extends Component public function with(): array { + $releases = $this->pressReleases(); + return [ - 'releases' => $this->pressReleases(), - 'totalCount' => $this->totalCount(), + 'releases' => $releases, + 'totalCount' => $releases->total(), ]; } + /** + * Der Feed rendert auf jeder Startseiten-Impression → die vorderen Seiten + * werden stale-while-revalidate gecacht (WebPageCache, wie die übrigen + * Seitendaten). Der Paginator wird pro Request neu gebaut, damit die + * Pagination-Links den Host des aktuellen Requests tragen und nicht den + * des Cache-Erzeugers. + */ private function pressReleases(): LengthAwarePaginator { - $reference = $this->referenceNow(); + $page = max(1, (int) Paginator::resolveCurrentPage('page')); + $timeframe = $this->normalizedTimeframe(); - return $this->baseQuery() - ->when($this->timeframe === 'today', fn (Builder $query) => $query->whereBetween('published_at', [$reference->copy()->startOfDay(), $reference->copy()->endOfDay()])) - ->when($this->timeframe === 'week', fn (Builder $query) => $query->where('published_at', '>=', $reference->copy()->subDays(7))) - ->orderByDesc('published_at') - ->paginate(8); + $build = fn (): array => $this->feedPayload($timeframe, $page); + + $payload = $page <= self::CACHED_PAGES + ? WebPageCache::remember( + WebPageCache::key('feed', [$this->portalValues()[0], $this->language, $timeframe, 'page', $page]), + $build, + ) + : $build(); + + return new LengthAwarePaginator( + $payload['items'], + $payload['total'], + 8, + $page, + ['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'], + ); + } + + /** + * @return array{items: \Illuminate\Database\Eloquent\Collection, total: int} + */ + private function feedPayload(string $timeframe, int $page): array + { + $query = $this->baseQuery(); + + if ($timeframe !== 'all') { + $reference = $this->referenceNow(); + $query + ->when($timeframe === 'today', fn (Builder $query) => $query->whereBetween('published_at', [$reference->copy()->startOfDay(), $reference->copy()->endOfDay()])) + ->when($timeframe === 'week', fn (Builder $query) => $query->where('published_at', '>=', $reference->copy()->subDays(7))); + } + + return [ + 'items' => (clone $query)->orderByDesc('published_at')->forPage($page, 8)->get(), + 'total' => (clone $query)->count(), + ]; + } + + /** + * Der timeframe kommt aus der URL (queryString) — unbekannte Werte fallen + * auf "all" zurück, damit sie weder Queries noch Cache-Keys erreichen. + */ + private function normalizedTimeframe(): string + { + return in_array($this->timeframe, self::TIMEFRAMES, true) ? $this->timeframe : 'all'; } private function referenceNow(): Carbon @@ -57,11 +114,6 @@ new class extends Component return EditorialClock::reference($this->portalValues(), $this->language); } - private function totalCount(): int - { - return $this->baseQuery()->count(); - } - private function baseQuery(): Builder { return PressRelease::query() diff --git a/tests/Feature/Web/PageDataCacheTest.php b/tests/Feature/Web/PageDataCacheTest.php index 9e2ef5a5..53da457e 100644 --- a/tests/Feature/Web/PageDataCacheTest.php +++ b/tests/Feature/Web/PageDataCacheTest.php @@ -55,8 +55,8 @@ test('homepage data is cached per portal and language', function () { ]); // Innerhalb der TTL liefert HomePageData den gecachten Stand: Lead bleibt - // die alte Meldung. (Der Livewire-Feed ist bewusst ungecacht und zeigt - // neue Meldungen sofort.) + // die alte Meldung. (Auch der Livewire-Feed cached seine vorderen Seiten + // über WebPageCache — neue Meldungen erscheinen nach Ablauf der Frische-TTL.) $cachedLead = $this->get('https://presseecho.test/de') ->assertSuccessful() ->viewData('leadRelease'); @@ -99,6 +99,47 @@ test('web page cache serves stale data instantly instead of rebuilding in the re expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-2'); }); +test('the press release feed caches its front pages but not deep pages or junk timeframes', function () { + /** @var TestCase $this */ + $category = makeCategoryWithSlug(Portal::Presseecho, 'Feed-Rubrik', 'feed-rubrik'); + $company = Company::factory()->presseecho()->create(); + + PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create([ + 'title' => 'Feed-Meldung eins', + 'slug' => 'feed-meldung-eins', + 'published_at' => now()->subHour(), + ]); + + $this->get('https://presseecho.test/de') + ->assertSuccessful() + ->assertSeeText('Feed-Meldung eins'); + + expect(Cache::has('web.feed.presseecho.de.all.page.1'))->toBeTrue(); + + // Tiefe Seiten bleiben ungecacht (Schutz vor unbegrenztem Key-Raum). + $this->get('https://presseecho.test/de?page=25')->assertSuccessful(); + + expect(Cache::has('web.feed.presseecho.de.all.page.25'))->toBeFalse(); + + // Unbekannte timeframe-Werte aus der URL fallen auf "all" zurück und + // erzeugen keinen eigenen (poisonbaren) Cache-Key. + $this->get('https://presseecho.test/de?timeframe=boesewert')->assertSuccessful(); + + expect(Cache::has('web.feed.presseecho.de.boesewert.page.1'))->toBeFalse(); + + // Der Cache hält nur items + total — der Paginator (inkl. Host-URLs der + // Pagination-Links) wird pro Request neu gebaut, damit der gecachte Feed + // nicht den Host des Cache-Erzeugers an andere Besucher ausliefert. + expect(Cache::get('web.feed.presseecho.de.all.page.1')) + ->toBeArray() + ->toHaveKeys(['items', 'total']); +}); + test('category page data is cached per portal, language and slug but 404s are not cached', function () { /** @var TestCase $this */ $category = makeCategoryWithSlug(Portal::Businessportal24, 'Mobilität', 'mobilitaet');