presseportale/app/Services/Web/ReleaseDetailPageData.php
Kevin Adametz 46b89e1d23 images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)
- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung)
- deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1)
- press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard)
- Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien
- ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite
- Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:53:49 +00:00

144 lines
5.6 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();
defer(static fn () => PressRelease::recordDeferredView($releaseId));
// Crawler-Abruf-Protokoll (Roadmap 1c.0): nur bekannte Such- und
// KI-Dienste, erkannt am User-Agent, ohne IP.
$userAgent = (string) request()->userAgent();
$crawler = CrawlerIdentifier::identify($userAgent);
if ($crawler !== null) {
defer(static fn () => PressRelease::recordDeferredCrawlerHit($releaseId, $crawler, $userAgent));
}
// 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,
];
}
}