assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]); $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); } $pressRelease->update(['status' => PressReleaseStatus::Review->value]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer'); // Quota-Stub: zählt den Monatsverbrauch des Autors hoch. Wird vom // echten Tarif-Modul später abgelöst (Schnittstelle bleibt stabil). $pressRelease->user?->increment('press_release_quota_used_this_month'); // 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'); } /** * Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses * (Konzept §15.1). Wird vom ClassifyPressRelease-Job aufgerufen. * * - Rot → Ablehnung mit Begründung an den Autor * - Gelb → bleibt in der manuellen Review-Queue * - Grün → automatische Veröffentlichung (sofort bzw. zum geplanten Termin) * * 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; } if ($classification === PressReleaseClassification::Green) { $this->autoPublishGreen($pressRelease); } // Gelb: keine Aktion – bleibt zur manuellen Prüfung im Status „review". } /** * Veröffentlicht eine 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 autoPublishGreen(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 { $this->assertStatus($pressRelease, [PressReleaseStatus::Review]); $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); } $pressRelease->update([ 'status' => PressReleaseStatus::Published->value, 'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride), ]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source); $this->notifyAuthor($pressRelease, 'published'); } /** * 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 { $this->assertStatus($pressRelease, [PressReleaseStatus::Review]); $previous = $pressRelease->status; $pressRelease->update(['status' => PressReleaseStatus::Rejected->value]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source); $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'); } public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void { $previous = $pressRelease->status; $pressRelease->update([ 'status' => $status->value, 'published_at' => $status === PressReleaseStatus::Published ? ($pressRelease->published_at ?? now()) : $pressRelease->published_at, ]); if ($previous !== $status) { $this->logStatusChange($pressRelease, $previous, $status, $reason, 'admin'); } } 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}" ); } } 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(), ]); Cache::forget(AdminPerformanceCache::PressReleaseStats); Cache::forget(AdminPerformanceCache::PressReleaseReviewCount); } private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void { $user = $pressRelease->user; if (! $user || ! $user->email) { return; } $mailable = match ($event) { 'published' => new PressReleasePublished($user, $pressRelease), 'rejected' => new PressReleaseRejected($user, $pressRelease, $reason), default => null, }; if ($mailable) { Mail::to($user->email)->queue($mailable); } } }