Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal — rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout (SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre, Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent (50 %) verhindert die verdeckte Selbst-Depublizierung. Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D, neuer abbestellbarer Typ press_release_corrected). Außerdem: - Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert (Lücke im bezahlten Webhook-Pfad geschlossen) - Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis; Store auf report/dsgvo/personal_rights gehärtet - Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt des Web-Formulars - Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung): Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt → Firmendaten - Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel, Wochenlimit-Hinweis in Text und Bremsen-Meldung Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.5 KiB
PHP
126 lines
4.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Web;
|
|
|
|
use App\Models\Contact;
|
|
use App\Models\PressRelease;
|
|
use App\Scopes\PortalScope;
|
|
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->orderByDesc('is_preview')->orderBy('sort_order'),
|
|
'contacts',
|
|
])
|
|
->first();
|
|
|
|
if (! $release) {
|
|
// T12-Tombstone: depublizierte PMs (kostenpflichtige Entfernung
|
|
// auf Wunsch des Absenders) melden 410 statt 404 — die URL gab
|
|
// es, der Inhalt wurde bewusst entfernt.
|
|
$depublished = PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->whereNotNull('depublished_at')
|
|
->where('slug', $slug)
|
|
->exists();
|
|
|
|
if ($depublished) {
|
|
return ['tombstone' => true];
|
|
}
|
|
|
|
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()
|
|
->withoutGlobalScopes()
|
|
->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));
|
|
|
|
// 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->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,
|
|
];
|
|
}
|
|
}
|