*/ public function build(WebPortalContext $context, string $slug): array { // Seitennummer exakt so auflösen wie der Paginator (Paginator:: // resolveCurrentPage validiert und fällt bei ungültigen Werten auf 1 // zurück). Sonst landet Seite-1-Inhalt unter einem anderen Seiten-Key // (Cache-Poisoning über z. B. ?page=2.5). $page = Paginator::resolveCurrentPage(); // Bewusst VOR dem Seiten-Cache: eine Entfernung (410), Löschung oder // Deaktivierung muss sofort greifen, auch wenn für den Slug noch ein // bis zu eine Stunde alter Stale-Eintrag im Cache liegt. Kostet einen // indizierten Slug-Lookup pro Aufruf — vertretbar, weil Entfernungen // rechtlich motiviert sind. $visibility = $this->publicVisibility($context, $slug); if ($visibility?->gone_at !== null) { return ['tombstone' => 'removed']; } // Gelöscht (Soft Delete) oder deaktiviert: nie aus dem Cache bedienen. // Der Neuaufbau liefert 404 — oder die Weiterleitung, falls die Firma // in einer Zusammenführung aufgegangen ist (F13). if ($visibility === null || ! $visibility->is_active) { return $this->buildUncached($context, $slug); } $cacheKey = WebPageCache::key('newsroom', [ implode('-', $context->portalValues), $context->language, $slug, 'page', $page, ]); return WebPageCache::remember( $cacheKey, fn (): array => $this->buildUncached($context, $slug), ); } /** * Sichtbarkeitsstatus der Firma hinter dem Slug (nur die dafür nötigen * Spalten). Null bei unbekanntem Slug und bei vollständig gelöschten * Firmen (Soft Delete) — deren URL antwortet weiterhin mit 404, ein * gesetztes `gone_at` dagegen mit 410. */ private function publicVisibility(WebPortalContext $context, string $slug): ?Company { return Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $context->portalValues) ->where('slug', $slug) ->first(['id', 'is_active', 'gone_at']); } /** * Slug einer Firma, die in einer Zusammenführung (Roadmap 1a.3) in einer * anderen aufgegangen ist → Slug des Überlebenden, sofern der abweicht. * Die alte URL ist öffentlich und verlinkt; sie führt per 301 weiter statt * ins Leere. Bei gleichem Slug kommt dieser Zweig gar nicht vor — dann hat * die Auflösung oben den Überlebenden (`both`) schon gefunden. * * @param list $portalValues */ private function mergedInto(array $portalValues, string $slug): ?string { $survivor = $this->mergedNewsrooms->survivorFor($portalValues, $slug); if ($survivor === null || ! $survivor->is_active) { return null; } return $survivor->slug !== $slug ? $survivor->slug : null; } /** * @return array */ private function buildUncached(WebPortalContext $context, string $slug): array { $portalValues = $context->portalValues; $language = $context->language; $company = Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $portalValues) ->where('is_active', true) ->where('slug', $slug) ->with(['contacts']) ->first(); if (! $company) { $target = $this->mergedInto($portalValues, $slug); if ($target !== null) { return ['redirect' => $target]; } abort(404); } $base = static fn (): Builder => PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->where('company_id', $company->id) ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); // Keine öffentliche Newsroom-Seite ohne veröffentlichte Meldung. if (! $base()->exists()) { abort(404); } $latest = $base()->max('published_at'); $referenceNow = $latest ? Carbon::parse($latest) : now(); if ($referenceNow->gt(now())) { $referenceNow = now(); } $recentSince = $referenceNow->copy()->subDays(30); $oldest = $base()->min('published_at'); $with = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1), ]; $leadRelease = $base()->with($with)->orderByDesc('published_at')->first(); // Nur den Seiten-Parameter an die Pagination-Links hängen (kein // ->withQueryString()): sonst backt der erste Besucher seine beliebigen // Query-Parameter (utm_* o. Ä.) in die für alle gecachten Links. $releases = $base() ->with($with) ->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey())) ->orderByDesc('published_at') ->paginate(10); $mostReadReleases = $base() ->orderByDesc('hits') ->orderByDesc('published_at') ->limit(5) ->get(['id', 'slug', 'title', 'hits', 'portal', 'language']); $companyFilter = static function (Builder $query) use ($company, $portalValues, $language): void { $query ->withoutGlobalScope(PortalScope::class) ->where('company_id', $company->id) ->forPortalValues($portalValues) ->forLanguage($language) ->published(); }; $categories = Category::query() ->whereIn('portal', $portalValues) ->whereHas('pressReleases', fn (Builder $query) => $companyFilter($query)) ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->withCount(['pressReleases as releases_count' => fn (Builder $query) => $companyFilter($query)]) ->orderByDesc('releases_count') ->limit(8) ->get() ->map(function (Category $category): ?array { $translation = $category->translations->first(); if (! $translation) { return null; } return [ 'name' => $translation->name, 'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'), 'count' => (int) ($category->releases_count ?? 0), ]; }) ->filter() ->values(); return [ 'company' => $company, 'referenceNow' => $referenceNow, 'leadRelease' => $leadRelease, 'releases' => $releases, 'mostReadReleases' => $mostReadReleases, 'categories' => $categories, 'newsroomStats' => [ 'total' => $base()->count(), 'recent' => $base()->where('published_at', '>=', $recentSince)->count(), 'today' => $base()->whereBetween('published_at', [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()])->count(), 'totalHits' => (int) $base()->sum('hits'), 'sinceYear' => $oldest ? (int) Carbon::parse($oldest)->format('Y') : null, ], ]; } }