presseportale/app/Services/PressRelease/PressReleaseService.php
Kevin Adametz 7cd84b2be3 archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- 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>
2026-10-02 13:09:01 +00:00

1177 lines
49 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\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);
}
}