presseportale/app/Services/PressRelease/PressReleaseService.php
Kevin Adametz c8dc99c3c8 Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub
- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
  und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
  Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
  Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
  bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
  Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
  PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
  Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
  (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
  CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
  Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E , Checkliste,
  STATUS-ABGLEICH, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:10:32 +00:00

394 lines
15 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\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
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) {}
public function submitForReview(PressRelease $pressRelease): 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;
}
$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');
// 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
* (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
{
$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);
}
$this->consumePublishSlot($pressRelease);
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
$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 = $pressRelease->user;
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;
}
$user->singlePurchases()
->grantingSubmission()
->oldest('paid_at')
->first()
?->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
{
$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;
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->consumePublishSlot($pressRelease);
}
$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);
}
}
}