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.) $pressRelease->update([ 'status' => PressReleaseStatus::Review->value, 'withdrawal_waiver_consented_at' => now(), ]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, 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); // KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand // des Ergebnisses übernimmt der Job über routeByClassification(). ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification'); // Content-Score parallel berechnen (Qualität, ohne Statuswirkung). ScorePressRelease::dispatch($pressRelease->id)->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 * oder Ablehnung – die Entscheidung bleibt beim regulären Workflow/Admin. */ public function reclassifyIfClassified(PressRelease $pressRelease): void { if ($pressRelease->classification === null) { return; } ClassifyPressRelease::dispatch($pressRelease->id, route: false)->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)->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. */ public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void { if ($pressRelease->status !== PressReleaseStatus::Review) { return; } if ($classification === PressReleaseClassification::Red) { $this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki'); return; } $this->autoPublishApproved($pressRelease); } /** * 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; } $delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0); $publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null; $this->publish($pressRelease, 'ki', $publishedAtOverride); } public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void { $blacklistViolation = 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; 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 ['reason' => $reason, 'word' => $word]; } $this->consumePublishSlot($locked); $locked->update([ 'status' => PressReleaseStatus::Published->value, 'published_at' => $this->resolvePublishedAt($locked, $publishedAtOverride), ]); $this->logStatusChange($locked, $previous, PressReleaseStatus::Published, null, $source); return null; }); $pressRelease->refresh(); if ($blacklistViolation !== null) { $this->notifyAuthor($pressRelease, 'rejected', $blacklistViolation['reason']); throw new BlacklistViolationException( $blacklistViolation['reason'], $blacklistViolation['word'], ); } $this->notifyAuthor($pressRelease, 'published'); } /** * 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 && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) { $user->increment('press_release_quota_used_this_month'); 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; } public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void { DB::transaction(function () use ($pressRelease, $reason, $source): void { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) ->lockForUpdate() ->firstOrFail(); $this->assertStatus($locked, [PressReleaseStatus::Review]); $previous = $locked->status; $locked->update(['status' => PressReleaseStatus::Rejected->value]); $this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, $source); }); $pressRelease->refresh(); $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; $pressRelease->update(['status' => PressReleaseStatus::Archived->value]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin'); } /** * 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). */ public function depublish(PressRelease $pressRelease): void { $this->assertStatus($pressRelease, [PressReleaseStatus::Published]); $previous = $pressRelease->status; $pressRelease->update([ 'status' => PressReleaseStatus::Archived->value, 'depublished_at' => now(), ]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication'); } public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void { DB::transaction(function () use ($pressRelease, $status, $reason): void { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) ->lockForUpdate() ->firstOrFail(); $previous = $locked->status; if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) { $this->consumePublishSlot($locked); } $locked->update([ 'status' => $status->value, 'published_at' => $status === PressReleaseStatus::Published ? ($locked->published_at ?? now()) : $locked->published_at, ]); if ($previous !== $status) { $this->logStatusChange($locked, $previous, $status, $reason, 'admin'); } }); $pressRelease->refresh(); } public function deleteFromAdmin(PressRelease $pressRelease): void { if ($pressRelease->status === PressReleaseStatus::Published) { $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, ]); return; } $pressRelease->delete(); } /** * @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): 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; $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). $recipient = $pressRelease->contacts()->withoutGlobalScopes()->whereNotNull('email')->value('email') ?? Company::withoutGlobalScopes()->find($pressRelease->company_id)?->email ?: $user->email; } // 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. $notificationType = match ($event) { 'published' => EmailNotificationType::PressReleasePublished, 'rejected' => EmailNotificationType::PressReleaseRejected, default => null, }; if (! $notificationType) { return; } if ($recipient === $user->email && ! $user->wantsEmailNotification($notificationType)) { return; } $mailable = match ($event) { 'published' => new PressReleasePublished($user, $pressRelease), 'rejected' => new PressReleaseRejected($user, $pressRelease, $reason), default => null, }; if ($mailable) { Mail::to($recipient)->queue($mailable); } } }