- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue), Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel FAILURE; archive_pending_at nur noch Sammelmarker - O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre - O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder offener Auftrag, nie ein still unvollständiger Nachweis - A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore, archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut - O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab - A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold- Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export - 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock), N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für auslieferbare Bilder), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1177 lines
49 KiB
PHP
1177 lines
49 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\ActivityType;
|
||
use App\Enums\ArchiveEvent;
|
||
use App\Enums\ArchiveReasonCategory;
|
||
use App\Enums\EmailNotificationType;
|
||
use App\Enums\PressReleaseClassification;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Enums\SinglePurchaseStatus;
|
||
use App\Jobs\ClassifyPressRelease;
|
||
use App\Jobs\ScorePressRelease;
|
||
use App\Mail\PressReleaseHeldForReview;
|
||
use App\Mail\PressReleasePublished;
|
||
use App\Mail\PressReleaseRejected;
|
||
use App\Models\AdminPreset;
|
||
use App\Models\ArchivePendingJob;
|
||
use App\Models\Company;
|
||
use App\Models\KiAudit;
|
||
use App\Models\LegalRequest;
|
||
use App\Models\PressRelease;
|
||
use App\Models\PressReleaseAiDeclaration;
|
||
use App\Models\PressReleaseImage;
|
||
use App\Models\PressReleaseStatusLog;
|
||
use App\Models\User;
|
||
use App\Scopes\PortalScope;
|
||
use App\Services\ActivityLog;
|
||
use App\Services\Admin\AdminPerformanceCache;
|
||
use App\Services\Archive\ArchivePendingQueue;
|
||
use App\Services\Archive\PressReleaseArchiver;
|
||
use App\Services\Mail\EmailSuppressionService;
|
||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||
use App\Services\PressRelease\Images\PublishedImageSnapshot;
|
||
use App\Support\WebCaches;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
|
||
/**
|
||
* Kapselt Statusübergänge für Pressemitteilungen inkl. Benachrichtigungs-Mails.
|
||
*
|
||
* Jede Methode verändert exklusiv den Status und versendet optional eine Mail
|
||
* an den Autor (User). Die Caller-Schicht (Volt, Command, API) muss nur noch
|
||
* diese Methoden aufrufen – keine Mail-Logik außerhalb dieser Klasse.
|
||
*/
|
||
class PressReleaseService
|
||
{
|
||
public function __construct(
|
||
private readonly BlacklistService $blacklist,
|
||
private readonly AdminPerformanceCache $adminCache,
|
||
private readonly ActivityLog $activityLog,
|
||
private readonly EmailSuppressionService $suppressions,
|
||
) {}
|
||
|
||
/**
|
||
* @param string $source Einlieferungsweg fuers Status-Log ('customer'
|
||
* = Panel, 'api' = Legacy-/v1-API) — steuert u. a.
|
||
* das Mail-Routing (API → Pressekontakt/Firma).
|
||
*/
|
||
public function submitForReview(PressRelease $pressRelease, string $source = 'customer'): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
|
||
|
||
$user = $pressRelease->user;
|
||
|
||
// Submit-Gate (Decision-Update §5.1): Einreichen erfordert eine aktive
|
||
// Buchung. Bis zum Tarif-Modul steuert billing.enforce_booking den Stub.
|
||
if ($user && ! $user->hasActiveBooking()) {
|
||
throw new BookingRequiredException;
|
||
}
|
||
|
||
// Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht
|
||
// (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn
|
||
// noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne
|
||
// verfügbares Kontingent automatisch veröffentlicht. Null bedeutet
|
||
// unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf).
|
||
$quotaRemaining = $user?->pressReleaseQuotaRemaining();
|
||
|
||
if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) {
|
||
throw new QuotaExceededException;
|
||
}
|
||
|
||
// KI-Kennzeichnung (Art. 50 KI-VO): ohne vollständige Deklaration
|
||
// kein Submit — die Editoren validieren das bereits im Modal, dieser
|
||
// Guard sichert alle Aufrufpfade (API, ältere Entwürfe) ab.
|
||
$this->assertAiDeclarationComplete($pressRelease);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
|
||
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
|
||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||
|
||
throw new BlacklistViolationException($reason, $word);
|
||
}
|
||
|
||
// Archiv-Hinweis (AGB § 13 Abs. 1, T2neu): Das Einreichungs-Modal
|
||
// verlangt die Bestätigung der dauerhaften Archivierung — der
|
||
// Zeitpunkt wird hier zur Nachweisbarkeit protokolliert. (Spalte
|
||
// heißt historisch withdrawal_waiver_consented_at; die frühere
|
||
// Erlöschens-Checkbox entfiel mit der B2B-Umstellung, E2.)
|
||
// Begrenzter Prüfkreislauf (E3, 16.09.2026): Ist die Automatik für
|
||
// diese Meldung erschöpft, geht die Freigabe ohne KI-Lauf direkt an
|
||
// einen Menschen. Sonst zählt dieser Lauf.
|
||
$manual = $pressRelease->requiresManualReview();
|
||
|
||
$pressRelease->update([
|
||
'status' => PressReleaseStatus::Review->value,
|
||
'withdrawal_waiver_consented_at' => now(),
|
||
'auto_check_attempts' => $manual ? $pressRelease->auto_check_attempts : $pressRelease->auto_check_attempts + 1,
|
||
]);
|
||
$this->logStatusChange(
|
||
$pressRelease,
|
||
$previous,
|
||
PressReleaseStatus::Review,
|
||
$manual ? 'Erneute Freigabe nach erschöpfter automatischer Prüfung — wartet auf manuelle Entscheidung.' : null,
|
||
$source,
|
||
);
|
||
|
||
// Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt
|
||
// einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung —
|
||
// "Kunde hat am Datum X Option Y deklariert und bestätigt").
|
||
$this->logAiDeclaration($pressRelease);
|
||
|
||
if (! $manual) {
|
||
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing
|
||
// anhand des Ergebnisses übernimmt der Job über
|
||
// routeByClassification().
|
||
ClassifyPressRelease::dispatch($pressRelease->id, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
||
}
|
||
|
||
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
|
||
ScorePressRelease::dispatch($pressRelease->id, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
||
}
|
||
|
||
/**
|
||
* Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal
|
||
* klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert").
|
||
*
|
||
* Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden
|
||
* aktualisiert, der Status bleibt unverändert. So führt das bloße
|
||
* Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung
|
||
* – die Entscheidung bleibt beim regulären Workflow/Admin.
|
||
*
|
||
* Eine Ausnahme (Audit U03): Wird der Re-Check Rot und die PM wartet
|
||
* noch in `review` (etwa eine terminierte Grün-PM, die vor dem Termin
|
||
* bearbeitet wurde), lehnt der Job sie wie bei der Erstprüfung ab –
|
||
* inkl. Autorenmail –, statt sie still als rote terminierte Meldung
|
||
* liegen zu lassen. Siehe ClassifyPressRelease.
|
||
*/
|
||
public function reclassifyIfClassified(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->classification === null) {
|
||
return;
|
||
}
|
||
|
||
ClassifyPressRelease::dispatch($pressRelease->id, route: false, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
||
}
|
||
|
||
/**
|
||
* Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits
|
||
* einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet").
|
||
*/
|
||
public function rescoreIfScored(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->content_score === null) {
|
||
return;
|
||
}
|
||
|
||
ScorePressRelease::dispatch($pressRelease->id, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
||
}
|
||
|
||
/**
|
||
* Nach einer inhaltlichen Änderung (Titel/Text) einer bereits geprüften
|
||
* bzw. bewerteten PM erneut prüfen (Re-Check ohne Routing) und neu
|
||
* bewerten. Operiert auf dem frischen Modellstand. Single Source of Truth
|
||
* für die vier PM-Editoren.
|
||
*/
|
||
public function reprocessAfterContentChange(PressRelease $pressRelease): void
|
||
{
|
||
$fresh = $pressRelease->fresh();
|
||
|
||
if ($fresh === null) {
|
||
return;
|
||
}
|
||
|
||
$this->reclassifyIfClassified($fresh);
|
||
$this->rescoreIfScored($fresh);
|
||
}
|
||
|
||
/**
|
||
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
|
||
* (Decision-Update §5.0, Entscheidung 12.06.2026). Wird vom
|
||
* ClassifyPressRelease-Job aufgerufen.
|
||
*
|
||
* - Rot → Ablehnung mit Begründung an den Autor
|
||
* - Gelb/Grün → automatische Veröffentlichung (sofort bzw. zum Termin);
|
||
* Gelb bleibt als interne Markierung erhalten (nicht
|
||
* boostbar, Admin-Signal), löst aber keine manuelle
|
||
* Prüfung aus
|
||
*
|
||
* Greift nur, solange die PM noch im Status `review` steht; manuelle
|
||
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
|
||
*
|
||
* `$expectedHash` ist die Prüfsumme des klassifizierten Inhalts (Audit
|
||
* N04): Rot/Hold werden nur für genau diese Fassung wirksam; hat ein
|
||
* Editor inzwischen korrigiert, greift unter der Sperre die gleiche
|
||
* Regel wie beim Publish (StaleClassificationException, Re-Check).
|
||
*
|
||
* @throws StaleClassificationException
|
||
*/
|
||
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null, ?string $expectedHash = null): void
|
||
{
|
||
if ($pressRelease->status !== PressReleaseStatus::Review) {
|
||
return;
|
||
}
|
||
|
||
if ($classification === PressReleaseClassification::Red) {
|
||
$max = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
|
||
|
||
// E3: Nach dem letzten erlaubten Rot nicht noch einmal ablehnen,
|
||
// sondern geblockt in die Admin-Queue — ab hier entscheidet ein
|
||
// Mensch, und die Automatik ist für diese Meldung aus.
|
||
if ($pressRelease->auto_check_attempts >= $max) {
|
||
$this->holdForManualReview($pressRelease, sprintf(
|
||
'Automatische Prüfung erschöpft (%d von %d): %s',
|
||
$pressRelease->auto_check_attempts,
|
||
$max,
|
||
$reason ?: 'Rot ohne Begründung.',
|
||
), automationExhausted: true, expectedHash: $expectedHash);
|
||
|
||
return;
|
||
}
|
||
|
||
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki', $expectedHash);
|
||
|
||
return;
|
||
}
|
||
|
||
// Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für
|
||
// die manuelle Queue ist bewusst eingeschaltet.
|
||
if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) {
|
||
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false, expectedHash: $expectedHash);
|
||
|
||
return;
|
||
}
|
||
|
||
$this->autoPublishApproved($pressRelease);
|
||
}
|
||
|
||
/**
|
||
* Meldung bleibt in `review`, wird als „Prüfung nötig" markiert und
|
||
* wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch. Der Autor
|
||
* bekommt eine kurze Mail „ein Mensch prüft jetzt" — ohne sie sähe er
|
||
* nach dem letzten Rot nur Stillstand statt einer Ablehnung.
|
||
*
|
||
* Unter Zeilensperre (Audit N04): Nur wenn die PM noch in `review` steht
|
||
* und – bei übergebener Prüfsumme – noch den geprüften Inhalt trägt.
|
||
*
|
||
* @throws StaleClassificationException
|
||
*/
|
||
private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted, ?string $expectedHash = null): void
|
||
{
|
||
/** @var array{stale?: string, skipped?: true}|null $outcome */
|
||
$outcome = DB::transaction(function () use ($pressRelease, $reason, $expectedHash): ?array {
|
||
$locked = PressRelease::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereKey($pressRelease->getKey())
|
||
->lockForUpdate()
|
||
->firstOrFail();
|
||
|
||
if ($locked->status !== PressReleaseStatus::Review) {
|
||
return ['skipped' => true];
|
||
}
|
||
|
||
if ($this->classificationIsStale($locked, $expectedHash)) {
|
||
return ['stale' => (string) $locked->content_hash];
|
||
}
|
||
|
||
$locked->update(['manual_review_required_at' => now()]);
|
||
$this->logStatusChange($locked, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
|
||
|
||
return null;
|
||
});
|
||
|
||
$pressRelease->refresh();
|
||
|
||
if (isset($outcome['stale'])) {
|
||
$this->rescheduleForCurrentContent($pressRelease, $expectedHash, $outcome['stale']);
|
||
}
|
||
|
||
if ($outcome !== null) {
|
||
return;
|
||
}
|
||
|
||
$this->activityLog->record(
|
||
ActivityType::PressReleaseStatus,
|
||
sprintf('PM #%d wartet auf manuelle Prüfung: %s', $pressRelease->id, mb_substr($reason, 0, 160)),
|
||
subject: $pressRelease,
|
||
actorLabel: 'ki',
|
||
);
|
||
|
||
$this->notifyAuthor($pressRelease, 'held', automationExhausted: $automationExhausted);
|
||
}
|
||
|
||
/**
|
||
* Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch.
|
||
*
|
||
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
|
||
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
|
||
* publiziert – optional mit einem Sicherheitsfenster
|
||
* (scoring.classification.green_delay_minutes).
|
||
*/
|
||
private function autoPublishApproved(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
|
||
return;
|
||
}
|
||
|
||
// Schneller Filter (Audit K-01): Eine Meldung, die auf einen Menschen
|
||
// wartet, bleibt in der Admin-Queue. Verbindlich prüft publish()
|
||
// unter der Zeilensperre.
|
||
if ($pressRelease->requiresManualReview()) {
|
||
return;
|
||
}
|
||
|
||
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
|
||
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
|
||
|
||
$this->publish($pressRelease, 'ki', $publishedAtOverride);
|
||
}
|
||
|
||
/**
|
||
* Quellen, die ohne menschliche Entscheidung veröffentlichen. Nur sie
|
||
* prüfen unter der Sperre, ob die freigebende Klassifikation noch zum
|
||
* Inhalt gehört (Audit U02); `admin` ist die bewusste Übersteuerung.
|
||
*/
|
||
private const AUTOMATIC_PUBLISH_SOURCES = ['ki', 'scheduler'];
|
||
|
||
/**
|
||
* @throws BlacklistViolationException Unzulässiges Wort — die PM wurde
|
||
* abgelehnt, der Autor benachrichtigt.
|
||
* @throws ManualReviewRequiredException Nur bei automatischen Quellen:
|
||
* Die Meldung wartet auf eine
|
||
* menschliche Entscheidung (E3,
|
||
* Audit K-01). Nichts geändert.
|
||
* @throws StaleClassificationException Nur bei automatischen Quellen
|
||
* (`ki`, `scheduler`): Die jüngste
|
||
* gültige Klassifikation gehört zu
|
||
* einer anderen Inhaltsversion als
|
||
* der gesperrten. Nichts geändert;
|
||
* eine Prüfung mit Routing für den
|
||
* aktuellen Stand ist eingeplant.
|
||
*/
|
||
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
|
||
{
|
||
/** @var array{blacklist?: array{reason: string, word: string}, stale?: array{analyzed: string|null, current: string}}|null $outcome */
|
||
$outcome = DB::transaction(function () use ($pressRelease, $source, $publishedAtOverride): ?array {
|
||
$locked = PressRelease::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereKey($pressRelease->getKey())
|
||
->lockForUpdate()
|
||
->firstOrFail();
|
||
|
||
$this->assertStatus($locked, [PressReleaseStatus::Review]);
|
||
$previous = $locked->status;
|
||
|
||
// Inhaltsbindung unter der Sperre (Audit U02): Der Vergleich im
|
||
// Job bzw. Scheduler liegt VOR der Sperre – hier zählt, was jetzt
|
||
// gesperrt ist. Innerhalb der Transaktion nichts ändern.
|
||
if (in_array($source, self::AUTOMATIC_PUBLISH_SOURCES, true)) {
|
||
// Wartet die Meldung auf einen Menschen (E3), gibt keine
|
||
// Automatik sie frei — auch kein spätes Grün nach einer
|
||
// Bearbeitung (Audit K-01). Nichts geändert.
|
||
if ($locked->requiresManualReview()) {
|
||
throw new ManualReviewRequiredException((int) $locked->getKey());
|
||
}
|
||
|
||
$analyzedHash = $this->latestClassificationHash($locked);
|
||
|
||
if ($this->classificationIsStale($locked, $analyzedHash)) {
|
||
return ['stale' => ['analyzed' => $analyzedHash, 'current' => (string) $locked->content_hash]];
|
||
}
|
||
}
|
||
|
||
if ($word = $this->blacklist->findInPressRelease($locked)) {
|
||
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
|
||
|
||
$locked->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
$this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
|
||
|
||
return ['blacklist' => ['reason' => $reason, 'word' => $word]];
|
||
}
|
||
|
||
// Veröffentlichungsgate (Bildkennzeichnung E.1): kein Bild aus
|
||
// Formular v2 ohne gültige Bestätigung zur aktuellen Datei.
|
||
$this->assertImageRightsConfirmed($locked);
|
||
|
||
$this->consumePublishSlot($locked);
|
||
|
||
$locked->update([
|
||
'status' => PressReleaseStatus::Published->value,
|
||
'published_at' => $this->resolvePublishedAt($locked, $publishedAtOverride),
|
||
'published_ever_at' => $locked->published_ever_at ?? now(),
|
||
]);
|
||
|
||
$this->markImagesPublished($locked);
|
||
|
||
$this->logStatusChange($locked, $previous, PressReleaseStatus::Published, null, $source);
|
||
|
||
return null;
|
||
});
|
||
|
||
$pressRelease->refresh();
|
||
|
||
if ($outcome === null) {
|
||
$this->capturePublishedImages($pressRelease);
|
||
}
|
||
|
||
if (isset($outcome['stale'])) {
|
||
$this->rescheduleForCurrentContent($pressRelease, $outcome['stale']['analyzed'], $outcome['stale']['current']);
|
||
}
|
||
|
||
if (isset($outcome['blacklist'])) {
|
||
$this->notifyAuthor($pressRelease, 'rejected', $outcome['blacklist']['reason']);
|
||
|
||
throw new BlacklistViolationException(
|
||
$outcome['blacklist']['reason'],
|
||
$outcome['blacklist']['word'],
|
||
);
|
||
}
|
||
|
||
$this->assignCanonical($pressRelease);
|
||
|
||
$this->notifyAuthor($pressRelease, 'published');
|
||
}
|
||
|
||
/**
|
||
* Doppelveröffentlichung (Anhang 2, 1c.8): liegt derselbe Text der Firma
|
||
* schon auf dem anderen Portal, bestimmt jetzt das Portal die Erstquelle.
|
||
* Nebensache — darf die Veröffentlichung nie brechen.
|
||
*/
|
||
private function assignCanonical(PressRelease $pressRelease): void
|
||
{
|
||
try {
|
||
app(CanonicalAssigner::class)->assignFor($pressRelease);
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gegenstück zu assignCanonical(): Verschwindet eine Erstquelle aus der
|
||
* öffentlichen Ansicht, bekommen ihre Kopien den Verweis nicht als Leiche
|
||
* mit — sie werden neu zugeordnet oder selbst Erstquelle (Sitemap).
|
||
*/
|
||
private function releaseCanonicalCopies(PressRelease $pressRelease): void
|
||
{
|
||
try {
|
||
app(CanonicalAssigner::class)->reassignCopiesOf($pressRelease);
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Prüfsumme der jüngsten gültigen (nicht verworfenen) Klassifikation.
|
||
* Null, wenn es keine gibt oder sie aus der Zeit vor der Bindung stammt.
|
||
*/
|
||
private function latestClassificationHash(PressRelease $pressRelease): ?string
|
||
{
|
||
return KiAudit::query()
|
||
->where('press_release_id', $pressRelease->id)
|
||
->where('type', KiAudit::TYPE_CLASSIFICATION)
|
||
->whereNull('discarded_at')
|
||
->orderByDesc('id')
|
||
->value('content_hash');
|
||
}
|
||
|
||
/**
|
||
* Gleiche Übergangsregel wie im Scheduler: Altbestand ohne Prüfsumme —
|
||
* auf der PM oder im Audit (auch: noch gar kein Audit) — gilt weiter.
|
||
* Nur eine vorhandene, abweichende Summe blockiert.
|
||
*/
|
||
private function classificationIsStale(PressRelease $pressRelease, ?string $analyzedHash): bool
|
||
{
|
||
return $analyzedHash !== null
|
||
&& $pressRelease->content_hash !== null
|
||
&& $analyzedHash !== $pressRelease->content_hash;
|
||
}
|
||
|
||
/**
|
||
* Gemeinsamer Ausgang von publish(), reject() und Hold, wenn die
|
||
* Klassifikation unter der Sperre nicht mehr zum Inhalt passt (U02, N04):
|
||
* Prüfung mit Routing für den aktuellen Stand einplanen und dem Aufrufer
|
||
* die Abweichung melden. Nichts an der PM geändert.
|
||
*
|
||
* @throws StaleClassificationException
|
||
*/
|
||
private function rescheduleForCurrentContent(PressRelease $pressRelease, ?string $analyzedHash, string $currentHash): never
|
||
{
|
||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $currentHash)->onQueue('classification');
|
||
|
||
throw new StaleClassificationException($pressRelease->id, $analyzedHash, $currentHash);
|
||
}
|
||
|
||
/**
|
||
* Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers
|
||
* (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte
|
||
* PMs kosten nichts). Erneutes Publizieren — etwa nach Archivierung —
|
||
* zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem
|
||
* Schreiben des neuen Status-Logs aufgerufen werden.
|
||
*
|
||
* Verbrauchsreihenfolge: zuerst das Plan-Monatskontingent (Zähler
|
||
* `press_release_quota_used_this_month`), danach der älteste bezahlte
|
||
* Einzel-/Extra-PM-Kauf (wird mit der PM verknüpft und eingelöst).
|
||
* Unbegrenzte User (Bestandsschutz) verbrauchen nichts.
|
||
*/
|
||
private function consumePublishSlot(PressRelease $pressRelease): void
|
||
{
|
||
$alreadyPublishedOnce = PressReleaseStatusLog::query()
|
||
->where('press_release_id', $pressRelease->id)
|
||
->where('to_status', PressReleaseStatus::Published->value)
|
||
->exists();
|
||
|
||
if ($alreadyPublishedOnce) {
|
||
return;
|
||
}
|
||
|
||
$user = User::query()
|
||
->whereKey($pressRelease->user_id)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if (! $user || $user->hasUnlimitedPressReleaseQuota()) {
|
||
return;
|
||
}
|
||
|
||
$plan = $user->currentPlan();
|
||
|
||
if ($plan && $user->pressReleaseQuotaUsedThisMonth() < $plan->press_release_quota) {
|
||
// Periodenwechsel atomar unter der Zeilensperre: ein explizit
|
||
// ABWEICHENDER Monat → Zähler beginnt bei 1, sonst erhöhen
|
||
// (Audit F18). NULL (Altbestand vor der Periodenbindung) zählt
|
||
// wie der laufende Monat — dieselbe Lesart wie
|
||
// User::pressReleaseQuotaUsedThisMonth() (Audit U04).
|
||
$period = User::currentQuotaPeriod();
|
||
$continuesPeriod = $user->press_release_quota_period === null
|
||
|| $user->press_release_quota_period === $period;
|
||
|
||
$user->forceFill([
|
||
'press_release_quota_used_this_month' => $continuesPeriod
|
||
? (int) $user->press_release_quota_used_this_month + 1
|
||
: 1,
|
||
'press_release_quota_period' => $period,
|
||
])->save();
|
||
|
||
return;
|
||
}
|
||
|
||
$purchase = $user->singlePurchases()
|
||
->grantingSubmission()
|
||
->oldest('paid_at')
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if ($purchase === null) {
|
||
throw new QuotaExceededException;
|
||
}
|
||
|
||
$purchase->update([
|
||
'status' => SinglePurchaseStatus::Consumed->value,
|
||
'consumed_at' => now(),
|
||
'press_release_id' => $pressRelease->id,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Bestimmt das wirksame `published_at` einer PM.
|
||
*
|
||
* Reihenfolge:
|
||
* 1. Bereits gesetztes `published_at` bleibt erhalten (z.B. Re-Publish)
|
||
* 2. `scheduled_at` (geplanter Veröffentlichungstermin) hat Vorrang vor "jetzt"
|
||
* 3. `embargo_at` (Sperrfrist) verschiebt zusätzlich nach hinten — egal ob
|
||
* Scheduled vorhanden ist oder nicht
|
||
* 4. Fallback: now()
|
||
*
|
||
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
||
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
||
* öffentlichen Listing.
|
||
*
|
||
* `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das
|
||
* Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt
|
||
* ist – ein geplanter Termin hat stets Vorrang.
|
||
*/
|
||
private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon
|
||
{
|
||
if ($pressRelease->published_at) {
|
||
return $pressRelease->published_at;
|
||
}
|
||
|
||
$base = $pressRelease->scheduled_at ?: ($override ?? now());
|
||
|
||
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
||
return $pressRelease->embargo_at;
|
||
}
|
||
|
||
return $base;
|
||
}
|
||
|
||
/**
|
||
* @param string|null $expectedHash Prüfsumme des Inhalts, für den die
|
||
* Ablehnung gilt (KI-Rot, Audit N04).
|
||
* Trägt die gesperrte Zeile eine
|
||
* andere, wird nicht abgelehnt,
|
||
* sondern neu geprüft. Admin-Ablehnung
|
||
* übergibt nichts.
|
||
*
|
||
* @throws StaleClassificationException
|
||
*/
|
||
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin', ?string $expectedHash = null): void
|
||
{
|
||
/** @var string|null $staleCurrentHash */
|
||
$staleCurrentHash = DB::transaction(function () use ($pressRelease, $reason, $source, $expectedHash): ?string {
|
||
$locked = PressRelease::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereKey($pressRelease->getKey())
|
||
->lockForUpdate()
|
||
->firstOrFail();
|
||
|
||
$this->assertStatus($locked, [PressReleaseStatus::Review]);
|
||
$previous = $locked->status;
|
||
|
||
if ($this->classificationIsStale($locked, $expectedHash)) {
|
||
return (string) $locked->content_hash;
|
||
}
|
||
|
||
$locked->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
$this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, $source);
|
||
|
||
return null;
|
||
});
|
||
|
||
$pressRelease->refresh();
|
||
|
||
if ($staleCurrentHash !== null) {
|
||
$this->rescheduleForCurrentContent($pressRelease, $expectedHash, $staleCurrentHash);
|
||
}
|
||
|
||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||
}
|
||
|
||
public function backToDraft(PressRelease $pressRelease): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review, PressReleaseStatus::Rejected]);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Draft->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Draft, null, 'admin');
|
||
}
|
||
|
||
public function archive(PressRelease $pressRelease): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
// Status und Archivauftrag (mit dem Stand zum Zeitpunkt der
|
||
// Depublikation, Audit A-04) in einer Transaktion.
|
||
$job = DB::transaction(function () use ($pressRelease): ArchivePendingJob {
|
||
$pressRelease->update(['status' => PressReleaseStatus::Archived->value]);
|
||
|
||
return $this->queueDepublication($pressRelease, ArchiveReasonCategory::OwnDecision);
|
||
});
|
||
|
||
// Kopien dieser Erstquelle neu bewerten wie bei den anderen
|
||
// Depublikationspfaden (Audit N19-Rest).
|
||
$this->releaseCanonicalCopies($pressRelease);
|
||
|
||
// Sichtbarkeitsregel (Roadmap 1b.1/1b.5): Ein Newsroom ist öffentlich
|
||
// nur mit mindestens einer veröffentlichten Meldung. Fällt die letzte
|
||
// weg, muss auch seine Seite sofort weg — nicht erst nach Ablauf des
|
||
// Seiten-Caches.
|
||
WebCaches::forgetPublicPages();
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
|
||
$this->archiveDepublished($pressRelease, $job, ArchiveReasonCategory::OwnDecision);
|
||
}
|
||
|
||
/**
|
||
* Depublikation (T12): kostenpflichtige Entfernung aus der öffentlichen
|
||
* Ansicht auf Wunsch des Absenders. Die PM wechselt auf "archived" und
|
||
* erhält den Depublikations-Marker — die öffentliche Detail-URL zeigt
|
||
* fortan einen Tombstone (410).
|
||
*/
|
||
/**
|
||
* Depubliziert eine veröffentlichte PM (Tombstone bleibt öffentlich).
|
||
*
|
||
* Setzt IMMER die Re-Sync-Sperre — sonst würde der nächste Importer-Lauf
|
||
* die Entfernung überschreiben (Meldeverfahren §7). Aufrufer können über
|
||
* $syncLockReason einen spezifischeren Grund (z. B. die Vorgangsnummer)
|
||
* ins Sperr-Protokoll geben. Kommt die Depublikation aus dem
|
||
* Meldeverfahren, trägt der Archiveintrag den Vorgang (5.1 d).
|
||
*/
|
||
public function depublish(PressRelease $pressRelease, ?string $syncLockReason = null, ?LegalRequest $legalRequest = null): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
|
||
|
||
$previous = $pressRelease->status;
|
||
$reason = $syncLockReason !== null || $legalRequest !== null ? ArchiveReasonCategory::ReportProcedure : ArchiveReasonCategory::CustomerRequest;
|
||
|
||
$job = DB::transaction(function () use ($pressRelease, $reason, $syncLockReason, $legalRequest): ArchivePendingJob {
|
||
$pressRelease->update([
|
||
'status' => PressReleaseStatus::Archived->value,
|
||
'depublished_at' => now(),
|
||
]);
|
||
|
||
return $this->queueDepublication($pressRelease, $reason, $syncLockReason, $legalRequest);
|
||
});
|
||
$pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders');
|
||
$this->releaseCanonicalCopies($pressRelease);
|
||
WebCaches::forgetPublicPages();
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication');
|
||
$this->archiveDepublished($pressRelease, $job, $reason, $syncLockReason, $legalRequest);
|
||
}
|
||
|
||
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
|
||
{
|
||
$previous = $pressRelease->status;
|
||
$job = null;
|
||
|
||
DB::transaction(function () use ($pressRelease, $status, $reason, &$job): void {
|
||
$locked = PressRelease::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereKey($pressRelease->getKey())
|
||
->lockForUpdate()
|
||
->firstOrFail();
|
||
$previous = $locked->status;
|
||
|
||
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
|
||
$this->assertImageRightsConfirmed($locked);
|
||
$this->consumePublishSlot($locked);
|
||
}
|
||
|
||
$locked->update([
|
||
'status' => $status->value,
|
||
'published_at' => $status === PressReleaseStatus::Published
|
||
? ($locked->published_at ?? now())
|
||
: $locked->published_at,
|
||
'published_ever_at' => $status === PressReleaseStatus::Published
|
||
? ($locked->published_ever_at ?? now())
|
||
: $locked->published_ever_at,
|
||
]);
|
||
|
||
// Archivauftrag in derselben Transaktion (N11, Audit A-02/A-04).
|
||
if ($previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published) {
|
||
$job = $this->queueDepublication($locked, ArchiveReasonCategory::OwnDecision, $reason);
|
||
}
|
||
|
||
if ($status === PressReleaseStatus::Published) {
|
||
$this->markImagesPublished($locked);
|
||
}
|
||
|
||
if ($previous !== $status) {
|
||
$this->logStatusChange($locked, $previous, $status, $reason, 'admin');
|
||
}
|
||
|
||
if ($previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published) {
|
||
WebCaches::forgetPublicPages();
|
||
}
|
||
});
|
||
|
||
$pressRelease->refresh();
|
||
|
||
if ($status === PressReleaseStatus::Published) {
|
||
$this->capturePublishedImages($pressRelease);
|
||
|
||
if ($previous !== PressReleaseStatus::Published) {
|
||
$this->assignCanonical($pressRelease);
|
||
}
|
||
} elseif ($previous === PressReleaseStatus::Published) {
|
||
$this->releaseCanonicalCopies($pressRelease);
|
||
}
|
||
|
||
if ($job instanceof ArchivePendingJob) {
|
||
$this->archiveDepublished($pressRelease, $job, ArchiveReasonCategory::OwnDecision, $reason);
|
||
}
|
||
}
|
||
|
||
public function deleteFromAdmin(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->status === PressReleaseStatus::Published) {
|
||
// Letzter veröffentlichter Stand VOR dem Überschreiben ins Archiv.
|
||
// Bewusst blockierend: ohne Archiveintrag kein Überschreiben.
|
||
app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, Auth::user(), 'Entfernung durch den Admin');
|
||
|
||
$pressRelease->update([
|
||
'status' => PressReleaseStatus::Archived->value,
|
||
'text' => AdminPreset::activeValue(
|
||
AdminPreset::PRESS_RELEASE_DELETED_PUBLISHED_TEXT,
|
||
"Diese Pressemitteilung wurde entfernt.\n\nDer Inhalt ist nicht mehr verfuegbar."
|
||
),
|
||
'keywords' => null,
|
||
'backlink_url' => null,
|
||
'no_export' => true,
|
||
]);
|
||
$this->releaseCanonicalCopies($pressRelease);
|
||
WebCaches::forgetPublicPages();
|
||
|
||
return;
|
||
}
|
||
|
||
// Nie veröffentlicht: Bilder samt Original und Bestätigung sofort
|
||
// weg (Bildkennzeichnung E.3, Decision-Update E4).
|
||
$remover = app(PressReleaseImageRemover::class);
|
||
|
||
foreach ($pressRelease->images()->get() as $image) {
|
||
$remover->remove($image);
|
||
}
|
||
|
||
$pressRelease->delete();
|
||
}
|
||
|
||
/**
|
||
* Beweisarchiv (Block F.4): Sobald eine Meldung das öffentliche Angebot
|
||
* verlässt, entsteht in der fachlichen Transaktion ein Archivauftrag mit
|
||
* dem Stand zu diesem Zeitpunkt (Umsetzungsaudit 29.09.2026, A-02/A-04).
|
||
* Nur deleteFromAdmin() archiviert direkt und bleibt blockierend.
|
||
*/
|
||
private function queueDepublication(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?string $note = null, ?LegalRequest $legalRequest = null): ArchivePendingJob
|
||
{
|
||
return app(ArchivePendingQueue::class)->push(
|
||
(int) $pressRelease->getKey(),
|
||
ArchiveEvent::PressReleaseDepublished,
|
||
$pressRelease,
|
||
$reason,
|
||
$legalRequest?->getKey(),
|
||
Auth::id() !== null ? (int) Auth::id() : null,
|
||
$note,
|
||
['state' => app(PressReleaseArchiver::class)->depublicationState($pressRelease)],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Nach dem Commit den Eintrag aus dem festgehaltenen Stand schreiben.
|
||
* Scheitert die Ablage, bleibt der Auftrag offen und
|
||
* `archive:retry-pending` holt ihn nach (N11); der Vorgang selbst läuft
|
||
* weiter.
|
||
*/
|
||
private function archiveDepublished(PressRelease $pressRelease, ArchivePendingJob $job, ArchiveReasonCategory $reason, ?string $note = null, ?LegalRequest $legalRequest = null): void
|
||
{
|
||
$state = (array) ($job->payload['state'] ?? []);
|
||
|
||
app(ArchivePendingQueue::class)->completeWith(
|
||
$job,
|
||
fn () => app(PressReleaseArchiver::class)->recordDepublished($pressRelease, $state, $reason, Auth::user(), $note, $legalRequest),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Veröffentlichungsgate (E.1): Bilder aus Formular v2 brauchen eine
|
||
* Bestätigung zur aktuellen Datei — sonst wartet ein Admin-Upload noch
|
||
* auf den Kunden (E10). Bestandsbilder sind nicht betroffen.
|
||
*/
|
||
private function assertImageRightsConfirmed(PressRelease $pressRelease): void
|
||
{
|
||
$unconfirmed = $pressRelease->images()
|
||
->whereNotNull('rules_version')
|
||
->with('latestRightsConfirmation')
|
||
->get()
|
||
->first(fn (PressReleaseImage $image): bool => ! $image->hasValidRightsConfirmation());
|
||
|
||
if ($unconfirmed !== null) {
|
||
throw new ImageRightsConfirmationRequiredException;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Nur öffentlich auslieferbare Bilder gelten als veröffentlicht
|
||
* (Umsetzungsaudit 29.09., N09): ein zurückgehaltenes Bild war nie
|
||
* öffentlich und wird bei späterer Freigabe mit dem Snapshot markiert.
|
||
*/
|
||
private function markImagesPublished(PressRelease $pressRelease): void
|
||
{
|
||
$pressRelease->images()->publiclyVisible()->whereNull('published_ever_at')->update(['published_ever_at' => now()]);
|
||
}
|
||
|
||
/**
|
||
* Snapshot der veröffentlichten Bildfassung nach dem Commit (E.2).
|
||
* Nebensache nach dem N08-Muster: Ein Fehler wird gemeldet, Canonical
|
||
* und Autorenmail laufen weiter (Umsetzungsaudit 29.09., N09).
|
||
*/
|
||
private function capturePublishedImages(PressRelease $pressRelease): void
|
||
{
|
||
try {
|
||
app(PublishedImageSnapshot::class)->captureForPressRelease($pressRelease);
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Entfernung mit 410 Gone: die Mitteilung verschwindet aus allen
|
||
* öffentlichen Listen, ihre Detail-URL liefert aber weiterhin einen
|
||
* Tombstone statt eines 404.
|
||
*
|
||
* Bewusste Abgrenzung zu deleteFromAdmin(): dort wird der Inhalt durch den
|
||
* Ersatztext überschrieben bzw. die PM soft-gelöscht. Hier bleibt der
|
||
* Datensatz unverändert erhalten, damit die Entfernung über
|
||
* restoreFromGone() umkehrbar und im Audit nachvollziehbar bleibt.
|
||
*
|
||
* Die Re-Sync-Sperre ist Pflicht — ohne sie würde der nächste
|
||
* Importer-Lauf die Entfernung wieder überschreiben.
|
||
*
|
||
* $flushPublicPages steuert den Auslieferungs-Cache. Der Flush verwirft
|
||
* ALLE Seiten-Caches auf einmal (siehe WebPageCache::flush) und ist damit
|
||
* genau einmal je Vorgang richtig — wer die Methode in einer Schleife
|
||
* aufruft, schaltet ihn ab und flusht selbst am Ende. Sonst zwingt jede
|
||
* einzelne Mitteilung die gesamte öffentliche Seite in den Neuaufbau.
|
||
*/
|
||
public function markGone(PressRelease $pressRelease, ?string $reason = null, bool $flushPublicPages = true): void
|
||
{
|
||
if ($pressRelease->gone_at !== null) {
|
||
return;
|
||
}
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
$pressRelease->update([
|
||
'status' => PressReleaseStatus::Archived->value,
|
||
'gone_at' => now(),
|
||
// Vorzustand sichern: `no_export` kann auch redaktionell oder vom
|
||
// Kunden gesetzt sein und ist in beiden Detailansichten sichtbar.
|
||
// Ohne diesen Merker stünde die Mitteilung nach dem Aufheben
|
||
// dauerhaft als „kein Export“ da, ohne dass das je jemand gewählt
|
||
// hätte.
|
||
'no_export_before_gone' => (bool) $pressRelease->no_export,
|
||
'no_export' => true,
|
||
]);
|
||
|
||
$pressRelease->lockSync($reason ?? 'Entfernung mit 410 (Tombstone)');
|
||
$this->releaseCanonicalCopies($pressRelease);
|
||
|
||
if ($flushPublicPages) {
|
||
WebCaches::forgetPublicPages();
|
||
}
|
||
|
||
$this->logStatusChange(
|
||
$pressRelease,
|
||
$previous,
|
||
PressReleaseStatus::Archived,
|
||
$reason ?? 'Aus der öffentlichen Ansicht entfernt (410 Gone)',
|
||
'gone',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Hebt eine 410-Entfernung auf. Der Status bleibt bewusst „archived“ —
|
||
* ob die Mitteilung wieder veröffentlicht wird, entscheidet die Redaktion
|
||
* anschließend über den regulären Statuswechsel.
|
||
*
|
||
* `no_export` geht dagegen auf den Stand VOR der Entfernung zurück: Die
|
||
* Sperre gehört zur Entfernung, nicht zur Mitteilung. Fehlt der Merker
|
||
* (Entfernung aus der Zeit vor dieser Spalte), bleibt der aktuelle Wert
|
||
* stehen — raten wäre schlechter als nichts zu tun.
|
||
*
|
||
* Zu $flushPublicPages siehe markGone().
|
||
*/
|
||
public function restoreFromGone(PressRelease $pressRelease, bool $flushPublicPages = true): void
|
||
{
|
||
if ($pressRelease->gone_at === null) {
|
||
return;
|
||
}
|
||
|
||
$pressRelease->update([
|
||
'gone_at' => null,
|
||
'no_export' => $pressRelease->no_export_before_gone ?? $pressRelease->no_export,
|
||
'no_export_before_gone' => null,
|
||
]);
|
||
|
||
if ($flushPublicPages) {
|
||
WebCaches::forgetPublicPages();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param PressReleaseStatus[] $allowed
|
||
*/
|
||
private function assertStatus(PressRelease $pressRelease, array $allowed): void
|
||
{
|
||
if (! in_array($pressRelease->status, $allowed, true)) {
|
||
$allowedValues = implode(', ', array_map(fn ($s) => $s->value, $allowed));
|
||
$currentStatus = $pressRelease->status instanceof PressReleaseStatus
|
||
? $pressRelease->status->value
|
||
: (string) $pressRelease->status;
|
||
|
||
throw new \LogicException(
|
||
"Statusübergang nicht erlaubt. Aktueller Status: {$currentStatus}, erwartet: {$allowedValues}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* KI-Kennzeichnung vollständig? Pflicht sind der deklarierte KI-Grad des
|
||
* Textes, die verbindliche Bestätigung sowie eine Kennzeichnung für
|
||
* jedes Bild (Art. 50 KI-VO, Decision-Update 08.07.2026).
|
||
*/
|
||
private function assertAiDeclarationComplete(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) {
|
||
throw new AiDeclarationRequiredException;
|
||
}
|
||
|
||
if ($pressRelease->images()->whereNull('ai_declaration')->exists()) {
|
||
throw new AiDeclarationRequiredException(
|
||
__('Bitte für jedes Bild die KI-Kennzeichnung angeben (Originalfoto, KI-generiert oder KI-bearbeitet) — im Bild-Manager der Bearbeiten-Seite.'),
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schreibt den unveränderlichen Audit-Eintrag der aktuellen Deklaration —
|
||
* inkl. Bild-Snapshot zum Submit-Zeitpunkt.
|
||
*/
|
||
private function logAiDeclaration(PressRelease $pressRelease): void
|
||
{
|
||
$imageDeclarations = $pressRelease->images()
|
||
->orderBy('sort_order')
|
||
->get(['id', 'path', 'ai_declaration'])
|
||
->map(fn (PressReleaseImage $image): array => [
|
||
'image_id' => $image->id,
|
||
'path' => $image->path,
|
||
'declaration' => $image->ai_declaration?->value,
|
||
])
|
||
->values()
|
||
->all();
|
||
|
||
PressReleaseAiDeclaration::query()->create([
|
||
'press_release_id' => $pressRelease->id,
|
||
'user_id' => $pressRelease->user_id,
|
||
'company_id' => $pressRelease->company_id,
|
||
'text_declaration' => $pressRelease->ai_text_declaration?->value,
|
||
'image_declarations' => $imageDeclarations ?: null,
|
||
'confirmed_at' => $pressRelease->ai_declaration_confirmed_at,
|
||
'created_at' => now(),
|
||
]);
|
||
}
|
||
|
||
private function logStatusChange(
|
||
PressRelease $pressRelease,
|
||
?PressReleaseStatus $from,
|
||
PressReleaseStatus $to,
|
||
?string $reason,
|
||
string $source,
|
||
): void {
|
||
PressReleaseStatusLog::query()->create([
|
||
'press_release_id' => $pressRelease->id,
|
||
'changed_by_user_id' => Auth::id(),
|
||
'from_status' => $from?->value,
|
||
'to_status' => $to->value,
|
||
'reason' => $reason,
|
||
'source' => $source,
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
// Aktivitäts-Verlauf: ohne eingeloggten Akteur (KI-Jobs, Scheduler)
|
||
// erscheint der Wechsel als „System".
|
||
$actor = Auth::user();
|
||
$actor = $actor instanceof User ? $actor : null;
|
||
|
||
$this->activityLog->record(
|
||
ActivityType::PressReleaseStatus,
|
||
sprintf(
|
||
'PM „%s“: %s → %s%s',
|
||
$pressRelease->title,
|
||
$from?->label() ?? 'Neu',
|
||
$to->label(),
|
||
$reason ? " — {$reason}" : '',
|
||
),
|
||
user: $actor,
|
||
subject: $pressRelease,
|
||
actorLabel: $actor?->name ?? 'System',
|
||
meta: [
|
||
'from' => $from?->value,
|
||
'to' => $to->value,
|
||
'source' => $source,
|
||
],
|
||
);
|
||
|
||
$this->adminCache->forgetPressReleaseStats();
|
||
}
|
||
|
||
private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null, bool $automationExhausted = false): void
|
||
{
|
||
/** @var User|null $user */
|
||
$user = $pressRelease->user;
|
||
|
||
if (! $user || ! $user->email) {
|
||
return;
|
||
}
|
||
|
||
// API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung
|
||
// 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die
|
||
// Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. Marker
|
||
// ist die Quelle des letzten Review-Uebergangs im Status-Log — der
|
||
// Kontotyp ist nicht verlaesslich (connektar ist z. B. 'agency').
|
||
$recipient = $user->email;
|
||
$recipientName = $user->name;
|
||
$isApiSubmitter = $pressRelease->statusLogs()
|
||
->where('to_status', PressReleaseStatus::Review->value)
|
||
->orderByDesc('id')
|
||
->value('source') === 'api';
|
||
|
||
if ($isApiSubmitter) {
|
||
// Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/
|
||
// Kontexten ohne passenden Portal-Host (PortalScope). Anrede und
|
||
// Adresse gehören zusammen — sobald die Mail an Pressekontakt
|
||
// oder Firma geht, darf nie der Name des API-Kontos gegrüßt
|
||
// werden. Ohne hinterlegten Namen bleibt $recipientName leer,
|
||
// das Template grüßt dann neutral.
|
||
$contact = $pressRelease->contacts()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereNotNull('contacts.email')
|
||
->where('contacts.email', '!=', '')
|
||
->orderBy('contacts.id')
|
||
->first();
|
||
|
||
$company = Company::withoutGlobalScope(PortalScope::class)->find($pressRelease->company_id);
|
||
$companyName = trim((string) $company?->name);
|
||
|
||
// Gesperrte Adressen (Bounce-Suppression, 1b.6) überspringen:
|
||
// Kontakt → Firmen-E-Mail → sonst gar nicht. Nie zurück an den
|
||
// API-Einlieferer — der soll nicht zugespammt werden.
|
||
if ($contact && ! $this->suppressions->isSuppressed($contact->email)) {
|
||
$recipient = $contact->email;
|
||
$recipientName = trim($contact->first_name.' '.$contact->last_name) ?: $companyName;
|
||
} elseif ($company && filled($company->email) && ! $this->suppressions->isSuppressed($company->email)) {
|
||
$recipient = $company->email;
|
||
$recipientName = $companyName;
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar
|
||
// (One-Click-Unsubscribe aus der Mail bzw. Präferenzen im Profil).
|
||
// Greift nur, wenn der Einlieferer selbst der Empfänger ist —
|
||
// Pressekontakt/Firma haben kein Konto mit Präferenzen. Der
|
||
// Zwischenstand „ein Mensch prüft" hängt am Typ „abgelehnt".
|
||
$notificationType = match ($event) {
|
||
'published' => EmailNotificationType::PressReleasePublished,
|
||
'rejected', 'held' => EmailNotificationType::PressReleaseRejected,
|
||
default => null,
|
||
};
|
||
|
||
if (! $notificationType) {
|
||
return;
|
||
}
|
||
|
||
if ($recipient === $user->email && ! $user->wantsEmailNotification($notificationType)) {
|
||
return;
|
||
}
|
||
|
||
$mailable = match ($event) {
|
||
'published' => new PressReleasePublished($user, $pressRelease, $recipientName, $recipient, $isApiSubmitter),
|
||
'held' => new PressReleaseHeldForReview($user, $pressRelease, $automationExhausted, $recipientName, $recipient),
|
||
default => new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient),
|
||
};
|
||
|
||
Mail::to($recipient)->queue($mailable);
|
||
}
|
||
}
|