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)->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) { $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); return; } $this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki'); 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); 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. */ private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted): void { $pressRelease->update(['manual_review_required_at' => now()]); $this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki'); $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; } $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'], ); } // 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. try { app(CanonicalAssigner::class)->assignFor($pressRelease); } catch (\Throwable $e) { report($e); } $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]); // 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'); } /** * 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. */ public function depublish(PressRelease $pressRelease, ?string $syncLockReason = null): void { $this->assertStatus($pressRelease, [PressReleaseStatus::Published]); $previous = $pressRelease->status; $pressRelease->update([ 'status' => PressReleaseStatus::Archived->value, 'depublished_at' => now(), ]); $pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders'); WebCaches::forgetPublicPages(); $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'); } if ($previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published) { WebCaches::forgetPublicPages(); } }); $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, ]); WebCaches::forgetPublicPages(); return; } $pressRelease->delete(); } /** * 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)'); 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() ->withoutGlobalScopes() ->whereNotNull('contacts.email') ->where('contacts.email', '!=', '') ->orderBy('contacts.id') ->first(); $company = Company::withoutGlobalScopes()->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); } }