*/ public function build(WebPortalContext $context, string $slug): array { $portalValues = $context->portalValues; $language = $context->language; $publishedScope = static fn (Builder $query): Builder => $query ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); $release = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap($publishedScope) ->where('slug', $slug) ->with([ 'company', 'user:id,agency_name', 'category.translations' => fn ($query) => $query->where('locale', $language), 'category.parent.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order'), 'contacts', ]) // Doppelveröffentlichung mit gleichem Slug: dieselbe Wahl wie // ReportedUrlResolver – die Erstquelle gewinnt (Audit 7.5). ->orderByRaw('canonical_press_release_id is null desc') ->orderBy('id') ->first(); if (! $release) { // Tombstone statt 404: die URL gab es, der Inhalt wurde bewusst // entfernt. Zwei Anlässe mit eigenem Seitentext — // `depublished_at` = kostenpflichtige Depublikation auf Wunsch des // Absenders (T12), `gone_at` = redaktionelle/rechtliche Entfernung // aus dem Admin. Vollständig gelöschte PMs bleiben bei 404. $removed = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($portalValues) ->forLanguage($language) ->where('slug', $slug) ->where(fn (Builder $query) => $query ->whereNotNull('depublished_at') ->orWhereNotNull('gone_at')) ->first(['depublished_at', 'gone_at']); if ($removed) { return ['tombstone' => $removed->depublished_at !== null ? 'depublished' : 'removed']; } abort(404); } // Legacy-Importe kennen fast keine PM↔Kontakt-Verknüpfung — im // Altsystem hingen Pressekontakte an der FIRMA. Ohne eigenen // PM-Kontakt zeigt die Seite deshalb den Firmen-Kontakt (wie das // Altsystem), statt den Block ganz wegzulassen. if ($release->contacts->isEmpty() && $release->company_id) { $companyContact = Contact::query() ->withoutGlobalScope(PortalScope::class) ->where('company_id', $release->company_id) ->orderBy('id') ->first(); if ($companyContact) { $release->setRelation('contacts', $release->contacts->push($companyContact)); } } $releaseId = $release->getKey(); // Crawler-Abruf-Protokoll (Roadmap 1c.0): nur bekannte Such- und // KI-Dienste, erkannt am User-Agent, ohne IP. Ein erkannter Crawler // ist kein Aufruf (Umsetzungsaudit 29.09.2026, R-07) — er landet nur // im Crawler-Protokoll, nicht im Aufrufzähler. $userAgent = (string) request()->userAgent(); $crawler = CrawlerIdentifier::identify($userAgent); if ($crawler !== null) { defer(static fn () => PressRelease::recordDeferredCrawlerHit($releaseId, $crawler, $userAgent)); } else { defer(static fn () => PressRelease::recordDeferredView($releaseId)); // Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren // Schreibzugriffe erfolgen nach dem Senden der Response. $release->hits = ($release->hits ?? 0) + 1; } $with = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1), ]; $companyReleases = $release->company_id ? PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap($publishedScope) ->where('company_id', $release->company_id) ->whereKeyNot($release->getKey()) ->with($with) ->orderByDesc('published_at') ->limit(3) ->get() : collect(); $relatedReleases = $release->category_id ? PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap($publishedScope) ->where('category_id', $release->category_id) ->whereKeyNot($release->getKey()) ->when($companyReleases->isNotEmpty(), fn (Builder $query) => $query->whereNotIn('id', $companyReleases->modelKeys())) ->with($with) ->orderByDesc('published_at') ->limit(3) ->get() : collect(); return [ 'release' => $release, 'companyReleases' => $companyReleases, 'relatedReleases' => $relatedReleases, ]; } }