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); } }