presseportale/app/Services/Web/ReleaseDetailPageData.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

147 lines
5.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Web;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\CrawlerIdentifier;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use function Illuminate\Support\defer;
/**
* Datenaufbereitung der Detailseite einer Pressemitteilung je Ausgabe inkl.
* Hit-Zähler und verwandten Meldungen (Firma, Kategorie). Unbekannter oder nicht
* öffentlich sichtbarer Slug → 404.
*/
class ReleaseDetailPageData
{
/**
* @return array<string, mixed>
*/
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,
];
}
}