diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php index fccdc6a..26480ac 100644 --- a/app/Console/Commands/PublishScheduledPressReleases.php +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Services\PressRelease\BlacklistViolationException; @@ -11,11 +12,13 @@ use Illuminate\Support\Str; use Throwable; /** - * Veröffentlicht Pressemitteilungen mit Status `review` und einem - * `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde. + * Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation + * `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde. * * Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent: - * berührt nur PRs in Review-Status — bereits publishte werden ignoriert. + * berührt nur grüne PRs in Review-Status — bereits publishte werden ignoriert. + * Gelb eingestufte PMs bleiben bewusst in der manuellen Admin-Queue, auch wenn + * ihr Termin fällig ist. * * Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit * Mail-Benachrichtigung des Autors. @@ -43,6 +46,7 @@ class PublishScheduledPressReleases extends Command $candidates = PressRelease::withoutGlobalScopes() ->where('status', PressReleaseStatus::Review->value) + ->where('classification', PressReleaseClassification::Green->value) ->whereNotNull('scheduled_at') ->where('scheduled_at', '<=', $now) ->orderBy('scheduled_at') diff --git a/app/Console/Commands/ResetMonthlyPressReleaseQuota.php b/app/Console/Commands/ResetMonthlyPressReleaseQuota.php new file mode 100644 index 0000000..452e4c6 --- /dev/null +++ b/app/Console/Commands/ResetMonthlyPressReleaseQuota.php @@ -0,0 +1,36 @@ +where('press_release_quota_used_this_month', '>', 0) + ->update(['press_release_quota_used_this_month' => 0]); + + $this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RunClassificationQueue.php b/app/Console/Commands/RunClassificationQueue.php new file mode 100644 index 0000000..f4c0c91 --- /dev/null +++ b/app/Console/Commands/RunClassificationQueue.php @@ -0,0 +1,35 @@ + 'classification', + '--tries' => 3, + ]; + + if ($this->option('once')) { + $options['--once'] = true; + } else { + $options['--stop-when-empty'] = true; + } + + return $this->call('queue:work', $options); + } +} diff --git a/app/Enums/ImageLicenseType.php b/app/Enums/ImageLicenseType.php new file mode 100644 index 0000000..34dbcf8 --- /dev/null +++ b/app/Enums/ImageLicenseType.php @@ -0,0 +1,67 @@ + 'Eigene Aufnahme', + self::Consent => 'Vom Urheber / Fotografen freigegeben', + self::Commercial => 'Agentur-/Stockbild-Lizenz', + self::CreativeCommons => 'Creative-Commons-Lizenz', + self::PressPr => 'Presse-/PR-Bild mit Nutzungsfreigabe', + self::PublicDomain => 'Gemeinfrei / Public Domain / CC0', + self::Other => 'Sonstige Lizenz / Sondervereinbarung', + }; + } + + /** + * Ob für diesen Typ eine Lizenz-URL Pflicht ist. + */ + public function requiresLicenseUrl(): bool + { + return in_array($this, [self::CreativeCommons, self::Commercial, self::PressPr], true); + } + + /** + * Ob zusaetzliche Lizenzdetails verpflichtend sind. + */ + public function requiresLicenseDetail(): bool + { + return in_array($this, [self::CreativeCommons, self::Other], true); + } + + /** + * Optionsliste für Selects: value => label. + * + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } +} diff --git a/app/Enums/PressReleaseClassification.php b/app/Enums/PressReleaseClassification.php new file mode 100644 index 0000000..28fd836 --- /dev/null +++ b/app/Enums/PressReleaseClassification.php @@ -0,0 +1,27 @@ + 'Grün', + self::Yellow => 'Gelb', + self::Red => 'Rot', + }; + } +} diff --git a/app/Enums/PressReleaseContentTier.php b/app/Enums/PressReleaseContentTier.php new file mode 100644 index 0000000..83084e0 --- /dev/null +++ b/app/Enums/PressReleaseContentTier.php @@ -0,0 +1,51 @@ += $hochwertig => self::Hochwertig, + $score >= $gepruft => self::Geprueft, + default => self::Standard, + }; + } + + public function label(): string + { + return match ($this) { + self::Standard => 'Standard', + self::Geprueft => 'Geprüft', + self::Hochwertig => 'Hochwertig', + }; + } + + /** + * Ob die Stufe öffentlich als Vertrauensindikator gezeigt wird. Standard + * wird laut Update 2 bewusst nicht beworben (kein Badge/Label). + */ + public function isPubliclyBadged(): bool + { + return $this !== self::Standard; + } +} diff --git a/app/Enums/PressReleasePlaceholder.php b/app/Enums/PressReleasePlaceholder.php new file mode 100644 index 0000000..676a712 --- /dev/null +++ b/app/Enums/PressReleasePlaceholder.php @@ -0,0 +1,90 @@ +.svg`. + */ +enum PressReleasePlaceholder: string +{ + case GridBlue = '01-grid-blue'; + case GridGreen = '02-grid-green'; + case GridAmber = '03-grid-amber'; + case LinesBlue = '04-lines-blue'; + case LinesGreen = '05-lines-green'; + case LinesAmber = '06-lines-amber'; + case DotsBlue = '07-dots-blue'; + case DotsGreen = '08-dots-green'; + case DotsAmber = '09-dots-amber'; + case WavesBlue = '10-waves-blue'; + case WavesGreen = '11-waves-green'; + case WavesAmber = '12-waves-amber'; + case EditorialBlue = '13-editorial-blue'; + case EditorialGreen = '14-editorial-green'; + case EditorialAmber = '15-editorial-amber'; + case SignalBlue = '16-signal-blue'; + case SignalGreen = '17-signal-green'; + case SignalAmber = '18-signal-amber'; + + /** + * Default-Variante, wenn nichts gesetzt ist. + */ + public static function default(): self + { + return self::GridBlue; + } + + /** + * Liefert die Variante zu einem Roh-Wert oder den Default-Fallback. + */ + public static function fromValueOrDefault(?string $value): self + { + return self::tryFrom((string) $value) ?? self::default(); + } + + /** + * Deterministische Variante aus einem Seed (z. B. PM-ID/Titel), damit + * dieselbe PM immer denselben Platzhalter bekommt. + */ + public static function fromSeed(int|string $seed): self + { + $cases = self::cases(); + + return $cases[abs(crc32((string) $seed)) % count($cases)]; + } + + /** + * Öffentlicher Asset-Pfad relativ zu `public/`. + */ + public function path(): string + { + return 'images/press-release-placeholders/'.$this->value.'.svg'; + } + + /** + * Lesbares Label für die UI (Picker-Tooltips etc.). + */ + public function label(): string + { + $pattern = match (true) { + str_contains($this->value, 'grid') => 'Raster', + str_contains($this->value, 'lines') => 'Linien', + str_contains($this->value, 'dots') => 'Punkte', + str_contains($this->value, 'waves') => 'Wellen', + str_contains($this->value, 'editorial') => 'Editorial', + default => 'Signal', + }; + + $color = match (true) { + str_contains($this->value, 'blue') => 'Blau', + str_contains($this->value, 'green') => 'Grün', + default => 'Bernstein', + }; + + return $pattern.' · '.$color; + } +} diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index 1b565f6..30baf83 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -2,12 +2,15 @@ namespace App\Http\Controllers\Api\V1; +use App\Enums\PressReleaseStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\StorePressReleaseRequest; use App\Http\Requests\Api\V1\UpdatePressReleaseRequest; use App\Http\Resources\PressReleaseResource; use App\Models\Company; use App\Models\PressRelease; +use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\PressReleaseService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -46,7 +49,10 @@ class PressReleaseController extends Controller $company->portal->value, $validated['language'], ), - 'status' => $validated['status'] ?? 'draft', + // Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach + // `review` erfolgt ausschließlich über die explizite submit-Route, + // damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden. + 'status' => PressReleaseStatus::Draft->value, ]); return PressReleaseResource::make( @@ -101,11 +107,51 @@ class PressReleaseController extends Controller $pressRelease->save(); + // Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen. + if ($pressRelease->wasChanged(['title', 'text'])) { + $service = app(PressReleaseService::class); + $service->reclassifyIfClassified($pressRelease); + $service->rescoreIfScored($pressRelease); + } + return PressReleaseResource::make( $pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) ); } + /** + * Reicht eine PM zur Prüfung ein – der einzige API-Weg nach `review`. + * + * Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter, + * Statuswechsel, Status-Log und Quota werden über den + * `PressReleaseService` garantiert durchlaufen. `published` bleibt über die + * API unerreichbar. + */ + public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse + { + abort_unless($request->user()->tokenCan('press-releases:write'), 403); + $pressRelease = $this->findOwnedPressRelease($pressRelease, $request); + abort_unless($pressRelease !== null, 403); + + if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) { + return response()->json([ + 'message' => 'Only draft or rejected press releases may be submitted for review.', + ], 409); + } + + try { + $service->submitForReview($pressRelease); + } catch (BlacklistViolationException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } + + return PressReleaseResource::make( + $pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + ); + } + public function destroy(Request $request, int $pressRelease): JsonResponse|Response { abort_unless($request->user()->tokenCan('press-releases:write'), 403); diff --git a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php index ef973fd..9f8e56f 100644 --- a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php @@ -2,10 +2,8 @@ namespace App\Http\Requests\Api\V1; -use App\Enums\PressReleaseStatus; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class StorePressReleaseRequest extends FormRequest { @@ -32,10 +30,6 @@ class StorePressReleaseRequest extends FormRequest 'text' => ['required', 'string'], 'backlink_url' => ['nullable', 'url', 'max:255'], 'keywords' => ['nullable', 'string', 'max:255'], - 'status' => ['nullable', Rule::in([ - PressReleaseStatus::Draft->value, - PressReleaseStatus::Review->value, - ])], 'teaser_begin' => ['nullable', 'integer', 'min:0'], 'teaser_end' => ['nullable', 'integer', 'min:0'], 'no_export' => ['nullable', 'boolean'], diff --git a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php index 7b295c1..d70bd90 100644 --- a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php @@ -2,10 +2,8 @@ namespace App\Http\Requests\Api\V1; -use App\Enums\PressReleaseStatus; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class UpdatePressReleaseRequest extends FormRequest { @@ -32,10 +30,6 @@ class UpdatePressReleaseRequest extends FormRequest 'text' => ['sometimes', 'required', 'string'], 'backlink_url' => ['nullable', 'url', 'max:255'], 'keywords' => ['nullable', 'string', 'max:255'], - 'status' => ['sometimes', Rule::in([ - PressReleaseStatus::Draft->value, - PressReleaseStatus::Review->value, - ])], 'teaser_begin' => ['nullable', 'integer', 'min:0'], 'teaser_end' => ['nullable', 'integer', 'min:0'], 'no_export' => ['nullable', 'boolean'], diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php new file mode 100644 index 0000000..a659cca --- /dev/null +++ b/app/Jobs/ClassifyPressRelease.php @@ -0,0 +1,107 @@ +find($this->pressReleaseId); + + if ($pressRelease === null) { + return; + } + + $result = $this->classify($manager, $pressRelease); + + $pressRelease->forceFill([ + 'classification' => $result->classification->value, + 'classified_at' => now(), + ])->save(); + + KiAudit::query()->create([ + 'press_release_id' => $pressRelease->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => $result->classification->value, + 'reason' => $result->reasonText(), + 'raw_response' => $result->rawResponse, + 'created_at' => now(), + ]); + + if (! $this->route) { + return; + } + + try { + $service->routeByClassification($pressRelease, $result->classification, $result->reasonText()); + } catch (BlacklistViolationException) { + // publish() hat die PM bereits abgelehnt und den Autor benachrichtigt. + } + } + + /** + * Klassifiziert über den aktiven (oder explizit gewählten) Treiber; bei + * Fehler greift der deterministische Fallback, damit das Ergebnis + * nachvollziehbar bleibt. + */ + private function classify(ClassificationManager $manager, PressRelease $pressRelease): ClassificationResult + { + $provider = $this->providerOverride ?: $manager->getDefaultDriver(); + + try { + return $manager->driver($provider)->classify($pressRelease); + } catch (\Throwable $exception) { + Log::warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [ + 'press_release_id' => $pressRelease->id, + 'provider' => $provider, + 'error' => $exception->getMessage(), + ]); + + return $manager->driver('deterministic')->classify($pressRelease); + } + } +} diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php new file mode 100644 index 0000000..c2d97ac --- /dev/null +++ b/app/Jobs/ScorePressRelease.php @@ -0,0 +1,85 @@ +find($this->pressReleaseId); + + if ($pressRelease === null) { + return; + } + + $result = $this->score($manager, $pressRelease); + $tier = PressReleaseContentTier::fromScore($result->score); + + $pressRelease->forceFill([ + 'content_score' => $result->score, + 'content_tier' => $tier->value, + 'scored_at' => now(), + ])->save(); + + KiAudit::query()->create([ + 'press_release_id' => $pressRelease->id, + 'type' => KiAudit::TYPE_CONTENT_SCORE, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => (string) $result->score, + 'reason' => $tier->label(), + 'raw_response' => $result->rawResponse, + 'created_at' => now(), + ]); + } + + /** + * Bewertet über den aktiven (oder explizit gewählten) Treiber; bei Fehler + * greift der deterministische Fallback. + */ + private function score(ContentScoreManager $manager, PressRelease $pressRelease): ContentScoreResult + { + $provider = $this->providerOverride ?: $manager->getDefaultDriver(); + + try { + return $manager->driver($provider)->score($pressRelease); + } catch (\Throwable $exception) { + Log::warning('Content-Score fiel auf den deterministischen Treiber zurück.', [ + 'press_release_id' => $pressRelease->id, + 'provider' => $provider, + 'error' => $exception->getMessage(), + ]); + + return $manager->driver('deterministic')->score($pressRelease); + } + } +} diff --git a/app/Models/KiAudit.php b/app/Models/KiAudit.php new file mode 100644 index 0000000..cbb051e --- /dev/null +++ b/app/Models/KiAudit.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + public $timestamps = false; + + public const TYPE_CLASSIFICATION = 'classification'; + + public const TYPE_CONTENT_SCORE = 'content_score'; + + protected $fillable = [ + 'press_release_id', + 'type', + 'provider', + 'model', + 'result', + 'reason', + 'raw_response', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'raw_response' => 'array', + 'created_at' => 'datetime', + ]; + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 26ec88b..3f4e5ad 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -3,6 +3,9 @@ namespace App\Models; use App\Enums\Portal; +use App\Enums\PressReleaseClassification; +use App\Enums\PressReleaseContentTier; +use App\Enums\PressReleasePlaceholder; use App\Enums\PressReleaseStatus; use App\Models\Concerns\HasUniqueSlug; use App\Scopes\PortalScope; @@ -14,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; use Illuminate\Support\HtmlString; class PressRelease extends Model @@ -21,6 +25,13 @@ class PressRelease extends Model /** @use HasFactory */ use HasFactory, HasUniqueSlug, SoftDeletes; + /** + * Anzeige-Zeitzone für vom Nutzer erfasste Termine (scheduled_at, + * embargo_at). In der Datenbank wird weiterhin UTC gespeichert + * (config app.timezone). + */ + public const DISPLAY_TIMEZONE = 'Europe/Berlin'; + /** * @return list */ @@ -37,6 +48,13 @@ class PressRelease extends Model protected static function booted(): void { static::addGlobalScope(new PortalScope); + + static::creating(function (self $pressRelease): void { + if (blank($pressRelease->placeholder_variant)) { + $seed = $pressRelease->uuid ?? $pressRelease->title ?? (string) now()->timestamp; + $pressRelease->placeholder_variant = PressReleasePlaceholder::fromSeed($seed)->value; + } + }); } protected $fillable = [ @@ -51,9 +69,15 @@ class PressRelease extends Model 'slug', 'text', 'boilerplate_override', + 'placeholder_variant', 'backlink_url', 'keywords', 'status', + 'classification', + 'classified_at', + 'content_score', + 'content_tier', + 'scored_at', 'hits', 'teaser_begin', 'teaser_end', @@ -69,7 +93,13 @@ class PressRelease extends Model { return [ 'portal' => Portal::class, + 'placeholder_variant' => PressReleasePlaceholder::class, 'status' => PressReleaseStatus::class, + 'classification' => PressReleaseClassification::class, + 'classified_at' => 'datetime', + 'content_score' => 'integer', + 'content_tier' => PressReleaseContentTier::class, + 'scored_at' => 'datetime', 'hits' => 'integer', 'teaser_begin' => 'integer', 'teaser_end' => 'integer', @@ -81,6 +111,22 @@ class PressRelease extends Model ]; } + /** + * Geplanter Veröffentlichungstermin in der Anzeige-Zeitzone (Europe/Berlin). + */ + public function scheduledAtLocal(): ?Carbon + { + return $this->scheduled_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE); + } + + /** + * Sperrfrist (Embargo) in der Anzeige-Zeitzone (Europe/Berlin). + */ + public function embargoAtLocal(): ?Carbon + { + return $this->embargo_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -116,6 +162,11 @@ class PressRelease extends Model return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at'); } + public function kiAudits(): HasMany + { + return $this->hasMany(KiAudit::class)->orderByDesc('created_at'); + } + /** * Display-ready text. Returns sanitized HTML for Phase-7+ PMs and *

/
-wrapped legacy plain text for older imports. diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 5207759..6b645b0 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ImageLicenseType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; @@ -19,6 +20,16 @@ class PressReleaseImage extends Model 'title', 'description', 'copyright', + 'author', + 'license_type', + 'license_detail', + 'license_url', + 'source_url', + 'persons_consent', + 'people_rights_status', + 'property_rights_status', + 'rights_notes', + 'rights_confirmed_at', 'is_preview', 'sort_order', 'width', @@ -32,6 +43,9 @@ class PressReleaseImage extends Model { return [ 'variants' => 'array', + 'license_type' => ImageLicenseType::class, + 'persons_consent' => 'boolean', + 'rights_confirmed_at' => 'datetime', 'is_preview' => 'boolean', 'sort_order' => 'integer', 'width' => 'integer', diff --git a/app/Models/User.php b/app/Models/User.php index e4abec7..a07089f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -43,6 +43,8 @@ class User extends Authenticatable 'legacy_portal', 'legacy_id', 'password', + 'press_release_quota', + 'press_release_quota_used_this_month', ]; /** @@ -73,9 +75,23 @@ class User extends Authenticatable 'last_seen_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', + 'press_release_quota' => 'integer', + 'press_release_quota_used_this_month' => 'integer', ]; } + /** + * Verbleibendes PM-Kontingent in diesem Monat. + * + * Temporärer Stub bis zum echten Tarif-/Credit-Modul. Die Schnittstelle + * (`pressReleaseQuotaRemaining()`) bleibt stabil, damit das + * Veröffentlichungs-Modal nicht neu gebaut werden muss. + */ + public function pressReleaseQuotaRemaining(): int + { + return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month); + } + /** * Get the user's initials */ diff --git a/app/Services/Customer/CustomerCompanyContext.php b/app/Services/Customer/CustomerCompanyContext.php index 1553828..a474aec 100644 --- a/app/Services/Customer/CustomerCompanyContext.php +++ b/app/Services/Customer/CustomerCompanyContext.php @@ -2,6 +2,7 @@ namespace App\Services\Customer; +use App\Enums\Portal; use App\Models\Company; use App\Models\User; use Illuminate\Database\Eloquent\Builder; @@ -78,6 +79,30 @@ class CustomerCompanyContext ->get(); } + /** + * @return Collection + */ + public function searchCompaniesFor(User $user, string $term = '', ?int $selectedCompanyId = null, int $limit = 10): Collection + { + $term = Portal::stripTrailingAbbreviation($term); + $limit = max(1, $limit); + + if ($term === '') { + return $this->companyOptionsWithSelected($user, $selectedCompanyId, $limit); + } + + return $this->companyOptionQuery($user) + ->where(function (Builder $query) use ($term): void { + $query->where('companies.name', 'like', '%'.$term.'%') + ->orWhere('companies.slug', 'like', '%'.$term.'%') + ->orWhere('companies.email', 'like', '%'.$term.'%'); + }) + ->orderBy('companies.name') + ->orderBy('companies.id') + ->limit($limit) + ->get(); + } + public function companyCountFor(User $user): int { return $this->accessibleCompanyQuery($user)->count(); @@ -181,6 +206,41 @@ class CustomerCompanyContext ]); } + /** + * @return Builder + */ + private function companyOptionQuery(User $user): Builder + { + return $this->accessibleCompanyQuery($user) + ->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at']); + } + + /** + * @return Collection + */ + private function companyOptionsWithSelected(User $user, ?int $selectedCompanyId, int $limit): Collection + { + $selectedCompany = $selectedCompanyId + ? $this->companyOptionQuery($user)->whereKey($selectedCompanyId)->first() + : null; + + $companies = $this->companyOptionQuery($user) + ->when($selectedCompany, fn (Builder $query): Builder => $query->whereKeyNot($selectedCompany->id)) + ->latest('companies.created_at') + ->latest('companies.id') + ->limit($selectedCompany ? max(0, $limit - 1) : $limit) + ->get(); + + if (! $selectedCompany) { + return $companies; + } + + return $companies + ->prepend($selectedCompany) + ->unique('id') + ->values(); + } + private function userCanAccessCompany(User $user, int $companyId): bool { return $this->accessibleCompanyQuery($user) diff --git a/app/Services/Image/ImageService.php b/app/Services/Image/ImageService.php index 35a6804..8006737 100644 --- a/app/Services/Image/ImageService.php +++ b/app/Services/Image/ImageService.php @@ -43,6 +43,8 @@ class ImageService 'thumb' => ['width' => 320, 'height' => 240], 'medium' => ['width' => 800, 'height' => 600], 'large' => ['width' => 1600, 'height' => 1200], + // Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px. + 'cover' => ['width' => 1280, 'height' => 580], ]; public const ALLOWED_LOGO_MIME_TYPES = [ @@ -60,7 +62,7 @@ class ImageService public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB - public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB + public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB public function __construct(private readonly string $disk = 'public') {} @@ -99,8 +101,9 @@ class ImageService } /** - * Persists a freshly uploaded press release image and generates all - * variants. Original is stored under `press-releases/{id}/images`. + * Persists a freshly uploaded press release image, generates all variants + * and discards the original upload. The canonical stored path points to + * the cover variant to keep storage usage predictable. * * @return array{ * path: string, @@ -122,9 +125,6 @@ class ImageService $disk = $this->disk(); $disk->put($relativePath, $upload->get(), 'public'); - $absolute = $disk->path($relativePath); - $size = @getimagesize($absolute) ?: [null, null]; - $variants = $this->generateVariants( $disk, $relativePath, @@ -133,11 +133,19 @@ class ImageService cover: true, ); + $coverPath = $variants['cover'] ?? $relativePath; + $coverAbsolute = $disk->path($coverPath); + $coverSize = @getimagesize($coverAbsolute) ?: [null, null]; + + if ($coverPath !== $relativePath && $disk->exists($relativePath)) { + $disk->delete($relativePath); + } + return [ - 'path' => $relativePath, + 'path' => $coverPath, 'variants' => $variants, - 'width' => is_int($size[0] ?? null) ? $size[0] : null, - 'height' => is_int($size[1] ?? null) ? $size[1] : null, + 'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null, + 'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null, 'mime' => $upload->getMimeType(), ]; } diff --git a/app/Services/PressRelease/Classification/ClassificationManager.php b/app/Services/PressRelease/Classification/ClassificationManager.php new file mode 100644 index 0000000..4412f78 --- /dev/null +++ b/app/Services/PressRelease/Classification/ClassificationManager.php @@ -0,0 +1,33 @@ +config->get('scoring.classification.provider') ?: 'deterministic'); + } + + public function createDeterministicDriver(): ClassificationDriver + { + return $this->container->make(DeterministicClassificationDriver::class); + } + + public function createOpenaiDriver(): ClassificationDriver + { + return $this->container->make(OpenAiClassificationDriver::class); + } +} diff --git a/app/Services/PressRelease/Classification/ClassificationResult.php b/app/Services/PressRelease/Classification/ClassificationResult.php new file mode 100644 index 0000000..9a0ea91 --- /dev/null +++ b/app/Services/PressRelease/Classification/ClassificationResult.php @@ -0,0 +1,31 @@ + $reasons Begründungen der KI (kann leer sein) + * @param array $rawResponse Roh-Antwort für das Audit-Log + */ + public function __construct( + public readonly PressReleaseClassification $classification, + public readonly array $reasons, + public readonly string $provider, + public readonly ?string $model, + public readonly array $rawResponse, + ) {} + + public function reasonText(): ?string + { + return $this->reasons === [] ? null : implode('; ', $this->reasons); + } +} diff --git a/app/Services/PressRelease/Classification/Contracts/ClassificationDriver.php b/app/Services/PressRelease/Classification/Contracts/ClassificationDriver.php new file mode 100644 index 0000000..16f4a87 --- /dev/null +++ b/app/Services/PressRelease/Classification/Contracts/ClassificationDriver.php @@ -0,0 +1,21 @@ +blacklist->findInPressRelease($pressRelease); + + if ($word !== null) { + return new ClassificationResult( + classification: PressReleaseClassification::Red, + reasons: [sprintf('Unzulässiges Wort gefunden: "%s".', $word)], + provider: 'deterministic', + model: null, + rawResponse: ['matched_word' => $word], + ); + } + + return new ClassificationResult( + classification: PressReleaseClassification::Green, + reasons: [], + provider: 'deterministic', + model: null, + rawResponse: ['matched_word' => null], + ); + } +} diff --git a/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php b/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php new file mode 100644 index 0000000..fb7967f --- /dev/null +++ b/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php @@ -0,0 +1,115 @@ +timeout($timeout) + ->acceptJson() + ->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $this->systemPrompt()], + ['role' => 'user', 'content' => $this->userPrompt($pressRelease)], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException("OpenAI-Klassifikation fehlgeschlagen (HTTP {$response->status()})."); + } + + $payload = $response->json(); + $content = data_get($payload, 'choices.0.message.content'); + + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.'); + } + + $parsed = json_decode($content, true); + + if (! is_array($parsed) || ! isset($parsed['classification'])) { + throw new RuntimeException('OpenAI-Antwort war kein gültiges Klassifikations-JSON.'); + } + + $classification = PressReleaseClassification::tryFrom((string) $parsed['classification']); + + if ($classification === null) { + throw new RuntimeException('OpenAI lieferte einen unbekannten Klassifikationswert.'); + } + + $reasons = array_values(array_filter(array_map( + static fn ($reason): string => (string) $reason, + is_array($parsed['reasons'] ?? null) ? $parsed['reasons'] : [], + ))); + + return new ClassificationResult( + classification: $classification, + reasons: $reasons, + provider: 'openai', + model: $model, + rawResponse: is_array($payload) ? $payload : [], + ); + } + + private function systemPrompt(): string + { + return <<<'PROMPT' + Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal. + Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf. + + Klassifiziere genau eine der drei Stufen: + - "green": unauffällig, kann veröffentlicht werden. + - "yellow": grenzwertig/unklar, sollte manuell geprüft werden. + - "red": unzulässig, darf nicht veröffentlicht werden. + + Prüfe insbesondere diese Faktoren (Red Flags): + - reine Werbung statt journalistischer Pressemitteilung + - beleidigende, diskriminierende oder hetzerische Inhalte + - rechtlich heikle Aussagen (z. B. unbelegte Heil-/Gewinnversprechen, + Verleumdung, Aufruf zu Straftaten) + - Spam-Muster (sinnlose Keyword-Wiederholung, irreführende Links) + - unseriöse oder offensichtlich falsche Versprechen + + Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema: + {"classification": "green|yellow|red", "reasons": ["kurze Begründung", ...]} + Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch. + PROMPT; + } + + private function userPrompt(PressRelease $pressRelease): string + { + $title = (string) $pressRelease->title; + $text = trim(strip_tags((string) $pressRelease->text)); + + return "Titel:\n{$title}\n\nText:\n{$text}"; + } +} diff --git a/app/Services/PressRelease/ContentScore/ContentScoreManager.php b/app/Services/PressRelease/ContentScore/ContentScoreManager.php new file mode 100644 index 0000000..2fdd753 --- /dev/null +++ b/app/Services/PressRelease/ContentScore/ContentScoreManager.php @@ -0,0 +1,30 @@ +config->get('scoring.content_score.provider') ?: 'deterministic'); + } + + public function createDeterministicDriver(): ContentScoreDriver + { + return $this->container->make(DeterministicContentScoreDriver::class); + } + + public function createOpenaiDriver(): ContentScoreDriver + { + return $this->container->make(OpenAiContentScoreDriver::class); + } +} diff --git a/app/Services/PressRelease/ContentScore/ContentScoreResult.php b/app/Services/PressRelease/ContentScore/ContentScoreResult.php new file mode 100644 index 0000000..404e5ae --- /dev/null +++ b/app/Services/PressRelease/ContentScore/ContentScoreResult.php @@ -0,0 +1,25 @@ + $breakdown Faktor-Aufschlüsselung (optional) + * @param array $rawResponse Roh-Antwort für das Audit-Log + */ + public function __construct( + public readonly int $score, + public readonly array $breakdown, + public readonly string $provider, + public readonly ?string $model, + public readonly array $rawResponse, + ) {} +} diff --git a/app/Services/PressRelease/ContentScore/Contracts/ContentScoreDriver.php b/app/Services/PressRelease/ContentScore/Contracts/ContentScoreDriver.php new file mode 100644 index 0000000..844ce50 --- /dev/null +++ b/app/Services/PressRelease/ContentScore/Contracts/ContentScoreDriver.php @@ -0,0 +1,21 @@ +plainTextLength(); + $lengthPoints = match (true) { + $textLength >= 1500 => 20, + $textLength >= 800 => 12, + $textLength >= 300 => 6, + default => 0, + }; + $score += $lengthPoints; + $breakdown['length'] = $lengthPoints; + + $subtitlePoints = filled($pressRelease->subtitle) ? 6 : 0; + $score += $subtitlePoints; + $breakdown['subtitle'] = $subtitlePoints; + + $imagePoints = $pressRelease->images()->count() > 0 ? 10 : 0; + $score += $imagePoints; + $breakdown['image'] = $imagePoints; + + $sourcePoints = filled($pressRelease->backlink_url) ? 8 : 0; + $score += $sourcePoints; + $breakdown['source'] = $sourcePoints; + + $titleLength = mb_strlen((string) $pressRelease->title); + $headlinePoints = ($titleLength >= 30 && $titleLength <= 90) ? 8 : 0; + $score += $headlinePoints; + $breakdown['headline'] = $headlinePoints; + + $keywordPoints = filled($pressRelease->keywords) ? 4 : 0; + $score += $keywordPoints; + $breakdown['keywords'] = $keywordPoints; + + $contactPoints = $pressRelease->contacts()->count() > 0 ? 4 : 0; + $score += $contactPoints; + $breakdown['contact'] = $contactPoints; + + $score = max(0, min(100, $score)); + + return new ContentScoreResult( + score: $score, + breakdown: $breakdown, + provider: 'deterministic', + model: null, + rawResponse: ['breakdown' => $breakdown, 'text_length' => $textLength], + ); + } +} diff --git a/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php b/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php new file mode 100644 index 0000000..2f38c5f --- /dev/null +++ b/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php @@ -0,0 +1,107 @@ +timeout($timeout) + ->acceptJson() + ->post($openai['url'] ?? 'https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $this->systemPrompt()], + ['role' => 'user', 'content' => $this->userPrompt($pressRelease)], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException("OpenAI-Content-Score fehlgeschlagen (HTTP {$response->status()})."); + } + + $payload = $response->json(); + $content = data_get($payload, 'choices.0.message.content'); + + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.'); + } + + $parsed = json_decode($content, true); + + if (! is_array($parsed) || ! isset($parsed['score']) || ! is_numeric($parsed['score'])) { + throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.'); + } + + $score = (int) max(0, min(100, (int) round((float) $parsed['score']))); + $breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : []; + + return new ContentScoreResult( + score: $score, + breakdown: $breakdown, + provider: 'openai', + model: $model, + rawResponse: is_array($payload) ? $payload : [], + ); + } + + private function systemPrompt(): string + { + return <<<'PROMPT' + Du bewertest die handwerkliche Qualität einer deutschen Pressemitteilung + auf einer Skala von 0 bis 100 (Content-Score). Bewerte ausschließlich die + Qualität, nicht die Zulässigkeit. + + Berücksichtige diese gewichteten Kategorien: + - Pressestil (20%): informativ statt werblich, aktive Sprache, Zitate + - Struktur (15%): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau + - Lesbarkeit (10%): Satzlängen, angemessene Fachsprache + - Vollständigkeit (15%): Pressekontakt, Unternehmensinfo, Datum, Region + - Bildmaterial (10%): Bild vorhanden/erwähnt + - Quellen/Belege (10%): Verlinkungen, Studien, Datenquellen + - Headline-Stärke (10%): Länge, Klarheit, Keyword-Relevanz + - Originalität (10%): kein Boilerplate, individueller Ton + + Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema: + {"score": 0-100, "breakdown": {"pressestil": 0-20, "struktur": 0-15, + "lesbarkeit": 0-10, "vollstaendigkeit": 0-15, "bild": 0-10, + "quellen": 0-10, "headline": 0-10, "originalitaet": 0-10}} + PROMPT; + } + + private function userPrompt(PressRelease $pressRelease): string + { + $title = (string) $pressRelease->title; + $text = trim(strip_tags((string) $pressRelease->text)); + $hasImage = $pressRelease->images()->count() > 0 ? 'ja' : 'nein'; + $source = filled($pressRelease->backlink_url) ? $pressRelease->backlink_url : '—'; + + return "Titel:\n{$title}\n\nBild vorhanden: {$hasImage}\nQuelle/Link: {$source}\n\nText:\n{$text}"; + } +} diff --git a/app/Services/PressRelease/PressReleaseCoverImage.php b/app/Services/PressRelease/PressReleaseCoverImage.php new file mode 100644 index 0000000..6f7ed51 --- /dev/null +++ b/app/Services/PressRelease/PressReleaseCoverImage.php @@ -0,0 +1,92 @@ +coverImage($pressRelease); + + if (! $image) { + return $this->placeholderUrl($pressRelease); + } + + foreach ($this->fallbackChain($variant) as $key) { + $url = $image->variantUrl($key); + + if ($url !== null) { + return $url; + } + } + + return $image->url() ?? $this->placeholderUrl($pressRelease); + } + + /** + * Fallback-Reihenfolge der Bild-Varianten, beginnend bei der gewünschten. + * + * @return list + */ + private function fallbackChain(string $preferred): array + { + $chain = [$preferred, 'cover', 'large', 'medium', 'thumb']; + + return array_values(array_unique($chain)); + } + + /** + * Ob für diese PM nur ein Platzhalter (kein echtes Bild) vorliegt. + */ + public function coverIsPlaceholder(PressRelease $pressRelease): bool + { + return $this->coverImage($pressRelease) === null; + } + + /** + * Die Platzhalter-Variante dieser PM (mit Default-Fallback). + */ + public function placeholder(PressRelease $pressRelease): PressReleasePlaceholder + { + $variant = $pressRelease->placeholder_variant; + + if ($variant instanceof PressReleasePlaceholder) { + return $variant; + } + + return PressReleasePlaceholder::fromValueOrDefault($variant); + } + + private function placeholderUrl(PressRelease $pressRelease): string + { + return asset($this->placeholder($pressRelease)->path()); + } + + private function coverImage(PressRelease $pressRelease): ?PressReleaseImage + { + $images = $pressRelease->relationLoaded('images') + ? $pressRelease->images + : $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->get(); + + return $images->firstWhere('is_preview', true) ?? $images->first(); + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index b8eca22..a251cda 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -2,7 +2,10 @@ namespace App\Services\PressRelease; +use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; +use App\Jobs\ClassifyPressRelease; +use App\Jobs\ScorePressRelease; use App\Mail\PressReleasePublished; use App\Mail\PressReleaseRejected; use App\Models\AdminPreset; @@ -43,9 +46,101 @@ class PressReleaseService $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'); } - public function publish(PressRelease $pressRelease, string $source = 'admin'): void + /** + * 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]); @@ -63,7 +158,7 @@ class PressReleaseService $pressRelease->update([ 'status' => PressReleaseStatus::Published->value, - 'published_at' => $this->resolvePublishedAt($pressRelease), + 'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride), ]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source); @@ -83,14 +178,18 @@ class PressReleaseService * 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 + private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon { if ($pressRelease->published_at) { return $pressRelease->published_at; } - $base = $pressRelease->scheduled_at ?: now(); + $base = $pressRelease->scheduled_at ?: ($override ?? now()); if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) { return $pressRelease->embargo_at; @@ -99,7 +198,7 @@ class PressReleaseService return $base; } - public function reject(PressRelease $pressRelease, ?string $reason = null): void + public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void { $this->assertStatus($pressRelease, [PressReleaseStatus::Review]); @@ -107,7 +206,7 @@ class PressReleaseService $pressRelease->update(['status' => PressReleaseStatus::Rejected->value]); - $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin'); + $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source); $this->notifyAuthor($pressRelease, 'rejected', $reason); } diff --git a/config/scoring.php b/config/scoring.php new file mode 100644 index 0000000..e9236d0 --- /dev/null +++ b/config/scoring.php @@ -0,0 +1,71 @@ + [ + // Aktiver Treiber: openai|deterministic (Anthropic/Gemini folgen). + // Fällt der Anbieter aus (kein Key, Timeout, Fehler), greift im Job + // automatisch der deterministische Treiber. + 'provider' => env('CLASSIFICATION_PROVIDER', 'openai'), + + // Optional ein abweichendes Modell; leer => config('services.openai.model'). + 'model' => env('CLASSIFICATION_MODEL'), + + // Sekunden, bevor auf den deterministischen Fallback-Treiber + // ausgewichen wird (Timeout/Rate-Limit/Ausfall). + 'timeout' => (int) env('CLASSIFICATION_TIMEOUT', 15), + + // Verzögerung in Minuten für „grün" eingestufte PMs als + // Sicherheitsfenster vor der automatischen Veröffentlichung + // (Konzept-Option, 0 = sofort). + 'green_delay_minutes' => (int) env('CLASSIFICATION_GREEN_DELAY', 0), + + // Ob „gelb" eingestufte PMs in die manuelle Admin-Queue gehen. + 'yellow_to_manual_queue' => (bool) env('CLASSIFICATION_YELLOW_MANUAL', true), + ], + + /* + |-------------------------------------------------------------------------- + | Content-Score (Qualitätsbewertung, §15.2 / Update 2) + |-------------------------------------------------------------------------- + | + | Anbieter/Modell für die Score-Berechnung und Schwellen für die Ableitung + | der Stufe aus dem 0–100-Score (Standard < 60 ≤ Geprüft < 80 ≤ Hochwertig). + | Schwellen werden laut Konzept nach 100–200 echten PMs kalibriert. + | + */ + 'content_score' => [ + 'provider' => env('CONTENT_SCORE_PROVIDER', 'openai'), + 'model' => env('CONTENT_SCORE_MODEL'), + 'timeout' => (int) env('CONTENT_SCORE_TIMEOUT', 30), + + 'tiers' => [ + 'hochwertig' => (int) env('CONTENT_SCORE_HOCHWERTIG', 80), + 'gepruft' => (int) env('CONTENT_SCORE_GEPRUEFT', 60), + // alles darunter => 'standard' + ], + ], + +]; diff --git a/config/services.php b/config/services.php index 27a3617..58fff24 100644 --- a/config/services.php +++ b/config/services.php @@ -34,5 +34,11 @@ return [ 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], + 'openai' => [ + 'api_key' => env('OPENAI_API_KEY'), + 'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'), + 'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'), + 'timeout' => env('OPENAI_TIMEOUT', 60), + ], ]; diff --git a/database/factories/KiAuditFactory.php b/database/factories/KiAuditFactory.php new file mode 100644 index 0000000..7c99d88 --- /dev/null +++ b/database/factories/KiAuditFactory.php @@ -0,0 +1,53 @@ + + */ +class KiAuditFactory extends Factory +{ + protected $model = KiAudit::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $classification = fake()->randomElement(PressReleaseClassification::cases()); + + return [ + 'press_release_id' => PressRelease::factory(), + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'provider' => 'anthropic', + 'model' => 'claude-sonnet-4-6', + 'result' => $classification->value, + 'reason' => fake()->optional()->sentence(), + 'raw_response' => ['classification' => $classification->value, 'reasons' => []], + 'created_at' => now(), + ]; + } + + public function classification(PressReleaseClassification $classification): static + { + return $this->state(fn (): array => [ + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => $classification->value, + ]); + } + + public function contentScore(int $score): static + { + return $this->state(fn (): array => [ + 'type' => KiAudit::TYPE_CONTENT_SCORE, + 'result' => (string) $score, + ]); + } +} diff --git a/database/migrations/2026_05_29_130110_add_placeholder_variant_to_press_releases.php b/database/migrations/2026_05_29_130110_add_placeholder_variant_to_press_releases.php new file mode 100644 index 0000000..81a1cb6 --- /dev/null +++ b/database/migrations/2026_05_29_130110_add_placeholder_variant_to_press_releases.php @@ -0,0 +1,28 @@ +string('placeholder_variant', 32)->nullable()->after('boilerplate_override'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('placeholder_variant'); + }); + } +}; diff --git a/database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php b/database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php new file mode 100644 index 0000000..0dbb4f1 --- /dev/null +++ b/database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php @@ -0,0 +1,32 @@ +string('author')->nullable()->after('copyright'); + $table->string('license_type', 32)->nullable()->after('author'); + $table->string('license_url')->nullable()->after('license_type'); + $table->boolean('persons_consent')->default(false)->after('license_url'); + $table->timestamp('rights_confirmed_at')->nullable()->after('persons_consent'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table) { + $table->dropColumn(['author', 'license_type', 'license_url', 'persons_consent', 'rights_confirmed_at']); + }); + } +}; diff --git a/database/migrations/2026_05_29_134459_add_press_release_quota_to_users.php b/database/migrations/2026_05_29_134459_add_press_release_quota_to_users.php new file mode 100644 index 0000000..4c5cf1a --- /dev/null +++ b/database/migrations/2026_05_29_134459_add_press_release_quota_to_users.php @@ -0,0 +1,29 @@ +unsignedInteger('press_release_quota')->default(3); + $table->unsignedInteger('press_release_quota_used_this_month')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['press_release_quota', 'press_release_quota_used_this_month']); + }); + } +}; diff --git a/database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php b/database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php new file mode 100644 index 0000000..59446cf --- /dev/null +++ b/database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php @@ -0,0 +1,38 @@ +string('license_detail', 120)->nullable()->after('license_type'); + $table->string('source_url', 2048)->nullable()->after('license_url'); + $table->string('people_rights_status', 40)->nullable()->after('persons_consent'); + $table->string('property_rights_status', 40)->nullable()->after('people_rights_status'); + $table->text('rights_notes')->nullable()->after('property_rights_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table) { + $table->dropColumn([ + 'license_detail', + 'source_url', + 'people_rights_status', + 'property_rights_status', + 'rights_notes', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_11_131506_add_classification_to_press_releases.php b/database/migrations/2026_06_11_131506_add_classification_to_press_releases.php new file mode 100644 index 0000000..b9ddf2a --- /dev/null +++ b/database/migrations/2026_06_11_131506_add_classification_to_press_releases.php @@ -0,0 +1,34 @@ +string('classification', 16)->nullable()->after('status'); + $table->timestamp('classified_at')->nullable()->after('classification'); + + $table->index('classification', 'press_releases_classification_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table): void { + $table->dropIndex('press_releases_classification_idx'); + $table->dropColumn(['classification', 'classified_at']); + }); + } +}; diff --git a/database/migrations/2026_06_11_131506_create_ki_audits_table.php b/database/migrations/2026_06_11_131506_create_ki_audits_table.php new file mode 100644 index 0000000..2cb341e --- /dev/null +++ b/database/migrations/2026_06_11_131506_create_ki_audits_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('press_release_id') + ->constrained() + ->cascadeOnDelete(); + // classification | content_score — welche KI-Bewertung dies protokolliert. + $table->string('type', 32); + $table->string('provider', 32)->nullable(); + $table->string('model', 64)->nullable(); + // Ergebnis als String (z. B. green/yellow/red) oder Score-Wert. + $table->string('result', 64)->nullable(); + $table->text('reason')->nullable(); + // Vollständige Roh-Antwort der KI für Nachvollziehbarkeit (DSGVO). + $table->json('raw_response')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['press_release_id', 'type'], 'ki_audits_pr_type_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ki_audits'); + } +}; diff --git a/database/migrations/2026_06_11_150538_add_content_score_to_press_releases.php b/database/migrations/2026_06_11_150538_add_content_score_to_press_releases.php new file mode 100644 index 0000000..b3c59df --- /dev/null +++ b/database/migrations/2026_06_11_150538_add_content_score_to_press_releases.php @@ -0,0 +1,36 @@ +unsignedTinyInteger('content_score')->nullable()->after('classified_at'); + $table->string('content_tier', 16)->nullable()->after('content_score'); + $table->timestamp('scored_at')->nullable()->after('content_tier'); + + $table->index('content_tier', 'press_releases_content_tier_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table): void { + $table->dropIndex('press_releases_content_tier_idx'); + $table->dropColumn(['content_score', 'content_tier', 'scored_at']); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 21f22e5..23a6bc1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,9 @@ + + + diff --git a/public/images/press-release-placeholders/01-grid-blue.svg b/public/images/press-release-placeholders/01-grid-blue.svg new file mode 100644 index 0000000..dfb7ba7 --- /dev/null +++ b/public/images/press-release-placeholders/01-grid-blue.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/02-grid-green.svg b/public/images/press-release-placeholders/02-grid-green.svg new file mode 100644 index 0000000..d48e5f8 --- /dev/null +++ b/public/images/press-release-placeholders/02-grid-green.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/03-grid-amber.svg b/public/images/press-release-placeholders/03-grid-amber.svg new file mode 100644 index 0000000..9344eba --- /dev/null +++ b/public/images/press-release-placeholders/03-grid-amber.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/04-lines-blue.svg b/public/images/press-release-placeholders/04-lines-blue.svg new file mode 100644 index 0000000..2b9dcfa --- /dev/null +++ b/public/images/press-release-placeholders/04-lines-blue.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/05-lines-green.svg b/public/images/press-release-placeholders/05-lines-green.svg new file mode 100644 index 0000000..29a1401 --- /dev/null +++ b/public/images/press-release-placeholders/05-lines-green.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/06-lines-amber.svg b/public/images/press-release-placeholders/06-lines-amber.svg new file mode 100644 index 0000000..bfd7619 --- /dev/null +++ b/public/images/press-release-placeholders/06-lines-amber.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/07-dots-blue.svg b/public/images/press-release-placeholders/07-dots-blue.svg new file mode 100644 index 0000000..90d4e48 --- /dev/null +++ b/public/images/press-release-placeholders/07-dots-blue.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/08-dots-green.svg b/public/images/press-release-placeholders/08-dots-green.svg new file mode 100644 index 0000000..58b8f2c --- /dev/null +++ b/public/images/press-release-placeholders/08-dots-green.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/09-dots-amber.svg b/public/images/press-release-placeholders/09-dots-amber.svg new file mode 100644 index 0000000..65d776d --- /dev/null +++ b/public/images/press-release-placeholders/09-dots-amber.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/10-waves-blue.svg b/public/images/press-release-placeholders/10-waves-blue.svg new file mode 100644 index 0000000..e1db745 --- /dev/null +++ b/public/images/press-release-placeholders/10-waves-blue.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/11-waves-green.svg b/public/images/press-release-placeholders/11-waves-green.svg new file mode 100644 index 0000000..c7da393 --- /dev/null +++ b/public/images/press-release-placeholders/11-waves-green.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/12-waves-amber.svg b/public/images/press-release-placeholders/12-waves-amber.svg new file mode 100644 index 0000000..ffc2fdd --- /dev/null +++ b/public/images/press-release-placeholders/12-waves-amber.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/13-editorial-blue.svg b/public/images/press-release-placeholders/13-editorial-blue.svg new file mode 100644 index 0000000..e7e9a08 --- /dev/null +++ b/public/images/press-release-placeholders/13-editorial-blue.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/14-editorial-green.svg b/public/images/press-release-placeholders/14-editorial-green.svg new file mode 100644 index 0000000..a08eead --- /dev/null +++ b/public/images/press-release-placeholders/14-editorial-green.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/15-editorial-amber.svg b/public/images/press-release-placeholders/15-editorial-amber.svg new file mode 100644 index 0000000..50bfc85 --- /dev/null +++ b/public/images/press-release-placeholders/15-editorial-amber.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/16-signal-blue.svg b/public/images/press-release-placeholders/16-signal-blue.svg new file mode 100644 index 0000000..69f3342 --- /dev/null +++ b/public/images/press-release-placeholders/16-signal-blue.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/17-signal-green.svg b/public/images/press-release-placeholders/17-signal-green.svg new file mode 100644 index 0000000..63fde4c --- /dev/null +++ b/public/images/press-release-placeholders/17-signal-green.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/18-signal-amber.svg b/public/images/press-release-placeholders/18-signal-amber.svg new file mode 100644 index 0000000..d5e9b67 --- /dev/null +++ b/public/images/press-release-placeholders/18-signal-amber.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/resources/css/shared/hub-components.css b/resources/css/shared/hub-components.css index d299b73..514a57d 100644 --- a/resources/css/shared/hub-components.css +++ b/resources/css/shared/hub-components.css @@ -1151,6 +1151,31 @@ * Tag-Chips und Portal-/Veröffentlichungs-Optionen verwendet. */ @layer components { + /* Container-Query-Kontext: Das zweispaltige Editor-Layout richtet sich + nach dem real verfügbaren Inhaltsbereich, nicht nach dem Viewport. + So rutschen die rechten Cards automatisch nach unten, sobald die + Sidebar offen ist und der Platz knapp wird — unabhängig davon, bei + welcher Viewport-Breite die Sidebar gerade ein- oder ausfährt. */ + .pr-editor-shell { + container-type: inline-size; + container-name: pr-editor; + } + + .pr-editor-layout { + grid-template-columns: minmax(0, 1fr); + } + + @container pr-editor (min-width: 960px) { + .pr-editor-layout { + grid-template-columns: minmax(0, 1fr) 360px; + } + + .pr-editor-side { + position: sticky; + top: 1rem; + } + } + .pr-form-label { display: flex; align-items: center; diff --git a/resources/views/admin/companies/create.blade.php b/resources/views/admin/companies/create.blade.php index 19dd1b5..7f2b8cc 100644 --- a/resources/views/admin/companies/create.blade.php +++ b/resources/views/admin/companies/create.blade.php @@ -1,7 +1,7 @@

- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/companies/edit.blade.php b/resources/views/admin/companies/edit.blade.php index 4defcdc..a53344b 100644 --- a/resources/views/admin/companies/edit.blade.php +++ b/resources/views/admin/companies/edit.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/companies/show.blade.php b/resources/views/admin/companies/show.blade.php index a986b05..5fddf82 100644 --- a/resources/views/admin/companies/show.blade.php +++ b/resources/views/admin/companies/show.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/press-releases/create.blade.php b/resources/views/admin/press-releases/create.blade.php index 0b030e8..6ca3454 100644 --- a/resources/views/admin/press-releases/create.blade.php +++ b/resources/views/admin/press-releases/create.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/press-releases/edit.blade.php b/resources/views/admin/press-releases/edit.blade.php index fd84b74..f9ffffc 100644 --- a/resources/views/admin/press-releases/edit.blade.php +++ b/resources/views/admin/press-releases/edit.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/press-releases/show.blade.php b/resources/views/admin/press-releases/show.blade.php index eee6465..ebc2f56 100644 --- a/resources/views/admin/press-releases/show.blade.php +++ b/resources/views/admin/press-releases/show.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/roles/create.blade.php b/resources/views/admin/roles/create.blade.php index 289eef1..61656ab 100644 --- a/resources/views/admin/roles/create.blade.php +++ b/resources/views/admin/roles/create.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/roles/edit.blade.php b/resources/views/admin/roles/edit.blade.php index f291022..5920a52 100644 --- a/resources/views/admin/roles/edit.blade.php +++ b/resources/views/admin/roles/edit.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 403dc90..cb1228a 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -16,8 +16,8 @@ - - + + {{-- Brand-Block: Wortmarke + Hub-Eyebrow --}} @@ -292,8 +292,8 @@ - - + + diff --git a/resources/views/components/portal/press-release-placeholder.blade.php b/resources/views/components/portal/press-release-placeholder.blade.php new file mode 100644 index 0000000..d13c04a --- /dev/null +++ b/resources/views/components/portal/press-release-placeholder.blade.php @@ -0,0 +1,29 @@ +@props([ + /** + * Variante (Dateiname ohne Endung), z. B. "01-grid-blue". + * Ungültige/leere Werte fallen auf den Default zurück. + */ + 'variant' => null, + + /** Optionaler Titel als Overlay-Text auf dem Platzhalter. */ + 'title' => null, +]) + +@php + $resolved = \App\Enums\PressReleasePlaceholder::fromValueOrDefault($variant); + $src = asset($resolved->path()); +@endphp + +
class(['relative overflow-hidden bg-[color:var(--color-hub)]']) }}> + {{ $title ? __('Platzhalter für :title', ['title' => $title]) : __('Pressemitteilung Platzhalter') }} + + @if ($title) +
+

+ {{ $title }} +

+
+ @endif +
diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php new file mode 100644 index 0000000..99c06f2 --- /dev/null +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -0,0 +1,77 @@ +@props([ + 'name' => 'confirm-submit-review', + 'action', + 'confirmLabel' => null, + 'quotaTotal' => null, + 'quotaRemaining' => null, +]) + +{{-- + Wiederverwendbares Einreichungs-Modal für Pressemitteilungen. + Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der + `action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim + Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`, + `save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind. +--}} + +
+
+ {{ __('Veröffentlichung') }} + {{ __('Pressemitteilung zur Prüfung einreichen') }} +
+ + {{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}} +
+

{{ __('Mit dem Einreichen versichern Sie:') }}

+
    +
  • {{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}
  • +
  • {{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}
  • +
  • {{ __('Personenbezogene Daten sind nur im zwingend erforderlichen Umfang enthalten.') }}
  • +
  • {{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}
  • +
+

+ {{ __('Sie stellen die Plattform von Ansprüchen Dritter frei, die aus einer unberechtigten Nutzung von Inhalten resultieren. Die endgültige Veröffentlichung erfolgt nach redaktioneller Prüfung.') }} +

+
+ + {{-- Kontingent (optional) --}} + @if (! is_null($quotaRemaining) && ! is_null($quotaTotal)) +
+
+
{{ __('PM-Kontingent diesen Monat') }}
+
{{ __('Verbleibend nach diesem Versand wird angerechnet.') }}
+
+ 0 ? 'ok' : 'warn'])> + {{ $quotaRemaining }} / {{ $quotaTotal }} + +
+ @endif + + {{-- Bestätigungen --}} +
+ + + +
+ +
+ + {{ __('Abbrechen') }} + + + {{ $confirmLabel ?? __('Veröffentlichung anfordern') }} + +
+
+
diff --git a/resources/views/livewire/admin/categories/create.blade.php b/resources/views/livewire/admin/categories/create.blade.php index b5a240c..4a10d4d 100644 --- a/resources/views/livewire/admin/categories/create.blade.php +++ b/resources/views/livewire/admin/categories/create.blade.php @@ -123,7 +123,7 @@ new #[Layout('components.layouts.app'), Title('Kategorie anlegen')] class extend
- + {{ __('Zurück') }}
diff --git a/resources/views/livewire/admin/categories/edit.blade.php b/resources/views/livewire/admin/categories/edit.blade.php index 0fb6b12..d08c3d9 100644 --- a/resources/views/livewire/admin/categories/edit.blade.php +++ b/resources/views/livewire/admin/categories/edit.blade.php @@ -191,7 +191,7 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext
- + {{ __('Zurück') }}
diff --git a/resources/views/livewire/admin/categories/index.blade.php b/resources/views/livewire/admin/categories/index.blade.php index 6a5f564..8e27fce 100644 --- a/resources/views/livewire/admin/categories/index.blade.php +++ b/resources/views/livewire/admin/categories/index.blade.php @@ -244,7 +244,7 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo @endif
- + {{ __('Zurück') }}
@@ -271,7 +271,14 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
{{ __('Firmenlogo') }} - + + + {{ __('Maximal 1 MB. Empfohlen: quadratisch, min. 400x400px') }} @@ -298,7 +305,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo {{ __('Aktionen') }}
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/companies/edit.blade.php b/resources/views/livewire/admin/companies/edit.blade.php index f85945e..054498b 100644 --- a/resources/views/livewire/admin/companies/edit.blade.php +++ b/resources/views/livewire/admin/companies/edit.blade.php @@ -213,7 +213,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
- + {{ __('Zurück') }}
@@ -350,7 +350,14 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
{{ __('Firmenlogo') }} - + + + {{ __('Maximal 4 MB. Varianten (sq/wide) werden automatisch generiert.') }} @@ -371,7 +378,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
- + {{ __('Logo entfernen') }} @@ -382,7 +389,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
{{ __('Logo wird beim Speichern entfernt.') }}
- + {{ __('Rückgängig') }} @@ -413,7 +420,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/companies/index.blade.php b/resources/views/livewire/admin/companies/index.blade.php index 56d796d..04b47e3 100644 --- a/resources/views/livewire/admin/companies/index.blade.php +++ b/resources/views/livewire/admin/companies/index.blade.php @@ -396,7 +396,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
- -
@@ -532,7 +532,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component @if ($company->press_releases_count > 0) @@ -547,7 +547,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component @if ($company->contacts_count > 0) diff --git a/resources/views/livewire/admin/companies/show.blade.php b/resources/views/livewire/admin/companies/show.blade.php index ebce649..82d637f 100644 --- a/resources/views/livewire/admin/companies/show.blade.php +++ b/resources/views/livewire/admin/companies/show.blade.php @@ -253,11 +253,11 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
- + {{ __('Zurück') }} @if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create')) - + {{ __('Kontakt hinzufügen') }} @endif @@ -344,7 +344,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
{{ __('Aktuelle Pressemitteilungen') }} - + {{ __('Alle anzeigen') }}
@@ -454,7 +454,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C @endif
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit')) - + @endif diff --git a/resources/views/livewire/admin/contacts/create.blade.php b/resources/views/livewire/admin/contacts/create.blade.php index 281f980..805ee38 100644 --- a/resources/views/livewire/admin/contacts/create.blade.php +++ b/resources/views/livewire/admin/contacts/create.blade.php @@ -163,7 +163,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
- + {{ __('Zurück') }}
@@ -283,7 +283,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/contacts/edit.blade.php b/resources/views/livewire/admin/contacts/edit.blade.php index d05a5cd..0680b80 100644 --- a/resources/views/livewire/admin/contacts/edit.blade.php +++ b/resources/views/livewire/admin/contacts/edit.blade.php @@ -195,7 +195,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
- + {{ __('Zurück') }}
@@ -330,7 +330,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/contacts/index.blade.php b/resources/views/livewire/admin/contacts/index.blade.php index ab24a42..07894e8 100644 --- a/resources/views/livewire/admin/contacts/index.blade.php +++ b/resources/views/livewire/admin/contacts/index.blade.php @@ -490,7 +490,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone - + {{ __('Preset speichern') }}
@@ -573,8 +573,8 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone @endforeach - {{ __('Anwenden') }} - {{ __('Als Standard') }} + {{ __('Anwenden') }} + {{ __('Als Standard') }} {{ __('Löschen') }} @@ -622,11 +622,11 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit')) - @endif @if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show')) - @endif @@ -674,7 +674,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone @if ($contact->press_releases_count > 0) @@ -694,11 +694,11 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
- -
diff --git a/resources/views/livewire/admin/footer-codes/create.blade.php b/resources/views/livewire/admin/footer-codes/create.blade.php index 770aee2..69efc5a 100644 --- a/resources/views/livewire/admin/footer-codes/create.blade.php +++ b/resources/views/livewire/admin/footer-codes/create.blade.php @@ -92,7 +92,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
- + {{ __('Zurück') }}
@@ -194,7 +194,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/footer-codes/edit.blade.php b/resources/views/livewire/admin/footer-codes/edit.blade.php index 3502e22..6526dbd 100644 --- a/resources/views/livewire/admin/footer-codes/edit.blade.php +++ b/resources/views/livewire/admin/footer-codes/edit.blade.php @@ -130,7 +130,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
- + {{ __('Zurück') }}
@@ -233,7 +233,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/footer-codes/index.blade.php b/resources/views/livewire/admin/footer-codes/index.blade.php index 52d4e65..c58e913 100644 --- a/resources/views/livewire/admin/footer-codes/index.blade.php +++ b/resources/views/livewire/admin/footer-codes/index.blade.php @@ -215,14 +215,14 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
{{ __('Filter & Suche') }} - + {{ __('Filter zurücksetzen') }}
@@ -260,7 +260,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
@@ -297,7 +297,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
- + {{ __('Dry Run') }} diff --git a/resources/views/livewire/admin/presets/create.blade.php b/resources/views/livewire/admin/presets/create.blade.php index ae6ce5e..f993eec 100644 --- a/resources/views/livewire/admin/presets/create.blade.php +++ b/resources/views/livewire/admin/presets/create.blade.php @@ -67,7 +67,7 @@ new #[Layout('components.layouts.app'), Title('Neue Voreinstellung')] class exte
- + {{ __('Zurück') }}
@@ -77,7 +77,7 @@ new #[Layout('components.layouts.app'), Title('Neue Voreinstellung')] class exte
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/presets/edit.blade.php b/resources/views/livewire/admin/presets/edit.blade.php index 0c17abb..b417827 100644 --- a/resources/views/livewire/admin/presets/edit.blade.php +++ b/resources/views/livewire/admin/presets/edit.blade.php @@ -89,7 +89,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] clas
- + {{ __('Zurück') }}
@@ -99,7 +99,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] clas
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/presets/index.blade.php b/resources/views/livewire/admin/presets/index.blade.php index ff333d4..bccb42c 100644 --- a/resources/views/livewire/admin/presets/index.blade.php +++ b/resources/views/livewire/admin/presets/index.blade.php @@ -187,7 +187,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends - + @empty diff --git a/resources/views/livewire/admin/press-releases/create.blade.php b/resources/views/livewire/admin/press-releases/create.blade.php index bdbe2e9..66941cb 100644 --- a/resources/views/livewire/admin/press-releases/create.blade.php +++ b/resources/views/livewire/admin/press-releases/create.blade.php @@ -18,8 +18,7 @@ use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Volt\Component; -new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component -{ +new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component { public string $portal = 'presseecho'; public string $language = 'de'; @@ -52,6 +51,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex public ?string $scheduledAt = null; + public ?string $scheduledDate = null; + + public ?string $scheduledTime = null; + public bool $useEmbargo = false; public ?string $embargoAt = null; @@ -61,9 +64,33 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $this->resetErrorBag('companyId'); } + public function updatedPublishMode(): void + { + $this->syncScheduledAt(); + + if ($this->publishMode === 'now') { + $this->scheduledDate = null; + $this->scheduledTime = null; + $this->scheduledAt = null; + $this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']); + } + } + + public function updatedScheduledDate(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + + public function updatedScheduledTime(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + public function updatedCompanyId(): void { - if (! $this->companyId) { + if (!$this->companyId) { $this->contactId = null; return; @@ -71,7 +98,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId); - if (! $contactStillValid) { + if (!$contactStillValid) { $this->contactId = $this->defaultContactIdFor((int) $this->companyId); } @@ -109,10 +136,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex public function removeTag(string $tag): void { - $existing = array_values(array_filter( - $this->tagsArray(), - fn (string $existingTag): bool => $existingTag !== $tag, - )); + $existing = array_values(array_filter($this->tagsArray(), fn(string $existingTag): bool => $existingTag !== $tag)); $this->keywords = implode(', ', $existing); @@ -128,7 +152,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex protected function formRules(): array { $rules = [ - 'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))], + 'portal' => ['required', Rule::in(array_map(fn(Portal $p) => $p->value, Portal::cases()))], 'language' => ['required', Rule::in(['de', 'en'])], 'companyId' => ['required', 'integer', Rule::exists('companies', 'id')], 'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')], @@ -143,26 +167,103 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex ]; if ($this->publishMode === 'scheduled') { - $rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()]; + $rules['scheduledDate'] = ['required', 'date']; + $rules['scheduledTime'] = ['required', 'date_format:H:i']; + $rules['scheduledAt'] = [ + 'required', + 'date', + // Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen- + // bewusst prüfen statt über die naive `after:`-Regel. + function (string $attribute, mixed $value, \Closure $fail): void { + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + } + }, + ]; } else { + $rules['scheduledDate'] = ['nullable']; + $rules['scheduledTime'] = ['nullable']; $rules['scheduledAt'] = ['nullable']; } - if ($this->useEmbargo) { - $rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()]; - } else { - $rules['embargoAt'] = ['nullable']; - } + $rules['embargoAt'] = ['nullable']; return $rules; } + protected function syncScheduledAt(): void + { + if ($this->publishMode !== 'scheduled') { + $this->scheduledAt = null; + + return; + } + + if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) { + $scheduledAt = \Carbon\Carbon::parse($this->scheduledAt); + $this->scheduledDate = $scheduledAt->format('Y-m-d'); + $this->scheduledTime = $scheduledAt->format('H:i'); + + return; + } + + if (blank($this->scheduledDate) || blank($this->scheduledTime)) { + $this->scheduledAt = null; + + return; + } + + $this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}"; + } + + /** + * Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt, + * wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt. + */ + protected function scheduledAtUtc(): ?\Carbon\Carbon + { + if (blank($this->scheduledAt)) { + return null; + } + + return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc(); + } + + protected function validateScheduledAtWhenReady(): void + { + if (blank($this->scheduledAt)) { + return; + } + + $this->resetErrorBag('scheduledAt'); + + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + + return; + } + + try { + $this->validateOnly('scheduledAt', $this->formRules()); + } catch (\Illuminate\Validation\ValidationException) { + // Termin bleibt invalid; Bag wird automatisch befüllt. + } + } + /** * Live-Re-Validation für bereits invalide Felder. + * + * Die Termin-Synchronisierung liegt vollständig in den spezifischen + * `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt + * nur die generische Re-Validierung bereits fehlerhafter Felder. */ public function updated(string $property): void { - if (! $this->getErrorBag()->has($property)) { + if (!$this->getErrorBag()->has($property)) { return; } @@ -175,22 +276,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void { - $count = $exception - ? array_sum(array_map('count', $exception->errors())) - : count($this->getErrorBag()->all()); + $count = $exception ? array_sum(array_map('count', $exception->errors())) : count($this->getErrorBag()->all()); - Flux::toast( - heading: __('Bitte Eingaben prüfen'), - text: $count > 1 - ? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count]) - : __('Ein Feld benötigt deine Aufmerksamkeit.'), - variant: 'danger', - duration: 6000, - ); + Flux::toast(heading: __('Bitte Eingaben prüfen'), text: $count > 1 ? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count]) : __('Ein Feld benötigt deine Aufmerksamkeit.'), variant: 'danger', duration: 6000); } public function save(string $submitStatus = 'draft'): void { + $this->syncScheduledAt(); + $this->useEmbargo = false; + $this->embargoAt = null; + try { $this->validate($this->formRules()); } catch (\Illuminate\Validation\ValidationException $e) { @@ -204,7 +300,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex default => PressReleaseStatus::Draft, }; - $slug = (new PressRelease)->generateUniqueSlug($this->title, [ + $slug = new PressRelease()->generateUniqueSlug($this->title, [ 'portal' => $this->portal, 'language' => $this->language, ]); @@ -222,17 +318,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex 'subtitle' => trim($this->subtitle) ?: null, 'slug' => $slug, 'text' => $cleanText, - 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' - ? trim($this->boilerplateOverride) - : null, + 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null, 'keywords' => $this->keywords ?: null, 'backlink_url' => $this->backlinkUrl ?: null, - 'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt - ? \Carbon\Carbon::parse($this->scheduledAt) - : null, - 'embargo_at' => $this->useEmbargo && $this->embargoAt - ? \Carbon\Carbon::parse($this->embargoAt) - : null, + 'scheduled_at' => $this->publishMode === 'scheduled' ? $this->scheduledAtUtc() : null, + 'embargo_at' => null, 'status' => $status->value, 'no_export' => $this->noExport, ]); @@ -244,20 +334,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex } } - Flux::toast( - heading: $status === PressReleaseStatus::Review ? __('Eingereicht') : __('Entwurf gespeichert'), - text: $status === PressReleaseStatus::Review - ? __('Pressemitteilung zur Prüfung eingereicht.') - : __('Pressemitteilung als Entwurf gespeichert.'), - variant: 'success', - ); + Flux::toast(heading: $status === PressReleaseStatus::Review ? __('Eingereicht') : __('Entwurf gespeichert'), text: $status === PressReleaseStatus::Review ? __('Pressemitteilung zur Prüfung eingereicht.') : __('Pressemitteilung als Entwurf gespeichert.'), variant: 'success'); $this->redirect(route('admin.press-releases.edit', $pr->id), navigate: true); } public function with(): array { - $term = trim($this->companySearch); + $term = Portal::stripTrailingAbbreviation($this->companySearch); $companies = Company::withoutGlobalScopes() ->when(filled($term), function ($q) use ($term): void { @@ -267,27 +351,22 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex return; } - $q->where('name', 'like', '%'.$term.'%') - ->orWhere('slug', 'like', '%'.$term.'%'); + $q->where('name', 'like', '%' . $term . '%')->orWhere('slug', 'like', '%' . $term . '%'); }) - ->when(blank($term) && $this->companyId, fn ($q) => $q->whereIn('id', [(int) $this->companyId])) - ->when(blank($term) && ! $this->companyId, fn ($q) => $q->whereRaw('0 = 1')) + ->when(blank($term) && $this->companyId, fn($q) => $q->whereIn('id', [(int) $this->companyId])) + ->when(blank($term) && !$this->companyId, fn($q) => $q->whereRaw('0 = 1')) ->orderBy('name') ->limit(50) ->get(['id', 'name']); - $selectedCompany = $this->companyId - ? Company::withoutGlobalScopes()->find((int) $this->companyId) - : null; + $selectedCompany = $this->companyId ? Company::withoutGlobalScopes()->find((int) $this->companyId) : null; return [ 'companies' => $companies, 'categories' => $this->categoryOptions(), - 'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both), + 'portalOptions' => array_filter(Portal::cases(), fn(Portal $p) => $p !== Portal::Both), 'selectedCompany' => $selectedCompany, - 'selectedCompanyContacts' => $selectedCompany - ? $this->companyContacts((int) $selectedCompany->id) - : Contact::query()->whereRaw('0 = 1')->get(), + 'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), ]; } @@ -340,9 +419,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex 'key' => 'tags', 'status' => $tagsCount >= 1 ? 'ok' : 'warn', 'label' => __('Themen-Tags vergeben'), - 'sub' => $tagsCount >= 1 - ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) - : __('empfohlen für SEO & Auffindbarkeit'), + 'sub' => $tagsCount >= 1 ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) : __('empfohlen für SEO & Auffindbarkeit'), ], ]; } @@ -357,7 +434,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex } return collect(explode(',', $this->keywords)) - ->map(fn (string $tag): string => trim($tag)) + ->map(fn(string $tag): string => trim($tag)) ->filter() ->unique() ->values() @@ -391,10 +468,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex return null; } - return Contact::withoutGlobalScopes() - ->where('company_id', $companyId) - ->whereKey($contactId) - ->first(); + return Contact::withoutGlobalScopes()->where('company_id', $companyId)->whereKey($contactId)->first(); } /** @@ -402,43 +476,27 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex */ private function tagSuggestionsFor(?Company $company): array { - $defaults = [ - __('Mittelstand'), - __('Unternehmen'), - __('Eröffnung'), - __('Innovation'), - __('Nachhaltigkeit'), - ]; + $defaults = [__('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit')]; - if (! $company) { + if (!$company) { return $defaults; } - return array_values(array_unique(array_filter([ - $company->portal?->label(), - $company->country_code === 'DE' ? __('Deutschland') : null, - ...$defaults, - ]))); + return array_values(array_unique(array_filter([$company->portal?->label(), $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults]))); } private function categoryOptions(): Collection { - return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query() - ->with('translations') - ->where('is_active', true) - ->orderBy('id') - ->get()); + return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn() => Category::query()->with('translations')->where('is_active', true)->orderBy('id')->get()); } private function supportsFullTextSearch(string $term): bool { - return mb_strlen($term) >= 3 - && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true); + return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true); } - }; ?> -
+
{{-- ============== PAGE HEADER ============== --}}
@@ -455,39 +513,37 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- + {{ __('Zurück') }}
{{-- ============== 2-COLUMN GRID ============== --}} -
+
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
{{-- 1) FIRMA-SELEKTOR --}}
-
- {{ __('Für Firma') }} * -
- +
+
+ + {{ __('Für Firma') }} * + + - + @foreach ($companies as $company) - {{ $company->name }} + {{ $company->name }} @if ($company->portal) + ({{ $company->portal->abbreviation() }}) + @endif @endforeach @@ -501,16 +557,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- - {{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} - - - @if ($selectedCompany) - - {{ __('Firmenprofil') }} - - @endif +
+ + {{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} + + @if ($selectedCompany) + + {{ __('Firmenprofil') }} + + @endif +
@@ -525,7 +582,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@php $titleLen = mb_strlen($title); - $titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : ''); + $titleClass = + $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : ''); $titleBar = min(100, max(0, ($titleLen / 100) * 100)); @endphp @@ -535,11 +593,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{ __('KI-Titel · bald') }}
- +

{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}

@@ -553,7 +608,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('Untertitel') }} - + — {{ __('optional') }} @@ -566,10 +622,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{ $subLen }} / 200
- +
@@ -583,7 +637,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@php - $textLen = app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->plainTextLength($text); + $textLen = app( + \App\Services\PressRelease\PressReleaseHtmlSanitizer::class, + )->plainTextLength($text); $textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : ''); $textBar = min(100, max(0, ($textLen / 3500) * 100)); @endphp @@ -594,11 +650,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{ __('KI-Lektorat · bald') }}
- + placeholder="{{ __('Hier weiterschreiben…') }}" />
@@ -619,14 +673,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('Über das Unternehmen') }} - + — {{ __('Boilerplate aus Firma') }} - +
@if ($selectedCompany?->boilerplate) @@ -634,7 +687,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex

{!! nl2br(e($selectedCompany->boilerplate)) !!}

@if ($selectedCompany->website)

- {{ __('Web') }}: + {{ __('Web') }}: {{ $selectedCompany->website }}

@endif @@ -647,11 +701,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @if ($useBoilerplateOverride)
- +
@endif @@ -666,7 +717,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{-- /Schreibfläche --}} {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}} -
-
+
@php $okCount = collect($this->presubmitChecks)->where('status', 'ok')->count(); $totalCount = count($this->presubmitChecks); @@ -702,7 +754,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{ $check['label'] }} - @if (! empty($check['sub'])) + @if (!empty($check['sub'])) {{ $check['sub'] }} @endif @@ -710,14 +762,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @endforeach
- + {{ __('Zur Prüfung einreichen') }}

@@ -725,14 +771,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex


- + {{ __('Als Entwurf speichern') }}
@@ -751,11 +791,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @foreach ($categories as $cat) - + @endforeach - {{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }} + {{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }} +
@@ -785,14 +827,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- {{ __('Portal-Override') }} * + {{ __('Portal-Override') }} * + @foreach ($portalOptions as $p) @endforeach - {{ __('Admin-Befugnis: Portal kann unabhängig von der Firma geändert werden.') }} + + {{ __('Admin-Befugnis: Portal kann unabhängig von der Firma geändert werden.') }} +
@@ -812,7 +857,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @endif

@if ($selectedCompany) - {{ __('Kontakt im Firmenprofil anlegen') }} @@ -824,8 +869,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @foreach ($selectedCompanyContacts as $contact) @php - $contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) - ?: __('Kontakt #:n', ['n' => $contact->id]); + $contactName = + trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?: + __('Kontakt #:n', ['n' => $contact->id]); $contactRole = $contact->responsibility ?: __('Kontakt'); @endphp
@@ -1005,10 +1045,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- + {{ __('Phase 2 — bald') }}
-
    +
    • · {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}
    • · {{ __('Versionshistorie & Kommentare') }}
    • · {{ __('Portal-Vorschau (presseecho vs. BP24)') }}
    • diff --git a/resources/views/livewire/admin/press-releases/edit.blade.php b/resources/views/livewire/admin/press-releases/edit.blade.php index dc782fd..0bb1120 100644 --- a/resources/views/livewire/admin/press-releases/edit.blade.php +++ b/resources/views/livewire/admin/press-releases/edit.blade.php @@ -2,6 +2,8 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; +use App\Jobs\ClassifyPressRelease; +use App\Jobs\ScorePressRelease; use App\Models\Category; use App\Models\Company; use App\Models\Contact; @@ -21,8 +23,7 @@ use Livewire\Attributes\Locked; use Livewire\Attributes\Title; use Livewire\Volt\Component; -new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component -{ +new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component { #[Locked] public int $id; @@ -58,6 +59,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl public ?string $scheduledAt = null; + public ?string $scheduledDate = null; + + public ?string $scheduledTime = null; + public bool $useEmbargo = false; public ?string $embargoAt = null; @@ -66,6 +71,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl public string $targetStatus = ''; + /** + * Treiber für den manuellen KI-Re-Check: 'default' nutzt den konfigurierten + * Anbieter, sonst expliziter Override (z. B. 'openai'|'deterministic'). + */ + public string $kiProvider = 'default'; + + public bool $kiRunClassification = true; + + public bool $kiRunContentScore = false; + public function mount(int $id): void { $this->id = $id; @@ -87,17 +102,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->currentStatus = $pr->status->value; $this->targetStatus = $this->currentStatus; - $this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id - ?? $this->defaultContactIdFor((int) $pr->company_id); + $this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id ?? $this->defaultContactIdFor((int) $pr->company_id); if ($pr->scheduled_at) { + // DB-Wert ist UTC; für die Eingabefelder nach Europe/Berlin wandeln. + $scheduledAt = $pr->scheduled_at->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE); $this->publishMode = 'scheduled'; - $this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i'); - } - - if ($pr->embargo_at) { - $this->useEmbargo = true; - $this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i'); + $this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i'); + $this->scheduledDate = $scheduledAt->format('Y-m-d'); + $this->scheduledTime = $scheduledAt->format('H:i'); } } @@ -106,9 +119,33 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->resetErrorBag('companyId'); } + public function updatedPublishMode(): void + { + $this->syncScheduledAt(); + + if ($this->publishMode === 'now') { + $this->scheduledDate = null; + $this->scheduledTime = null; + $this->scheduledAt = null; + $this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']); + } + } + + public function updatedScheduledDate(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + + public function updatedScheduledTime(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + public function updatedCompanyId(): void { - if (! $this->companyId) { + if (!$this->companyId) { $this->contactId = null; return; @@ -116,7 +153,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId); - if (! $contactStillValid) { + if (!$contactStillValid) { $this->contactId = $this->defaultContactIdFor((int) $this->companyId); } @@ -149,10 +186,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl public function removeTag(string $tag): void { - $existing = array_values(array_filter( - $this->tagsArray(), - fn (string $existingTag): bool => $existingTag !== $tag, - )); + $existing = array_values(array_filter($this->tagsArray(), fn(string $existingTag): bool => $existingTag !== $tag)); $this->keywords = implode(', ', $existing); @@ -165,7 +199,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl protected function formRules(): array { $rules = [ - 'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))], + 'portal' => ['required', Rule::in(array_map(fn(Portal $p) => $p->value, Portal::cases()))], 'language' => ['required', Rule::in(['de', 'en'])], 'companyId' => ['required', 'integer', Rule::exists('companies', 'id')], 'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')], @@ -180,23 +214,101 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl ]; if ($this->publishMode === 'scheduled') { - $rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()]; + $rules['scheduledDate'] = ['required', 'date']; + $rules['scheduledTime'] = ['required', 'date_format:H:i']; + $rules['scheduledAt'] = [ + 'required', + 'date', + // Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen- + // bewusst prüfen statt über die naive `after:`-Regel. + function (string $attribute, mixed $value, \Closure $fail): void { + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + } + }, + ]; } else { + $rules['scheduledDate'] = ['nullable']; + $rules['scheduledTime'] = ['nullable']; $rules['scheduledAt'] = ['nullable']; } - if ($this->useEmbargo) { - $rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()]; - } else { - $rules['embargoAt'] = ['nullable']; - } + $rules['embargoAt'] = ['nullable']; return $rules; } + protected function syncScheduledAt(): void + { + if ($this->publishMode !== 'scheduled') { + $this->scheduledAt = null; + + return; + } + + if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) { + $scheduledAt = \Carbon\Carbon::parse($this->scheduledAt); + $this->scheduledDate = $scheduledAt->format('Y-m-d'); + $this->scheduledTime = $scheduledAt->format('H:i'); + + return; + } + + if (blank($this->scheduledDate) || blank($this->scheduledTime)) { + $this->scheduledAt = null; + + return; + } + + $this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}"; + } + + /** + * Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt, + * wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt. + */ + protected function scheduledAtUtc(): ?\Carbon\Carbon + { + if (blank($this->scheduledAt)) { + return null; + } + + return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc(); + } + + protected function validateScheduledAtWhenReady(): void + { + if (blank($this->scheduledAt)) { + return; + } + + $this->resetErrorBag('scheduledAt'); + + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + + return; + } + + try { + $this->validateOnly('scheduledAt', $this->formRules()); + } catch (\Illuminate\Validation\ValidationException) { + // Termin bleibt invalid. + } + } + + /** + * Die Termin-Synchronisierung liegt vollständig in den spezifischen + * `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt + * nur die generische Re-Validierung bereits fehlerhafter Felder. + */ public function updated(string $property): void { - if (! $this->getErrorBag()->has($property)) { + if (!$this->getErrorBag()->has($property)) { return; } @@ -209,22 +321,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void { - $count = $exception - ? array_sum(array_map('count', $exception->errors())) - : count($this->getErrorBag()->all()); + $count = $exception ? array_sum(array_map('count', $exception->errors())) : count($this->getErrorBag()->all()); - Flux::toast( - heading: __('Bitte Eingaben prüfen'), - text: $count > 1 - ? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count]) - : __('Ein Feld benötigt deine Aufmerksamkeit.'), - variant: 'danger', - duration: 6000, - ); + Flux::toast(heading: __('Bitte Eingaben prüfen'), text: $count > 1 ? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count]) : __('Ein Feld benötigt deine Aufmerksamkeit.'), variant: 'danger', duration: 6000); } public function save(): void { + $this->syncScheduledAt(); + $this->useEmbargo = false; + $this->embargoAt = null; + try { $this->validate($this->formRules()); } catch (\Illuminate\Validation\ValidationException $e) { @@ -255,20 +362,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'subtitle' => trim($this->subtitle) ?: null, 'slug' => $slug, 'text' => $cleanText, - 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' - ? trim($this->boilerplateOverride) - : null, + 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null, 'keywords' => $this->keywords ?: null, 'backlink_url' => $this->backlinkUrl ?: null, - 'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt - ? \Carbon\Carbon::parse($this->scheduledAt) - : null, - 'embargo_at' => $this->useEmbargo && $this->embargoAt - ? \Carbon\Carbon::parse($this->embargoAt) - : null, + 'scheduled_at' => $this->publishMode === 'scheduled' ? $this->scheduledAtUtc() : null, + 'embargo_at' => null, 'no_export' => $this->noExport, ]); + $contentChanged = $pr->wasChanged(['title', 'text']); + if ($this->contactId) { $contact = $this->companyContact((int) $this->contactId, (int) $this->companyId); if ($contact) { @@ -276,6 +379,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl } } + // Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu + // prüfen (Re-Check ohne Routing) und neu bewerten. + if ($contentChanged) { + $service = app(PressReleaseService::class); + $fresh = $pr->fresh(); + $service->reclassifyIfClassified($fresh); + $service->rescoreIfScored($fresh); + } + Flux::toast(text: __('Pressemitteilung gespeichert.'), variant: 'success'); } @@ -287,12 +399,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->submitForReview($pr); } catch (BlacklistViolationException $e) { $this->currentStatus = PressReleaseStatus::Rejected->value; - Flux::toast( - heading: __('Automatisch abgelehnt'), - text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), - variant: 'danger', - duration: 8000, - ); + Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000); return; } @@ -301,6 +408,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl Flux::toast(text: __('Zur Prüfung eingereicht.'), variant: 'success'); } + /** + * Stößt eine manuelle KI-Prüfung im Hintergrund an (Re-Check). + * + * Anders als beim Einreichen wird hier NICHT geroutet (Status bleibt + * unverändert): Das Ergebnis aktualisiert nur Klassifikation + Audit, die + * Entscheidung trifft der Admin weiterhin selbst. + */ + public function runKiCheck(): void + { + if (! $this->kiRunClassification && ! $this->kiRunContentScore) { + Flux::toast(text: __('Es wurde keine Prüfung ausgewählt.'), variant: 'warning'); + + return; + } + + $provider = $this->kiProvider === 'default' ? null : $this->kiProvider; + + if ($this->kiRunClassification) { + ClassifyPressRelease::dispatch($this->id, route: false, providerOverride: $provider) + ->onQueue('classification'); + } + + if ($this->kiRunContentScore) { + ScorePressRelease::dispatch($this->id, providerOverride: $provider) + ->onQueue('classification'); + } + + Flux::modal('admin-ki-check')->close(); + Flux::toast( + heading: __('KI-Prüfung gestartet'), + text: __('Die Prüfung läuft im Hintergrund. Das Ergebnis erscheint nach Abschluss in der Detailansicht.'), + variant: 'success', + duration: 7000, + ); + } + public function publish(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); @@ -309,12 +452,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->publish($pr); } catch (BlacklistViolationException $e) { $this->currentStatus = PressReleaseStatus::Rejected->value; - Flux::toast( - heading: __('Automatisch abgelehnt'), - text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), - variant: 'danger', - duration: 8000, - ); + Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000); return; } @@ -351,7 +489,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl public function changeStatus(): void { $this->validate([ - 'targetStatus' => ['required', Rule::in(array_map(fn (PressReleaseStatus $status) => $status->value, PressReleaseStatus::cases()))], + 'targetStatus' => ['required', Rule::in(array_map(fn(PressReleaseStatus $status) => $status->value, PressReleaseStatus::cases()))], ]); if ($this->targetStatus === $this->currentStatus) { @@ -379,19 +517,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->deleteFromAdmin($pr); - Flux::toast( - text: $wasPublished - ? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.') - : __('Pressemitteilung wurde gelöscht.'), - variant: 'success', - ); + Flux::toast(text: $wasPublished ? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.') : __('Pressemitteilung wurde gelöscht.'), variant: 'success'); $this->redirect(route('admin.press-releases.index'), navigate: true); } public function with(): array { - $term = trim($this->companySearch); + $term = Portal::stripTrailingAbbreviation($this->companySearch); $companies = Company::withoutGlobalScopes() ->when(filled($term), function ($q) use ($term): void { @@ -401,31 +534,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl return; } - $q->where('name', 'like', '%'.$term.'%')->orWhere('slug', 'like', '%'.$term.'%'); + $q->where('name', 'like', '%' . $term . '%')->orWhere('slug', 'like', '%' . $term . '%'); }) - ->when(blank($term) && $this->companyId, fn ($q) => $q->whereIn('id', [(int) $this->companyId])) - ->when(blank($term) && ! $this->companyId, fn ($q) => $q->whereRaw('0 = 1')) + ->when(blank($term) && $this->companyId, fn($q) => $q->whereIn('id', [(int) $this->companyId])) + ->when(blank($term) && !$this->companyId, fn($q) => $q->whereRaw('0 = 1')) ->orderBy('name') ->limit(50) ->get(['id', 'name']); $statusEnum = PressReleaseStatus::tryFrom($this->currentStatus); - $selectedCompany = $this->companyId - ? Company::withoutGlobalScopes()->find((int) $this->companyId) - : null; + $selectedCompany = $this->companyId ? Company::withoutGlobalScopes()->find((int) $this->companyId) : null; return [ 'companies' => $companies, 'categories' => $this->categoryOptions(), - 'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both), + 'portalOptions' => array_filter(Portal::cases(), fn(Portal $p) => $p !== Portal::Both), 'statusOptions' => PressReleaseStatus::cases(), 'statusEnum' => $statusEnum, 'targetStatusEnum' => PressReleaseStatus::tryFrom($this->targetStatus), 'selectedCompany' => $selectedCompany, - 'selectedCompanyContacts' => $selectedCompany - ? $this->companyContacts((int) $selectedCompany->id) - : Contact::query()->whereRaw('0 = 1')->get(), + 'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'statusColor' => match ($this->currentStatus) { 'published' => 'green', @@ -485,9 +614,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'key' => 'tags', 'status' => $tagsCount >= 1 ? 'ok' : 'warn', 'label' => __('Themen-Tags vergeben'), - 'sub' => $tagsCount >= 1 - ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) - : __('empfohlen für SEO & Auffindbarkeit'), + 'sub' => $tagsCount >= 1 ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) : __('empfohlen für SEO & Auffindbarkeit'), ], ]; } @@ -502,7 +629,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl } return collect(explode(',', $this->keywords)) - ->map(fn (string $tag): string => trim($tag)) + ->map(fn(string $tag): string => trim($tag)) ->filter() ->unique() ->values() @@ -536,10 +663,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl return null; } - return Contact::withoutGlobalScopes() - ->where('company_id', $companyId) - ->whereKey($contactId) - ->first(); + return Contact::withoutGlobalScopes()->where('company_id', $companyId)->whereKey($contactId)->first(); } /** @@ -547,43 +671,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl */ private function tagSuggestionsFor(?Company $company): array { - $defaults = [ - __('Mittelstand'), - __('Unternehmen'), - __('Eröffnung'), - __('Innovation'), - __('Nachhaltigkeit'), - ]; + $defaults = [__('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit')]; - if (! $company) { + if (!$company) { return $defaults; } - return array_values(array_unique(array_filter([ - $company->portal?->label(), - $company->country_code === 'DE' ? __('Deutschland') : null, - ...$defaults, - ]))); + return array_values(array_unique(array_filter([$company->portal?->label(), $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults]))); } private function categoryOptions(): Collection { - return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query() - ->with('translations') - ->where('is_active', true) - ->orderBy('id') - ->get()); + return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn() => Category::query()->with('translations')->where('is_active', true)->orderBy('id')->get()); } private function supportsFullTextSearch(string $term): bool { - return mb_strlen($term) >= 3 - && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true); + return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true); } - }; ?> -
      +
      @php $statusClass = match ($currentStatus) { 'published' => 'ok', @@ -613,42 +721,92 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      - + + + {{ __('Prüfung') }} + + + {{ __('Vorschau / Detail') }} - + {{ __('Zur Liste') }}
      + {{-- ============== KI-PRÜFUNG (On-Demand) ============== --}} + +
      +
      + {{ __('KI-Prüfung') }} + {{ __('Prüfung im Hintergrund starten') }} + + {{ __('Startet eine erneute KI-Prüfung. Das Ergebnis aktualisiert nur die Bewertung und das Audit-Log – der Status bleibt unverändert.') }} + +
      + +
      + + + + + + + + + +
      + +
      + + {{ __('Abbrechen') }} + + + {{ __('Prüfung starten') }} + +
      +
      +
      + {{-- ============== 2-COLUMN GRID ============== --}} -
      +
      {{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
      {{-- 1) FIRMA-SELEKTOR --}}
      -
      - {{ __('Für Firma') }} * -
      - +
      +
      + + {{ __('Für Firma') }} * + + - + @foreach ($companies as $company) - {{ $company->name }} + {{ $company->name }} @if ($company->portal) + ({{ $company->portal->abbreviation() }}) + @endif @endforeach @@ -662,20 +820,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      - - {{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} - - - @if ($selectedCompany) - - {{ __('Firmenprofil') }} - - @endif +
      + + {{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} + + @if ($selectedCompany) + + {{ __('Firmenprofil') }} + + @endif +
      + {{-- 2) TITEL --}}
      @@ -686,7 +846,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      @php $titleLen = mb_strlen($title); - $titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : ''); + $titleClass = + $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : ''); $titleBar = min(100, max(0, ($titleLen / 100) * 100)); @endphp @@ -696,11 +857,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ __('KI-Titel · bald') }}
      - +

      {{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}

      @@ -714,7 +872,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      {{ __('Untertitel') }} - + — {{ __('optional') }} @@ -727,10 +886,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ $subLen }} / 200
      - +
      @@ -744,7 +901,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      @php - $textLen = app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->plainTextLength($text); + $textLen = app( + \App\Services\PressRelease\PressReleaseHtmlSanitizer::class, + )->plainTextLength($text); $textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : ''); $textBar = min(100, max(0, ($textLen / 3500) * 100)); @endphp @@ -755,11 +914,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ __('KI-Lektorat · bald') }}
      - + placeholder="{{ __('Hier weiterschreiben…') }}" />
      @@ -790,14 +947,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      {{ __('Über das Unternehmen') }} - + — {{ __('Boilerplate aus Firma') }} - +
      @if ($selectedCompany?->boilerplate) @@ -805,7 +961,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl

      {!! nl2br(e($selectedCompany->boilerplate)) !!}

      @if ($selectedCompany->website)

      - {{ __('Web') }}: + {{ __('Web') }}: {{ $selectedCompany->website }}

      @endif @@ -818,11 +975,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl @if ($useBoilerplateOverride)
      - +
      @endif @@ -837,7 +991,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{-- /Schreibfläche --}} {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}} -
      -
      +
      @php $okCount = collect($this->presubmitChecks)->where('status', 'ok')->count(); $totalCount = count($this->presubmitChecks); @@ -873,7 +1028,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ $check['label'] }} - @if (! empty($check['sub'])) + @if (!empty($check['sub'])) {{ $check['sub'] }} @endif @@ -914,11 +1069,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl @foreach ($categories as $cat) - + @endforeach - {{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }} + {{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }} +
      @@ -948,14 +1105,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
      - {{ __('Portal-Override') }} * + {{ __('Portal-Override') }} * + @foreach ($portalOptions as $p) @endforeach - {{ __('Admin-Befugnis: Portal kann unabhängig von der Firma geändert werden.') }} + + {{ __('Admin-Befugnis: Portal kann unabhängig von der Firma geändert werden.') }} +
      @@ -971,7 +1131,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ __('Diese Firma hat noch keine Pressekontakte.') }}

      @if ($selectedCompany) - {{ __('Kontakt im Firmenprofil anlegen') }} @@ -983,8 +1143,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl @foreach ($selectedCompanyContacts as $contact) @php - $contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) - ?: __('Kontakt #:n', ['n' => $contact->id]); + $contactName = + trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?: + __('Kontakt #:n', ['n' => $contact->id]); $contactRole = $contact->responsibility ?: __('Kontakt'); @endphp

      {{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}

      + @if ($latestClassification && $latestClassification->reason) +

      + {{ __('KI-Hinweis') }}: + {{ $latestClassification->reason }} +

      + @endif @if ($pr->scheduled_at)

      - {{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduled_at->format('d.m.Y H:i')]) }} + {{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduledAtLocal()->format('d.m.Y H:i')]) }}

      @endif
      @@ -243,7 +295,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @if ($pr->embargo_at && $pr->embargo_at->isFuture())

      - {{ __('Sperrfrist bis: :date', ['date' => $pr->embargo_at->format('d.m.Y H:i')]) }} + {{ __('Sperrfrist bis: :date', ['date' => $pr->embargoAtLocal()->format('d.m.Y H:i')]) }}

      @endif @if ($pr->hits > 0) @@ -254,7 +306,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @endif
      - {{ __('Archivieren') }} + {{ __('Archivieren') }}
      @@ -266,7 +318,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
      {{ __('Zugeordnete Pressekontakte') }} @if ($pr->company) - + {{ __('Firma') }} @endif @@ -340,7 +392,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
      {{ __('Geplant') }}
      - {{ $pr->scheduled_at->format('d.m.Y H:i') }} + {{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
      @endif @@ -348,7 +400,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
      {{ __('Sperrfrist bis') }}
      - {{ $pr->embargo_at->format('d.m.Y H:i') }} + {{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
      @endif diff --git a/resources/views/livewire/admin/reports/slow-requests.blade.php b/resources/views/livewire/admin/reports/slow-requests.blade.php index f1af59b..d3b6524 100644 --- a/resources/views/livewire/admin/reports/slow-requests.blade.php +++ b/resources/views/livewire/admin/reports/slow-requests.blade.php @@ -76,7 +76,7 @@ new #[Layout('components.layouts.app'), Title('Performance Reports')] class exte
      {{ __('Filter') }} - + {{ __('Filter zurücksetzen') }}
      diff --git a/resources/views/livewire/admin/roles/create.blade.php b/resources/views/livewire/admin/roles/create.blade.php index 3d46794..0d67c35 100644 --- a/resources/views/livewire/admin/roles/create.blade.php +++ b/resources/views/livewire/admin/roles/create.blade.php @@ -90,7 +90,7 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
      - + {{ __('Zurück') }}
      @@ -143,7 +143,7 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
      - + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/roles/edit.blade.php b/resources/views/livewire/admin/roles/edit.blade.php index 66de9b7..d85928e 100644 --- a/resources/views/livewire/admin/roles/edit.blade.php +++ b/resources/views/livewire/admin/roles/edit.blade.php @@ -116,7 +116,7 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
      - + {{ __('Zurück') }}
      @@ -178,7 +178,7 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
      - + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/roles/index.blade.php b/resources/views/livewire/admin/roles/index.blade.php index 22f3705..5518119 100644 --- a/resources/views/livewire/admin/roles/index.blade.php +++ b/resources/views/livewire/admin/roles/index.blade.php @@ -87,7 +87,7 @@ new #[Layout('components.layouts.app'), Title('Rollen & Rechte')] class extends @if (\Illuminate\Support\Facades\Route::has('admin.roles.edit')) - + @endif diff --git a/resources/views/livewire/admin/users.blade.php b/resources/views/livewire/admin/users.blade.php index 02cd0b8..78b2b82 100644 --- a/resources/views/livewire/admin/users.blade.php +++ b/resources/views/livewire/admin/users.blade.php @@ -359,7 +359,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone @if ($search || $activeFilter !== 'all' || $portalFilter !== 'all' || $roleFilter !== 'all' || $qualityFilter !== 'all' || $permissionFilter !== 'all')
      {{ __('Filter aktiv') }} - + {{ __('Zurücksetzen') }}
      @@ -464,14 +464,14 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
      - - @if($canLoginAsUser) - {{ __('Schließen') }} + {{ __('Schließen') }}
      @@ -663,7 +663,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone @if($selectedUser->published_press_releases_count > 0)
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit')) - + {{ __('Bearbeiten') }} @endif diff --git a/resources/views/livewire/admin/users/create.blade.php b/resources/views/livewire/admin/users/create.blade.php index 6550d78..5270821 100644 --- a/resources/views/livewire/admin/users/create.blade.php +++ b/resources/views/livewire/admin/users/create.blade.php @@ -246,7 +246,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
- + {{ __('Zurück') }}
@@ -365,7 +365,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends - {{ __('Entfernen') }} @@ -439,7 +439,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/users/edit.blade.php b/resources/views/livewire/admin/users/edit.blade.php index 789217a..9866b77 100644 --- a/resources/views/livewire/admin/users/edit.blade.php +++ b/resources/views/livewire/admin/users/edit.blade.php @@ -739,7 +739,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
- + {{ __('Zurück') }}
@@ -1035,7 +1035,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte {{ __('Owner') }} - + {{ __('Entfernen') }} @@ -1113,7 +1113,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/admin/users/show.blade.php b/resources/views/livewire/admin/users/show.blade.php index 28675e0..764909b 100644 --- a/resources/views/livewire/admin/users/show.blade.php +++ b/resources/views/livewire/admin/users/show.blade.php @@ -92,7 +92,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
- + {{ __('Zurück') }} @@ -221,7 +221,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
{{ $contact->portal?->label() ?? '—' }} @if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit')) - {{ __('Bearbeiten') }} diff --git a/resources/views/livewire/admin/users/table.blade.php b/resources/views/livewire/admin/users/table.blade.php index f5fda67..bf675e8 100644 --- a/resources/views/livewire/admin/users/table.blade.php +++ b/resources/views/livewire/admin/users/table.blade.php @@ -118,7 +118,7 @@ new #[Layout('components.layouts.app'), Title('Benutzertabelle')] class extends - + New post diff --git a/resources/views/livewire/components/press-release-attachments-manager.blade.php b/resources/views/livewire/components/press-release-attachments-manager.blade.php index 060d520..da7e9bc 100644 --- a/resources/views/livewire/components/press-release-attachments-manager.blade.php +++ b/resources/views/livewire/components/press-release-attachments-manager.blade.php @@ -80,6 +80,12 @@ new class extends Component Flux::toast(text: __('Anhang hochgeladen.'), variant: 'success'); } + public function removeNewFile(): void + { + $this->reset('newFile'); + $this->resetErrorBag('newFile'); + } + public function startEdit(int $attachmentId): void { $attachment = $this->getAttachment($attachmentId); @@ -252,16 +258,31 @@ new class extends Component @if ($canEdit)
-
- - {{ __('Datei') }} * - - - + + + + + + @if ($newFile) + + + + + + @endif + +
{{ __('Hochladen') }} {{ __('Lädt…') }} @@ -308,7 +329,7 @@ new class extends Component
- {{ __('Abbrechen') }} + {{ __('Abbrechen') }} {{ __('Speichern') }}
@@ -359,17 +380,17 @@ new class extends Component
@if ($attachment->url()) - {{ __('Download') }} @endif @if ($canEdit) - - - + + + - @endif
diff --git a/resources/views/livewire/components/press-release-images-manager.blade.php b/resources/views/livewire/components/press-release-images-manager.blade.php index cc0375a..a46915b 100644 --- a/resources/views/livewire/components/press-release-images-manager.blade.php +++ b/resources/views/livewire/components/press-release-images-manager.blade.php @@ -1,11 +1,12 @@ pressReleaseId = $pressReleaseId; } - public function upload(ImageService $imageService): void + public function openUploadForm(): void + { + $this->isUploadFormOpen = true; + } + + public function closeUploadForm(): void + { + $this->resetUploadForm(); + $this->resetErrorBag(); + + $this->isUploadFormOpen = false; + } + + public function saveImage(ImageService $imageService): void { $pressRelease = $this->getPressRelease(); $this->authorize('update', $pressRelease); - if (! $this->canChangeImages($pressRelease)) { - $this->addError('newImage', __('Bilder können nur bei Entwürfen oder abgelehnten PMs geändert werden.')); + if (!$this->canChangeImages($pressRelease)) { + $this->addError('newImage', __('Das Titelbild kann nur bei Entwürfen oder abgelehnten PMs geändert werden.')); return; } - $this->validate([ - 'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:'.(int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)], - 'newTitle' => ['nullable', 'string', 'max:120'], - 'newCopyright' => ['nullable', 'string', 'max:255'], - ]); + if ($this->titleImageFor($pressRelease) !== null) { + $this->addError('newImage', __('Bitte löschen Sie zuerst das vorhandene Titelbild.')); + + return; + } + + $licenseType = ImageLicenseType::tryFrom($this->newLicenseType); + $requiresLicenseUrl = $licenseType?->requiresLicenseUrl() ?? false; + $requiresLicenseDetail = $licenseType?->requiresLicenseDetail() ?? false; + + $this->validate( + [ + 'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:' . (int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)], + 'newTitle' => ['nullable', 'string', 'max:120'], + 'newCopyright' => ['nullable', 'string', 'max:255'], + 'newAuthor' => ['required', 'string', 'max:255'], + 'newLicenseType' => ['required', Rule::enum(ImageLicenseType::class)], + 'newLicenseDetail' => [$requiresLicenseDetail ? 'required' : 'nullable', 'string', 'max:120'], + 'newLicenseUrl' => [$requiresLicenseUrl ? 'required' : 'nullable', 'url', 'max:2048'], + 'newSourceUrl' => ['nullable', 'url', 'max:2048'], + 'newPeopleRightsStatus' => ['required', Rule::in(array_keys($this->peopleRightsOptions()))], + 'newPropertyRightsStatus' => ['required', Rule::in(array_keys($this->propertyRightsOptions()))], + 'newRightsNotes' => ['nullable', 'string', 'max:1000'], + 'newRightsConfirmed' => ['accepted'], + ], + [ + 'newAuthor.required' => __('Bitte Urheber, Fotograf oder Rechteinhaber angeben.'), + 'newLicenseType.required' => __('Bitte einen Lizenztyp wählen.'), + 'newLicenseDetail.required' => __('Bitte die Lizenz genauer angeben.'), + 'newLicenseUrl.required' => __('Für diesen Lizenztyp ist eine Nachweis-URL erforderlich.'), + 'newPeopleRightsStatus.required' => __('Bitte angeben, ob erkennbare Personen abgebildet sind.'), + 'newPropertyRightsStatus.required' => __('Bitte angeben, ob Marken, Kunstwerke oder private Orte sichtbar sind.'), + 'newRightsConfirmed.accepted' => __('Bitte bestätigen, dass die Bildrechte geklärt sind.'), + ], + ); $stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id); - if ($this->newIsPreview) { - $pressRelease->images()->update(['is_preview' => false]); - } + $pressRelease->images()->update(['is_preview' => false]); $pressRelease->images()->create([ 'disk' => 'public', @@ -65,43 +124,48 @@ new class extends Component 'variants' => $stored['variants'], 'title' => $this->newTitle ?: null, 'copyright' => $this->newCopyright ?: null, - 'is_preview' => $this->newIsPreview, + 'author' => $this->newAuthor, + 'license_type' => $this->newLicenseType, + 'license_detail' => $this->newLicenseDetail ?: null, + 'license_url' => $this->newLicenseUrl ?: null, + 'source_url' => $this->newSourceUrl ?: null, + 'persons_consent' => $this->newPeopleRightsStatus === 'consent', + 'people_rights_status' => $this->newPeopleRightsStatus, + 'property_rights_status' => $this->newPropertyRightsStatus, + 'rights_notes' => $this->newRightsNotes ?: null, + 'rights_confirmed_at' => now(), + 'is_preview' => true, 'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1, 'width' => $stored['width'], 'height' => $stored['height'], 'mime' => $stored['mime'], ]); - $this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']); + $this->resetUploadForm(); + $this->isUploadFormOpen = false; - Flux::toast(text: __('Bild hochgeladen.'), variant: 'success'); + $this->dispatch('title-image-changed'); + + Flux::toast(text: __('Titelbild hochgeladen.'), variant: 'success'); } - public function setPreview(int $imageId): void + public function removeNewImage(): void { - $pressRelease = $this->getPressRelease(); - $this->authorize('update', $pressRelease); + $this->reset('newImage'); + $this->resetErrorBag('newImage'); + } - $image = $pressRelease->images()->whereKey($imageId)->first(); - - if (! $image) { - return; + public function newImagePreviewUrl(): ?string + { + if (!is_object($this->newImage) || !method_exists($this->newImage, 'temporaryUrl')) { + return null; } - $pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]); - $image->update(['is_preview' => true]); - - Flux::toast(text: __('Vorschaubild gesetzt.'), variant: 'success'); - } - - public function moveUp(int $imageId): void - { - $this->swapSortOrder($imageId, -1); - } - - public function moveDown(int $imageId): void - { - $this->swapSortOrder($imageId, 1); + try { + return $this->newImage->temporaryUrl(); + } catch (\Throwable) { + return null; + } } public function remove(int $imageId, ImageService $imageService): void @@ -109,20 +173,22 @@ new class extends Component $pressRelease = $this->getPressRelease(); $this->authorize('update', $pressRelease); - if (! $this->canChangeImages($pressRelease)) { + if (!$this->canChangeImages($pressRelease)) { return; } $image = $pressRelease->images()->whereKey($imageId)->first(); - if (! $image) { + if (!$image) { return; } $imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); $image->delete(); - Flux::toast(text: __('Bild entfernt.'), variant: 'success'); + $this->dispatch('title-image-changed'); + + Flux::toast(text: __('Titelbild entfernt.'), variant: 'success'); } public function with(): array @@ -130,149 +196,273 @@ new class extends Component $pressRelease = $this->getPressRelease(); return [ - 'images' => $pressRelease->images() - ->orderBy('sort_order') - ->orderBy('id') - ->get(), - 'canEdit' => auth()->user()?->can('update', $pressRelease) === true - && $this->canChangeImages($pressRelease), + 'titleImage' => $this->titleImageFor($pressRelease), + 'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease), + 'licenseTypeOptions' => ImageLicenseType::options(), + 'ccLicenseOptions' => $this->ccLicenseOptions(), + 'peopleRightsOptions' => $this->peopleRightsOptions(), + 'propertyRightsOptions' => $this->propertyRightsOptions(), + 'licenseUrlRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseUrl() ?? false, + 'licenseDetailRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseDetail() ?? false, + 'showsCcWarning' => $this->newLicenseType === ImageLicenseType::CreativeCommons->value, + 'showsRightsWarning' => $this->shouldShowRightsWarning(), ]; } - private function swapSortOrder(int $imageId, int $direction): void - { - $pressRelease = $this->getPressRelease(); - $this->authorize('update', $pressRelease); - - if (! $this->canChangeImages($pressRelease)) { - return; - } - - $images = $pressRelease->images()->orderBy('sort_order')->orderBy('id')->get(); - $currentIndex = $images->search(fn (PressReleaseImage $image) => $image->id === $imageId); - - if ($currentIndex === false) { - return; - } - - $targetIndex = $currentIndex + $direction; - - if ($targetIndex < 0 || $targetIndex >= $images->count()) { - return; - } - - $current = $images[$currentIndex]; - $target = $images[$targetIndex]; - - $currentSort = $current->sort_order; - $current->update(['sort_order' => $target->sort_order]); - $target->update(['sort_order' => $currentSort]); - } - private function getPressRelease(): PressRelease { - return PressRelease::withoutGlobalScopes() - ->findOrFail($this->pressReleaseId); + return PressRelease::withoutGlobalScopes()->findOrFail($this->pressReleaseId); } private function canChangeImages(PressRelease $pressRelease): bool { if (auth()->user()?->canAccessAdmin()) { - return ! in_array( - $pressRelease->status, - [PressReleaseStatus::Archived], - true, - ); + return !in_array($pressRelease->status, [PressReleaseStatus::Archived], true); } - return in_array( - $pressRelease->status, - [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], - true, - ); + return in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true); + } + + private function titleImageFor(PressRelease $pressRelease): ?PressReleaseImage + { + return $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(); + } + + private function resetUploadForm(): void + { + $this->reset(['newImage', 'newTitle', 'newCopyright', 'newAuthor', 'newLicenseType', 'newLicenseDetail', 'newLicenseUrl', 'newSourceUrl', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsNotes', 'newRightsConfirmed']); + } + + /** + * @return array + */ + private function ccLicenseOptions(): array + { + return [ + 'cc0' => 'CC0', + 'cc_by' => 'CC BY', + 'cc_by_sa' => 'CC BY-SA', + 'cc_by_nd' => 'CC BY-ND', + 'cc_by_nc' => 'CC BY-NC', + 'cc_by_nc_sa' => 'CC BY-NC-SA', + 'cc_by_nc_nd' => 'CC BY-NC-ND', + ]; + } + + /** + * @return array + */ + private function peopleRightsOptions(): array + { + return [ + 'none' => __('Nein, keine erkennbaren Personen'), + 'consent' => __('Ja, Einwilligung liegt vor'), + 'public_event' => __('Ja, öffentliche Veranstaltung / redaktioneller Kontext'), + ]; + } + + /** + * @return array + */ + private function propertyRightsOptions(): array + { + return [ + 'none' => __('Nein'), + 'cleared' => __('Ja, Rechte / Nutzung sind geklärt'), + ]; + } + + private function shouldShowRightsWarning(): bool + { + $restrictedCcLicense = str_contains($this->newLicenseDetail, '_nc') || str_contains($this->newLicenseDetail, '_nd'); + + return $this->newLicenseType === ImageLicenseType::Other->value || $restrictedCcLicense; } }; ?>
- {{ __('Bilder') }} - {{ count($images) }} + {{ __('Titelbild') }} + + {{ $titleImage ? __('gesetzt') : __('Platzhalter aktiv') }} +
- @if($canEdit) - - {{ __('Neues Bild hinzufügen') }} - - + @if ($titleImage) +
+
+ @php + $titleImageUrl = + $titleImage->variantUrl('cover') ?? + ($titleImage->variantUrl('large') ?? ($titleImage->variantUrl('medium') ?? $titleImage->url())); + @endphp -
- - + @if ($titleImageUrl) + {{ $titleImage->title ?? __('Titelbild') }} + @endif
- +
+
+
+
+ {{ $titleImage->title ?: __('Eigenes Titelbild') }} +
+ @if ($titleImage->author) +

© + {{ $titleImage->author }}

+ @endif + @if ($titleImage->copyright) +

+ {{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }} +

+ @endif +
+ @if ($titleImage->license_type) + {{ $titleImage->license_type->label() }} + + @endif + @if ($titleImage->width && $titleImage->height) + {{ $titleImage->width }}×{{ $titleImage->height }} + @endif +
+
-
+ @if ($canEdit) + + {{ __('Titelbild löschen') }} + + @endif +
+
+
+ @elseif($canEdit) + @if (!$isUploadFormOpen) +
+
+ {{ __('Hier fehlt ein Titelbild') }} + + {{ __('Der Platzhalter bleibt aktiv, bis ein eigenes Titelbild hochgeladen wurde.') }} + +
+ + + {{ __('Eigenes Titelbild hochladen') }} + +
+ @else + + {{ __('Titelbild hochladen') }} + + + + + +
+ {{ __('Bitte laden Sie nur Bilder hoch, für die Sie die erforderlichen Nutzungsrechte besitzen. Bilder aus Google, Social Media, Messenger-Gruppen oder fremden Websites dürfen nicht ohne ausdrückliche Erlaubnis verwendet werden.') }} +
+ + @if ($newImage) + + + + + + @endif + + + + + + + + + {{ __('Bitte wählen…') }} + @foreach ($licenseTypeOptions as $value => $label) + {{ $label }} + @endforeach + + + @if ($newLicenseType === \App\Enums\ImageLicenseType::CreativeCommons->value) + + {{ __('Bitte wählen…') }} + @foreach ($ccLicenseOptions as $value => $label) + {{ $label }} + @endforeach + + +
+ {{ __('Creative-Commons-Lizenzen können Einschränkungen enthalten. Bitte prüfen Sie, ob kommerzielle Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt sind.') }} +
+ @elseif($licenseDetailRequired) + + @endif + + + + + + + @foreach ($peopleRightsOptions as $value => $label) + + @endforeach + + + @if (in_array($newPeopleRightsStatus, ['consent', 'public_event'], true)) +
+ {{ __('Bei erkennbaren Personen können zusätzlich Persönlichkeits- oder Datenschutzrechte betroffen sein. Bitte stellen Sie sicher, dass die Veröffentlichung zulässig ist.') }} +
+ @endif + + + @foreach ($propertyRightsOptions as $value => $label) + + @endforeach + + + @if ($showsRightsWarning) +
+ {{ __('Diese Auswahl kann Einschränkungen enthalten. Bitte laden Sie das Bild nur hoch, wenn die Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt ist.') }} +
+ @endif + + + + + +
+ {{ __('Abbrechen') }} {{ __('Hochladen') }}
- @endif - - @if($images->isEmpty()) + @endif + @else
- {{ __('Noch keine Bilder hinterlegt.') }} -
- @else -
- @foreach($images as $image) -
-
- @if($image->variantUrl('thumb') ?? $image->url()) - {{ $image->title ?? '' }} - @endif - @if($image->is_preview) - - {{ __('Vorschau') }} - - @endif -
- -
- @if($image->title) -

{{ $image->title }}

- @endif - @if($image->copyright) -

{{ $image->copyright }}

- @endif -
- @if($image->width && $image->height) - {{ $image->width }}×{{ $image->height }} - @endif - @if(is_array($image->variants)) - {{ count($image->variants) }}× variant - @endif -
- - @if($canEdit) -
- @if(! $image->is_preview) - - @endif - - - -
- @endif -
-
- @endforeach + + {{ __('Noch kein eigenes Titelbild hinterlegt. Der Platzhalter bleibt aktiv.') }}
@endif diff --git a/resources/views/livewire/components/press-release-placeholder-picker.blade.php b/resources/views/livewire/components/press-release-placeholder-picker.blade.php new file mode 100644 index 0000000..79e0c41 --- /dev/null +++ b/resources/views/livewire/components/press-release-placeholder-picker.blade.php @@ -0,0 +1,82 @@ +selected = PressReleasePlaceholder::fromValueOrDefault($current)->value; + } + + public function choose(string $variant): void + { + $this->selected = PressReleasePlaceholder::fromValueOrDefault($variant)->value; + } + + public function confirm(): void + { + $this->dispatch('placeholder-selected', variant: $this->selected); + + Flux::modal('placeholder-picker')->close(); + } + + public function with(): array + { + return [ + 'variants' => PressReleasePlaceholder::cases(), + ]; + } +}; ?> + + +
+
+ {{ __('Titelbild-Platzhalter wählen') }} + + {{ __('Wird verwendet, solange kein eigenes Titelbild hochgeladen ist.') }} + +
+ +
+ @foreach ($variants as $variant) + + @endforeach +
+ +
+ + {{ __('Abbrechen') }} + + + {{ __('Übernehmen') }} + +
+
+
diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index bfca14f..923c1aa 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -116,7 +116,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
- + {{ __('Rechnungen') }} @@ -229,7 +229,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @endif - + {{ __('Kaufen') }} diff --git a/resources/views/livewire/customer/company-switcher.blade.php b/resources/views/livewire/customer/company-switcher.blade.php index 7077c22..49a8306 100644 --- a/resources/views/livewire/customer/company-switcher.blade.php +++ b/resources/views/livewire/customer/company-switcher.blade.php @@ -78,7 +78,7 @@ new class extends Component @if ($selectedCompany) @else - + {{ __('Firmen') }} @endif diff --git a/resources/views/livewire/customer/dashboard.blade.php b/resources/views/livewire/customer/dashboard.blade.php index 18eded6..a6773ac 100644 --- a/resources/views/livewire/customer/dashboard.blade.php +++ b/resources/views/livewire/customer/dashboard.blade.php @@ -515,7 +515,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C {{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
- + {{ __('Firmen öffnen') }}
diff --git a/resources/views/livewire/customer/invoices.blade.php b/resources/views/livewire/customer/invoices.blade.php index 59c7b82..829cc93 100644 --- a/resources/views/livewire/customer/invoices.blade.php +++ b/resources/views/livewire/customer/invoices.blade.php @@ -76,7 +76,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
- + {{ __('Rechnungsadresse im Profil pflegen') }}
@@ -192,7 +192,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo {{ __('Sobald Rechnungen aus dem Archiv oder aus neuen Buchungen vorhanden sind, erscheinen sie hier.') }}

- + {{ __('Rechnungsadresse prüfen') }}
diff --git a/resources/views/livewire/customer/press-kits/create.blade.php b/resources/views/livewire/customer/press-kits/create.blade.php index afd91ca..580fbea 100644 --- a/resources/views/livewire/customer/press-kits/create.blade.php +++ b/resources/views/livewire/customer/press-kits/create.blade.php @@ -136,7 +136,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
- + {{ __('Zurück zur Liste') }}
@@ -207,7 +207,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
- + {{ __('Abbrechen') }} diff --git a/resources/views/livewire/customer/press-kits/index.blade.php b/resources/views/livewire/customer/press-kits/index.blade.php index cf3f9d8..d48826e 100644 --- a/resources/views/livewire/customer/press-kits/index.blade.php +++ b/resources/views/livewire/customer/press-kits/index.blade.php @@ -452,7 +452,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
- + {{ __('Export') }} {{ __('bald') }} @@ -829,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
- + {{ __('Zurück') }} @if ($canManageCompany) - + {{ __('Stammdaten bearbeiten') }} @endif @@ -375,7 +375,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{{ __('Stammdaten') }} @if ($canManageCompany) - + {{ __('Bearbeiten') }} @endif @@ -438,21 +438,41 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{{ $company->name }} - + {{ __('Logo entfernen') }}
@endif - + :description="__('JPG/PNG/WebP/GIF, max. 4 MB. Varianten werden automatisch generiert.')"> + + + + @if ($companyLogo) + + + + + + @endif
- + {{ __('Abbrechen') }} @@ -548,7 +568,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
- + {{ __('Abbrechen') }} @@ -585,10 +605,10 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component @if ($canManageContacts)
- + {{ __('Bearbeiten') }} - {{ __('Löschen') }} @@ -618,7 +638,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{{ __('Pressemitteilungen dieser Firma') }} - + {{ __('Alle anzeigen') }}
@@ -653,7 +673,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component - {{ __('Öffnen') }} @@ -701,7 +721,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component

{{ __('Rechnungen finden Sie aktuell gesammelt im Finanzbereich. Firmenscharfe Zahlungsarten folgen mit dem Preismodell.') }}

- + {{ __('Rechnungen öffnen') }}
diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index aa7fa78..bd51e6a 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -1,6 +1,7 @@ user(); $context = app(CustomerCompanyContext::class); - $firstCompany = $context->selectedCompany($user) ?? $context->companiesFor($user)->first(); + $firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first(); if ($firstCompany) { $this->companyId = $firstCompany->id; $this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value; $this->contactId = $this->defaultContactIdFor((int) $firstCompany->id); } + + $this->placeholderVariant = PressReleasePlaceholder::default()->value; + } + + #[On('placeholder-selected')] + public function setPlaceholderVariant(string $variant): void + { + $this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value; } public function updatedCompanyId(): void @@ -79,11 +97,44 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex unset($this->tags, $this->presubmitChecks); } + public function updatedCompanySearch(): void + { + $this->resetErrorBag('companyId'); + } + + public function updatedPublishMode(): void + { + $this->syncScheduledAt(); + + if ($this->publishMode === 'now') { + $this->scheduledDate = null; + $this->scheduledTime = null; + $this->scheduledAt = null; + $this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']); + } + } + + public function updatedScheduledDate(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + + public function updatedScheduledTime(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + /** * Live-Re-Validation: sobald für ein Property bereits ein Error im Bag * liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein * roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und * der User muss nicht erst auf „Entwurf speichern" klicken. + * + * Die Termin-Synchronisierung liegt vollständig in den spezifischen + * `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt + * nur die generische Re-Validierung bereits fehlerhafter Felder. */ public function updated(string $property): void { @@ -145,20 +196,93 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex // Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min) // die PM verlässlich rechtzeitig fängt. if ($this->publishMode === 'scheduled') { - $rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()]; + $rules['scheduledDate'] = ['required', 'date']; + $rules['scheduledTime'] = ['required', 'date_format:H:i']; + $rules['scheduledAt'] = [ + 'required', + 'date', + // Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen- + // bewusst prüfen statt über die naive `after:`-Regel. + function (string $attribute, mixed $value, \Closure $fail): void { + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + } + }, + ]; } else { + $rules['scheduledDate'] = ['nullable']; + $rules['scheduledTime'] = ['nullable']; $rules['scheduledAt'] = ['nullable']; } - if ($this->useEmbargo) { - $rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()]; - } else { - $rules['embargoAt'] = ['nullable']; - } + $rules['embargoAt'] = ['nullable']; return $rules; } + protected function syncScheduledAt(): void + { + if ($this->publishMode !== 'scheduled') { + $this->scheduledAt = null; + + return; + } + + if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) { + $scheduledAt = \Carbon\Carbon::parse($this->scheduledAt); + $this->scheduledDate = $scheduledAt->format('Y-m-d'); + $this->scheduledTime = $scheduledAt->format('H:i'); + + return; + } + + if (blank($this->scheduledDate) || blank($this->scheduledTime)) { + $this->scheduledAt = null; + + return; + } + + $this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}"; + } + + /** + * Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt, + * wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt. + */ + protected function scheduledAtUtc(): ?\Carbon\Carbon + { + if (blank($this->scheduledAt)) { + return null; + } + + return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc(); + } + + protected function validateScheduledAtWhenReady(): void + { + if (blank($this->scheduledAt)) { + return; + } + + $this->resetErrorBag('scheduledAt'); + + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + + return; + } + + try { + $this->validateOnly('scheduledAt', $this->formRules()); + } catch (\Illuminate\Validation\ValidationException) { + // Termin bleibt invalid — Error-Bag wird automatisch befüllt. + } + } + public function addTag(string $tag): void { $tag = trim($tag); @@ -195,8 +319,77 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex unset($this->tags, $this->presubmitChecks); } + /** + * Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und + * Einstellungen schon vor dem finalen Speichern gepflegt werden + * können. Erfordert nur Firma und Kategorie (category_id ist NOT NULL), + * alle übrigen Felder werden – soweit erfasst – übernommen. Anschließend + * wird in den vollwertigen Editor (Edit-Seite) weitergeleitet, wo der + * Bild-Manager direkt zur Verfügung steht. + */ + public function ensureDraft(): void + { + try { + $this->validate([ + 'companyId' => ['required', 'integer'], + 'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')], + ], [ + 'companyId.required' => __('Bitte zuerst eine Firma wählen, bevor du ein Titelbild hochlädst.'), + 'categoryId.required' => __('Bitte zuerst eine Kategorie wählen, bevor du ein Titelbild hochlädst.'), + ]); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->notifyValidationError($e); + + throw $e; + } + + $user = auth()->user(); + $company = $this->selectedCompany(); + + if (! $company) { + $this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.')); + $this->notifyValidationError(); + + return; + } + + $this->portal = $company->portal?->value ?? Portal::Presseecho->value; + + $slug = (new PressRelease)->generateUniqueSlug($this->title ?: __('Entwurf'), [ + 'portal' => $this->portal, + 'language' => $this->language, + ]); + + $pr = PressRelease::query()->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + ...$this->pressReleaseAttributes($slug), + ]); + + if ($this->contactId) { + $contact = $this->companyContact((int) $this->contactId, (int) $company->id); + + if ($contact) { + $pr->contacts()->sync([$contact->id]); + } + } + + Flux::toast( + heading: __('Entwurf gesichert'), + text: __('Du kannst jetzt ein Titelbild hochladen und alle Einstellungen vornehmen. Der Entwurf liegt unter „Meine PMs".'), + variant: 'success', + ); + + $this->redirect(route('me.press-releases.edit', $pr->id), navigate: true); + } + public function save(string $submitStatus = 'draft'): void { + $this->syncScheduledAt(); + $this->useEmbargo = false; + $this->embargoAt = null; + try { $this->validate($this->formRules()); } catch (\Illuminate\Validation\ValidationException $e) { @@ -237,31 +430,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex 'language' => $this->language, ]); - $cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text); - $pr = PressRelease::query()->create([ 'uuid' => (string) Str::uuid(), - 'portal' => $this->portal, - 'language' => $this->language, 'user_id' => $user->id, - 'company_id' => (int) $this->companyId, - 'category_id' => (int) $this->categoryId, - 'title' => $this->title, - 'subtitle' => trim($this->subtitle) ?: null, - 'slug' => $slug, - 'text' => $cleanText, - 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' - ? trim($this->boilerplateOverride) - : null, - 'keywords' => $this->keywords ?: null, - 'backlink_url' => $this->backlinkUrl ?: null, - 'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt - ? \Carbon\Carbon::parse($this->scheduledAt) - : null, - 'embargo_at' => $this->useEmbargo && $this->embargoAt - ? \Carbon\Carbon::parse($this->embargoAt) - : null, 'status' => $status->value, + ...$this->pressReleaseAttributes($slug), ]); if ($contact) { @@ -281,12 +454,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); } + /** + * Gemeinsame Spaltenwerte für Create- und Auto-Draft-Anlage. + * + * @return array + */ + private function pressReleaseAttributes(string $slug): array + { + return [ + 'portal' => $this->portal, + 'language' => $this->language, + 'company_id' => (int) $this->companyId, + 'category_id' => (int) $this->categoryId, + 'title' => trim($this->title) !== '' ? $this->title : __('Unbenannter Entwurf'), + 'subtitle' => trim($this->subtitle) ?: null, + 'slug' => $slug, + 'text' => app(PressReleaseHtmlSanitizer::class)->clean($this->text), + 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' + ? trim($this->boilerplateOverride) + : null, + 'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value, + 'keywords' => $this->keywords ?: null, + 'backlink_url' => $this->backlinkUrl ?: null, + 'scheduled_at' => $this->publishMode === 'scheduled' + ? $this->scheduledAtUtc() + : null, + 'embargo_at' => null, + ]; + } + public function with(): array { $user = auth()->user(); $context = app(CustomerCompanyContext::class); - $myCompanies = $context->companiesFor($user); $selectedCompany = $this->selectedCompany(); + $companyOptions = $context->searchCompaniesFor( + $user, + $this->companySearch, + $this->companyId ? (int) $this->companyId : null, + 10, + ); $categories = Category::query() ->with('translations') @@ -295,7 +502,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex ->get(); return [ - 'myCompanies' => $myCompanies, + 'companyOptions' => $companyOptions, 'categories' => $categories, 'selectedCompany' => $selectedCompany, 'selectedCompanyContacts' => $selectedCompany @@ -303,6 +510,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex : Contact::query()->whereRaw('0 = 1')->get(), 'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), + 'quotaTotal' => (int) $user->press_release_quota, + 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), ]; } @@ -442,7 +651,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex } }; ?> -
+
{{-- ============== PAGE HEADER ============== --}}
@@ -460,38 +669,64 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- + {{ __('Zur Liste') }}
{{-- ============== 2-COLUMN GRID ============== --}} -
+
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
{{-- 1) FIRMA-SELEKTOR --}}
-
- {{ __('Für Firma') }} - - - @foreach ($myCompanies as $c) - - @endforeach - - - {{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }} - - - @if ($selectedCompany) - - {{ __('Firmenprofil') }} - - @endif +
+
+ {{ __('Für Firma') }} + + + + + @foreach ($companyOptions as $company) + + {{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }} + + @endforeach + + + @if (blank(trim($companySearch))) + {{ __('Keine Firma verfügbar.') }} + @else + {{ __('Keine Firma gefunden.') }} + @endif + + + +
+
+ + {{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }} + + @if ($selectedCompany) + + {{ __('Firmenprofil') }} + + @endif +
@@ -594,27 +829,62 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- {{-- 5) MEDIEN (nach Speichern verfügbar) --}} + {{-- 5) TITELBILD --}}
- {{ __('Medien / Bilder') }} - - — {{ __('nach Speichern verfügbar') }} - + {{ __('Titelbild') }} {{ __('KI-Bildgenerierung · bald') }}
+ + {{-- Titelbild-Platzhalter (bis ein eigenes Bild hochgeladen ist) --}} +
+
+ {{ __('Titelbild-Platzhalter') }} + + + {{ __('Platzhalter wählen') }} + + +
+ {{-- Anzeige analog Detailansicht: max. 1280×580, zentriert begrenzt --}} + +

+ {{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }} +

+
+

- {{ __('Sobald die Pressemitteilung als Entwurf gespeichert ist, kannst du Bilder hinzufügen, ein Titelbild festlegen und Bildunterschriften/Alt-Texte pflegen.') }} + {{ __('Lade ein eigenes Titelbild hoch. Dafür wird automatisch ein Entwurf gesichert — danach kannst du alles weiter bearbeiten.') }} +

+ + {{ __('Titelbild hochladen & Entwurf sichern') }} + +

+ {{ __('Erfordert nur Firma + Kategorie. Du landest danach im Editor mit Bild-Upload.') }}

+ {{-- Titelbild-Platzhalter-Auswahl --}} + + {{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT Datei-Uploads erfordern eine vollständige Sicherheitsprüfung (Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten). @@ -689,7 +959,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{-- /Schreibfläche --}} {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}} -
- - {{ __('Zur Prüfung senden') }} - + + + {{ __('Zur Prüfung senden') }} + +

{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}

@@ -750,7 +1021,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@if ($selectedCompany) - {{ __('Kontakt im Firmenprofil anlegen') }} @@ -952,39 +1223,31 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @if ($publishMode === 'scheduled') - - {{ __('Veröffentlichungstermin') }} - - {{ __('Frühestens 5 Min. in der Zukunft.') }} - - - @endif - -
- -

- {{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }} -

- - @if ($useEmbargo) - - {{ __('Sperrfrist bis') }} - + + {{ __('Datum') }} + - + - @endif -
+ + + {{ __('Uhrzeit') }} + + + +
+ +

{{ __('Frühestens 5 Min. in der Zukunft.') }}

+ @endif
@@ -1027,4 +1290,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
+ + {{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}} +
diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 64e2212..6d70f24 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -1,6 +1,7 @@ id = $id; @@ -72,6 +89,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl ); $this->currentStatus = $pr->status->value; + $this->contentScore = $pr->content_score; + $this->contentTier = $pr->content_tier?->value; $this->portal = $pr->portal->value; $this->language = $pr->language; $this->companyId = $pr->company_id; @@ -87,14 +106,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl ?? $this->defaultContactIdFor((int) $pr->company_id); if ($pr->scheduled_at) { + // DB-Wert ist UTC; für die Eingabefelder nach Europe/Berlin wandeln. + $scheduledAt = $pr->scheduled_at->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE); $this->publishMode = 'scheduled'; - $this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i'); + $this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i'); + $this->scheduledDate = $scheduledAt->format('Y-m-d'); + $this->scheduledTime = $scheduledAt->format('H:i'); } - if ($pr->embargo_at) { - $this->useEmbargo = true; - $this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i'); - } + $this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value; + } + + #[On('placeholder-selected')] + public function setPlaceholderVariant(string $variant): void + { + $this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value; + } + + #[On('title-image-changed')] + public function refreshTitleImage(): void + { + unset($this->tags, $this->presubmitChecks); } public function updatedCompanyId(): void @@ -118,6 +150,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl unset($this->tags, $this->presubmitChecks); } + public function updatedCompanySearch(): void + { + $this->resetErrorBag('companyId'); + } + + public function updatedPublishMode(): void + { + $this->syncScheduledAt(); + + if ($this->publishMode === 'now') { + $this->scheduledDate = null; + $this->scheduledTime = null; + $this->scheduledAt = null; + $this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']); + } + } + + public function updatedScheduledDate(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + + public function updatedScheduledTime(): void + { + $this->syncScheduledAt(); + $this->validateScheduledAtWhenReady(); + } + public function addTag(string $tag): void { $tag = trim($tag); @@ -159,6 +220,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl * liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein * roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und * der User muss nicht erst auf „Speichern" klicken. + * + * Die Termin-Synchronisierung liegt vollständig in den spezifischen + * `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt + * nur die generische Re-Validierung bereits fehlerhafter Felder. */ public function updated(string $property): void { @@ -214,22 +279,99 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl ]; if ($this->publishMode === 'scheduled') { - $rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()]; + $rules['scheduledDate'] = ['required', 'date']; + $rules['scheduledTime'] = ['required', 'date_format:H:i']; + $rules['scheduledAt'] = [ + 'required', + 'date', + // Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen- + // bewusst prüfen statt über die naive `after:`-Regel. + function (string $attribute, mixed $value, \Closure $fail): void { + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + } + }, + ]; } else { + $rules['scheduledDate'] = ['nullable']; + $rules['scheduledTime'] = ['nullable']; $rules['scheduledAt'] = ['nullable']; } - if ($this->useEmbargo) { - $rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()]; - } else { - $rules['embargoAt'] = ['nullable']; - } + $rules['embargoAt'] = ['nullable']; return $rules; } + protected function syncScheduledAt(): void + { + if ($this->publishMode !== 'scheduled') { + $this->scheduledAt = null; + + return; + } + + if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) { + $scheduledAt = \Carbon\Carbon::parse($this->scheduledAt); + $this->scheduledDate = $scheduledAt->format('Y-m-d'); + $this->scheduledTime = $scheduledAt->format('H:i'); + + return; + } + + if (blank($this->scheduledDate) || blank($this->scheduledTime)) { + $this->scheduledAt = null; + + return; + } + + $this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}"; + } + + /** + * Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt, + * wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt. + */ + protected function scheduledAtUtc(): ?\Carbon\Carbon + { + if (blank($this->scheduledAt)) { + return null; + } + + return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc(); + } + + protected function validateScheduledAtWhenReady(): void + { + if (blank($this->scheduledAt)) { + return; + } + + $this->resetErrorBag('scheduledAt'); + + $scheduledAt = $this->scheduledAtUtc(); + + if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { + $this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); + + return; + } + + try { + $this->validateOnly('scheduledAt', $this->formRules()); + } catch (\Illuminate\Validation\ValidationException) { + // Termin bleibt invalid — Error-Bag wird automatisch befüllt. + } + } + public function save(bool $submitAfterSave = false): void { + $this->syncScheduledAt(); + $this->useEmbargo = false; + $this->embargoAt = null; + try { $this->validate($this->formRules()); } catch (\Illuminate\Validation\ValidationException $e) { @@ -278,16 +420,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null, + 'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value, 'keywords' => $this->keywords ?: null, 'backlink_url' => $this->backlinkUrl ?: null, - 'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt - ? \Carbon\Carbon::parse($this->scheduledAt) - : null, - 'embargo_at' => $this->useEmbargo && $this->embargoAt - ? \Carbon\Carbon::parse($this->embargoAt) + 'scheduled_at' => $this->publishMode === 'scheduled' + ? $this->scheduledAtUtc() : null, + 'embargo_at' => null, ]); + $contentChanged = $pr->wasChanged(['title', 'text']); + $pr->contacts()->sync($contact ? [$contact->id] : []); if ($submitAfterSave) { @@ -314,6 +457,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl variant: 'success', ); } else { + // Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu + // prüfen (Re-Check ohne Routing) und neu bewerten. Beim Einreichen + // übernimmt das submitForReview. + if ($contentChanged) { + $service = app(PressReleaseService::class); + $fresh = $pr->fresh(); + $service->reclassifyIfClassified($fresh); + $service->rescoreIfScored($fresh); + } + Flux::toast( heading: __('Gespeichert'), text: __('Deine Änderungen sind gesichert.'), @@ -333,17 +486,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl { $user = auth()->user(); $context = app(CustomerCompanyContext::class); - $myCompanies = $context->companiesFor($user); $selectedCompany = $this->selectedCompany(); + $companyOptions = $context->searchCompaniesFor( + $user, + $this->companySearch, + $this->companyId ? (int) $this->companyId : null, + 10, + ); $categories = Category::query() ->with('translations') ->where('is_active', true) ->orderBy('id') ->get(); + $pressRelease = $this->getMyPR()->load('images'); + $cover = app(PressReleaseCoverImage::class); return [ - 'myCompanies' => $myCompanies, + 'companyOptions' => $companyOptions, 'categories' => $categories, 'selectedCompany' => $selectedCompany, 'selectedCompanyContacts' => $selectedCompany @@ -351,6 +511,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl : Contact::query()->whereRaw('0 = 1')->get(), 'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), + 'coverUrl' => $cover->coverUrl($pressRelease, 'cover'), + 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease), + 'quotaTotal' => (int) $user->press_release_quota, + 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), ]; } @@ -453,7 +617,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl private function getMyPR(): PressRelease { - return PressRelease::withoutGlobalScopes() + // Pro Livewire-Request memoisiert: mount(), with() und save() greifen + // sonst jeweils mit einer eigenen Query auf dieselbe PM zu. + return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes() ->where('user_id', auth()->id()) ->findOrFail($this->id); } @@ -495,7 +661,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl } }; ?> -
+
{{-- ============== PAGE HEADER ============== --}}
@@ -518,17 +684,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
- + {{ __('Vorschau / Detail') }} - + {{ __('Zur Liste') }}
{{-- ============== 2-COLUMN GRID ============== --}} -
+
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
@@ -537,18 +703,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{{ __('Für Firma') }} - - - @foreach ($myCompanies as $c) - - @endforeach - +
+ + + + + @foreach ($companyOptions as $company) + + {{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }} + + @endforeach + + + @if (blank(trim($companySearch))) + {{ __('Keine Firma verfügbar.') }} + @else + {{ __('Keine Firma gefunden.') }} + @endif + + + +
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} @if ($selectedCompany) - {{ __('Firmenprofil') }} @@ -655,7 +845,32 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
- {{-- 5) MEDIEN — Image-Manager direkt eingebunden --}} + @if ($coverIsPlaceholder) + {{-- 5) TITELBILD-PLATZHALTER --}} +
+
+
+ {{ __('Titelbild-Platzhalter') }} + + + {{ __('Platzhalter wählen') }} + + +
+ +

+ {{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }} +

+
+
+ + + @endif + + {{-- 6) MEDIEN — Image-Manager direkt eingebunden --}} {{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT @@ -718,7 +933,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{-- /Schreibfläche --}} {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}} -
- - {{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }} - + + + {{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }} + +

{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}

@@ -784,7 +999,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
+ {{-- Content-Score (Qualitäts-Feedback während des Schreibens) --}} + @if (! is_null($contentScore)) + @php + $tierEnum = $contentTier ? \App\Enums\PressReleaseContentTier::from($contentTier) : null; + $tiers = config('scoring.content_score.tiers'); + $nextThreshold = $contentScore < (int) $tiers['gepruft'] + ? (int) $tiers['gepruft'] + : ($contentScore < (int) $tiers['hochwertig'] ? (int) $tiers['hochwertig'] : null); + @endphp +
+
+ {{ __('Qualität') }} + @if ($tierEnum) + {{ $tierEnum->label() }} + @endif +
+
+
+ {{ $contentScore }}/100 +
+ @if ($nextThreshold) +

+ {{ __('Noch :points Punkte bis zur nächsten Stufe.', ['points' => $nextThreshold - $contentScore]) }} +

+ @else +

+ {{ __('Höchste Stufe erreicht.') }} +

+ @endif +

+ {{ __('Der Score wird nach dem Speichern automatisch neu berechnet.') }} +

+
+
+ @endif + {{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
@@ -856,7 +1107,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ __('Diese Firma hat noch keine Pressekontakte.') }}

@if ($selectedCompany) - {{ __('Kontakt im Firmenprofil anlegen') }} @@ -986,39 +1237,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl @if ($publishMode === 'scheduled') - - {{ __('Veröffentlichungstermin') }} - - {{ __('Frühestens 5 Min. in der Zukunft.') }} - - - @endif - -
- -

- {{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }} -

- - @if ($useEmbargo) - - {{ __('Sperrfrist bis') }} - + + {{ __('Datum') }} + - + - @endif -
+ + + {{ __('Uhrzeit') }} + + + +
+ +

{{ __('Frühestens 5 Min. in der Zukunft.') }}

+ @endif
@@ -1061,4 +1304,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
+ + {{-- Einreichungs-Modal (öffnet über „Speichern & zur Prüfung") --}} +
diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 688541a..980812d 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -505,23 +505,23 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class @if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
- {{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }} + {{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}
@endif @if ($pr->embargo_at && $pr->embargo_at->isFuture())
- {{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }} + {{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}
@endif
- @if (in_array($status, ['draft', 'rejected'])) - @endif
diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 27f9cc1..21b0e26 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -4,6 +4,7 @@ use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Services\Auth\MagicLinkGenerator; use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseService; use Flux\Flux; use Livewire\Attributes\Layout; @@ -35,6 +36,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends try { app(PressReleaseService::class)->submitForReview($pr); } catch (BlacklistViolationException $e) { + Flux::modal('confirm-submit-review')->close(); + Flux::toast( heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]), @@ -45,6 +48,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends return; } + Flux::modal('confirm-submit-review')->close(); + Flux::toast( heading: __('Eingereicht'), text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'), @@ -78,9 +83,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends ->firstWhere(fn ($log) => $log->to_status?->value === 'rejected'); } + $cover = app(PressReleaseCoverImage::class); + $user = auth()->user(); + return [ 'pr' => $pr, 'categoryName' => $categoryName, + 'coverUrl' => $cover->coverUrl($pr, 'cover'), + 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), + 'quotaTotal' => (int) $user->press_release_quota, + 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'canEdit' => auth()->user()->can('update', $pr) && in_array($pr->status->value, ['draft', 'rejected']), 'latestRejection' => $latestRejection, @@ -133,6 +145,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends {{ __('User Backend') }} {{ __('Mein Bereich · Pressemitteilung') }} {{ $pr->status->label() }} + @if ($pr->content_tier?->isPubliclyBadged()) + + {{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? '★ ' : '✓ ' }}{{ $pr->content_tier->label() }} + + @endif {{ strtoupper($pr->language) }}

@@ -154,19 +171,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($canEdit) - + {{ __('Bearbeiten') }} @endif - + {{ __('Vorschau-Link') }} - + {{ __('Zurück') }}
+ {{-- ============== TITELBILD (Hero) ============== --}} + {{-- Harte Obergrenze 1280x580 px: Container deckelt Breite und Seitenverhältnis, + damit das Bild auf großen Screens nicht über die Detailgröße hinauswächst. --}} +
+
+ {{ $pr->title }} +
+ @if ($coverIsPlaceholder) +
+ + {{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }} +
+ @endif +
+ {{-- ============== SHARE-LINK ERFOLG ============== --}} @if ($shareUrl)
@@ -224,14 +257,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends

@if ($canEdit) - + {{ __('Bearbeiten') }} @endif - - {{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }} - + + + {{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }} + +

@@ -261,7 +295,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Zugeordnete Pressekontakte') }} @if ($pr->company) - + {{ __('Firma') }} @endif @@ -341,7 +375,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Geplante Veröffentlichung') }}
- {{ $pr->scheduled_at->format('d.m.Y H:i') }} + {{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
@endif @@ -349,7 +383,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Sperrfrist bis') }}
- {{ $pr->embargo_at->format('d.m.Y H:i') }} + {{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
@endif @@ -434,6 +468,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
+ {{-- ============== VERÖFFENTLICHUNGS-MODAL ============== --}} + @if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected) + + @endif + {{-- ============== BOILERPLATE-OVERRIDE ============== --}} @if ($pr->boilerplate_override)
diff --git a/resources/views/livewire/customer/profile.blade.php b/resources/views/livewire/customer/profile.blade.php index c530e6a..62cac42 100644 --- a/resources/views/livewire/customer/profile.blade.php +++ b/resources/views/livewire/customer/profile.blade.php @@ -199,7 +199,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp

- + {{ __('Firmen verwalten') }}
@@ -315,7 +315,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
- + {{ __('Konto-Sicherheit öffnen') }}
diff --git a/resources/views/livewire/customer/security.blade.php b/resources/views/livewire/customer/security.blade.php index 9f5fea1..7386030 100644 --- a/resources/views/livewire/customer/security.blade.php +++ b/resources/views/livewire/customer/security.blade.php @@ -291,7 +291,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends @endif
- + {{ __('Neue Wiederherstellungs-Codes erzeugen') }} diff --git a/resources/views/livewire/customer/tokens.blade.php b/resources/views/livewire/customer/tokens.blade.php index 49fc1c6..35ee952 100644 --- a/resources/views/livewire/customer/tokens.blade.php +++ b/resources/views/livewire/customer/tokens.blade.php @@ -102,7 +102,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
- + {{ __('API-Dokumentation') }}
diff --git a/routes/api.php b/routes/api.php index 65d686b..a12d894 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,8 @@ Route::prefix('v1') ->group(function (): void { Route::apiResource('press-releases', PressReleaseController::class) ->parameters(['press-releases' => 'pressRelease']); + Route::post('press-releases/{pressRelease}/submit', [PressReleaseController::class, 'submit']) + ->name('press-releases.submit'); Route::get('press-releases/{pressRelease}/images', [PressReleaseImageController::class, 'index']) ->name('press-releases.images.index'); Route::post('press-releases/{pressRelease}/images', [PressReleaseImageController::class, 'store']) diff --git a/routes/console.php b/routes/console.php index 8f683c4..0f2ada6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,7 @@ use App\Console\Commands\PublishScheduledPressReleases; use App\Console\Commands\PurgeExpiredPressReleaseDrafts; use App\Console\Commands\PurgeMagicLinks; +use App\Console\Commands\ResetMonthlyPressReleaseQuota; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; @@ -39,3 +40,13 @@ Schedule::command(PublishScheduledPressReleases::class) ->everyFiveMinutes() ->withoutOverlapping() ->runInBackground(); + +// ======================================== +// PM-Kontingent (Stub bis zum echten Tarif-Modul) +// ======================================== + +// Monatlicher Reset des verbrauchten PM-Kontingents (am 1. um 00:05). +Schedule::command(ResetMonthlyPressReleaseQuota::class) + ->monthlyOn(1, '00:05') + ->withoutOverlapping() + ->runInBackground(); diff --git a/tests/Feature/Admin/AdminKiCheckTest.php b/tests/Feature/Admin/AdminKiCheckTest.php new file mode 100644 index 0000000..00ae60a --- /dev/null +++ b/tests/Feature/Admin/AdminKiCheckTest.php @@ -0,0 +1,101 @@ +seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); +}); + +test('admin editor exposes the KI check button and modal', function () { + /** @var TestCase $this */ + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + + LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id]) + ->assertSee('Prüfung') + ->assertSee('Prüfung im Hintergrund starten'); +}); + +test('runKiCheck dispatches a non-routing classification job with provider override', function () { + /** @var TestCase $this */ + Queue::fake(); + + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + + LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id]) + ->set('kiProvider', 'deterministic') + ->call('runKiCheck') + ->assertHasNoErrors(); + + Queue::assertPushedOn('classification', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) { + return $job->pressReleaseId === $pressRelease->id + && $job->route === false + && $job->providerOverride === 'deterministic'; + }); +}); + +test('runKiCheck dispatches a scoring job when content score is selected', function () { + /** @var TestCase $this */ + Queue::fake(); + + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + + LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id]) + ->set('kiRunClassification', false) + ->set('kiRunContentScore', true) + ->call('runKiCheck') + ->assertHasNoErrors(); + + Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id); + Queue::assertNotPushed(ClassifyPressRelease::class); +}); + +test('runKiCheck does nothing when no check is selected', function () { + /** @var TestCase $this */ + Queue::fake(); + + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + + LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id]) + ->set('kiRunClassification', false) + ->call('runKiCheck') + ->assertHasNoErrors(); + + Queue::assertNothingPushed(); +}); + +test('a non-routing classification job updates the verdict but leaves the status unchanged', function () { + /** @var TestCase $this */ + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['penis']); + + // Rote Einstufung, aber als Re-Check ohne Routing: Status bleibt published. + $pressRelease = PressRelease::factory()->published()->create([ + 'title' => 'Titel penis', + 'text' => 'Inhalt', + ]); + + (new ClassifyPressRelease($pressRelease->id, route: false))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Red); + expect($fresh->status)->toBe(PressReleaseStatus::Published); +}); diff --git a/tests/Feature/Admin/AdminPressReleaseSchedulingTest.php b/tests/Feature/Admin/AdminPressReleaseSchedulingTest.php index 9908ce4..17b6d3c 100644 --- a/tests/Feature/Admin/AdminPressReleaseSchedulingTest.php +++ b/tests/Feature/Admin/AdminPressReleaseSchedulingTest.php @@ -22,7 +22,7 @@ function makeAdmin(): User return $admin; } -test('admin create form persistiert scheduled_at und embargo_at', function () { +test('admin create form persistiert scheduled_at aus datum und uhrzeit ohne embargo', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 10:00:00'); @@ -38,7 +38,8 @@ test('admin create form persistiert scheduled_at und embargo_at', function () { ->set('categoryId', $category->id) ->set('portal', $company->portal->value) ->set('publishMode', 'scheduled') - ->set('scheduledAt', '2026-06-05T14:30') + ->set('scheduledDate', '2026-06-05') + ->set('scheduledTime', '14:30') ->set('useEmbargo', true) ->set('embargoAt', '2026-06-10T08:00') ->call('save') @@ -46,8 +47,11 @@ test('admin create form persistiert scheduled_at und embargo_at', function () { $pr = PressRelease::query()->latest('id')->firstOrFail(); - expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00'); - expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00'); + // Eingabe 14:30 erfolgt in Europe/Berlin (CEST, +02:00) und wird als + // 12:30 UTC gespeichert. + expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 12:30:00'); + expect($pr->scheduled_at?->copy()->setTimezone('Europe/Berlin')->format('Y-m-d H:i'))->toBe('2026-06-05 14:30'); + expect($pr->embargo_at)->toBeNull(); }); test('admin create form lehnt scheduled_at in der Vergangenheit ab', function () { @@ -71,7 +75,7 @@ test('admin create form lehnt scheduled_at in der Vergangenheit ab', function () ->assertHasErrors(['scheduledAt']); }); -test('admin edit hydriert scheduled_at und embargo_at', function () { +test('admin edit hydriert scheduled_at in datum und uhrzeit ohne embargo', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 10:00:00'); @@ -79,18 +83,22 @@ test('admin edit hydriert scheduled_at und embargo_at', function () { $this->actingAs($admin); $pr = PressRelease::factory()->create([ - 'scheduled_at' => '2026-06-05 14:30:00', + // 12:30 UTC entspricht 14:30 in Europe/Berlin (CEST) – so wird der + // Termin in den Eingabefeldern angezeigt. + 'scheduled_at' => '2026-06-05 12:30:00', 'embargo_at' => '2026-06-10 08:00:00', ]); LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id]) ->assertSet('publishMode', 'scheduled') ->assertSet('scheduledAt', '2026-06-05T14:30') - ->assertSet('useEmbargo', true) - ->assertSet('embargoAt', '2026-06-10T08:00'); + ->assertSet('scheduledDate', '2026-06-05') + ->assertSet('scheduledTime', '14:30') + ->assertSet('useEmbargo', false) + ->assertSet('embargoAt', null); }); -test('admin edit persistiert scheduled_at und embargo_at', function () { +test('admin edit persistiert scheduled_at aus datum und uhrzeit ohne embargo', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 10:00:00'); @@ -104,7 +112,8 @@ test('admin edit persistiert scheduled_at und embargo_at', function () { LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id]) ->set('publishMode', 'scheduled') - ->set('scheduledAt', '2026-06-08T09:00') + ->set('scheduledDate', '2026-06-08') + ->set('scheduledTime', '09:00') ->set('useEmbargo', true) ->set('embargoAt', '2026-06-12T12:00') ->call('save') @@ -112,8 +121,9 @@ test('admin edit persistiert scheduled_at und embargo_at', function () { $pr->refresh(); - expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-08 09:00:00'); - expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-12 12:00:00'); + // Eingabe 09:00 in Europe/Berlin (CEST, +02:00) wird als 07:00 UTC gespeichert. + expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-08 07:00:00'); + expect($pr->embargo_at)->toBeNull(); }); test('admin publishMode now clears scheduled_at on save', function () { diff --git a/tests/Feature/Admin/AdminPressReleaseShowTest.php b/tests/Feature/Admin/AdminPressReleaseShowTest.php index 1ac9c6a..1acc5d2 100644 --- a/tests/Feature/Admin/AdminPressReleaseShowTest.php +++ b/tests/Feature/Admin/AdminPressReleaseShowTest.php @@ -91,9 +91,10 @@ test('admin show zeigt Scheduling-Termin im Review-Workflow', function () { 'scheduled_at' => '2026-06-15 10:00:00', ]); + // 10:00 UTC wird in Europe/Berlin (CEST, +02:00) als 12:00 angezeigt. LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id]) ->assertSee('Geplante Veröffentlichung') - ->assertSee('15.06.2026 10:00'); + ->assertSee('15.06.2026 12:00'); }); test('admin show zeigt Embargo-Info im Published-Workflow', function () { diff --git a/tests/Feature/Api/V1/PressReleaseImageApiTest.php b/tests/Feature/Api/V1/PressReleaseImageApiTest.php index 3f7089c..307e8b6 100644 --- a/tests/Feature/Api/V1/PressReleaseImageApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseImageApiTest.php @@ -27,12 +27,15 @@ test('api user can upload list and delete own press release images', function () 'is_preview' => true, ]); + // Pressebilder werden auf das Hero-/Cover-Format normalisiert (harte + // Obergrenze 1280x580, siehe ImageService::PRESS_RELEASE_IMAGE_VARIANTS). + // Gespeichert werden daher die Cover-Maße, nicht die Originalgröße. $uploadResponse ->assertCreated() ->assertJsonPath('data.title', 'Pressefoto') ->assertJsonPath('data.is_preview', true) - ->assertJsonPath('data.width', 1200) - ->assertJsonPath('data.height', 800) + ->assertJsonPath('data.width', 1280) + ->assertJsonPath('data.height', 580) ->assertJsonStructure(['data' => ['variants', 'urls']]); $image = PressReleaseImage::query()->firstOrFail(); diff --git a/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php new file mode 100644 index 0000000..3068de9 --- /dev/null +++ b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php @@ -0,0 +1,119 @@ +create(); + $company = Company::factory()->presseecho()->create(); + $category = Category::factory()->withTranslations()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->postJson('/api/v1/press-releases', [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'API Entwurf', + 'text' => 'Inhalt', + 'status' => 'review', // soll ignoriert werden + ]) + ->assertCreated() + ->assertJsonPath('data.status', PressReleaseStatus::Draft->value); +}); + +test('api submit route raises a draft to review and counts quota and writes a log', function () { + /** @var TestCase $this */ + Queue::fake(); // Klassifikations-Routing separat getestet; hier nur der Submit-Übergang. + + $user = User::factory()->create(['press_release_quota_used_this_month' => 0]); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Saubere Pressemitteilung', + 'text' => 'Vollkommen unauffälliger Inhalt.', + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertOk() + ->assertJsonPath('data.status', PressReleaseStatus::Review->value); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); + expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); + expect(PressReleaseStatusLog::where('press_release_id', $pressRelease->id) + ->where('to_status', PressReleaseStatus::Review->value) + ->exists())->toBeTrue(); +}); + +test('api submit auto-rejects a press release containing a banned word', function () { + /** @var TestCase $this */ + config()->set('blacklist.words', ['penis']); + + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Unzulässiger Titel penis', + 'text' => 'Inhalt', + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertStatus(422); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected); +}); + +test('api submit requires the write ability', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + Sanctum::actingAs($user, ['press-releases:read']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertForbidden(); +}); + +test('api submit rejects a press release already in review', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->inReview()->create([ + 'user_id' => $user->id, + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertStatus(409); +}); + +test('api user cannot submit another users press release', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $owner->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + Sanctum::actingAs($otherUser, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertForbidden(); +}); diff --git a/tests/Feature/CustomerPressReleaseCreatePhase7Test.php b/tests/Feature/CustomerPressReleaseCreatePhase7Test.php index 5d85abc..7245f9c 100644 --- a/tests/Feature/CustomerPressReleaseCreatePhase7Test.php +++ b/tests/Feature/CustomerPressReleaseCreatePhase7Test.php @@ -7,6 +7,7 @@ use App\Models\Contact; use App\Models\PressRelease; use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Database\Eloquent\Factories\Sequence; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -62,6 +63,71 @@ test('changing the company resets the contactId to the new company default', fun ->assertSet('contactId', $alphaContact->id); }); +test('company options are limited and searchable', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $companies = Company::factory() + ->count(12) + ->presseecho() + ->sequence(fn (Sequence $sequence): array => [ + 'name' => sprintf('Firma %02d', $sequence->index + 1), + ]) + ->create(); + + $companies->each(fn (Company $company) => $customer->companies()->attach($company->id, ['role' => 'owner'])); + + $hiddenCompany = $companies->first(); + $hiddenCompany->update(['name' => 'Zielunternehmen Spezial']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 10 + && ! $companyOptions->pluck('id')->contains($hiddenCompany->id)) + ->set('companySearch', 'Zielunternehmen') + ->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 1 + && $companyOptions->first()->id === $hiddenCompany->id); +}); + +test('company options show portal abbreviations for duplicate names', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $presseechoCompany = Company::factory()->presseecho()->create(['name' => 'Doppel GmbH']); + $businessportalCompany = Company::factory()->businessportal24()->create(['name' => 'Doppel GmbH']); + + $customer->companies()->attach($presseechoCompany->id, ['role' => 'owner']); + $customer->companies()->attach($businessportalCompany->id, ['role' => 'owner']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('companySearch', 'Doppel') + ->assertSee('Doppel GmbH (PE)') + ->assertSee('Doppel GmbH (B24)'); +}); + +test('company search ignores trailing portal abbreviation from selected label', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $presseechoCompany = Company::factory()->presseecho()->create(['name' => 'Wein & Würze / N8Schicht GmbH']); + $businessportalCompany = Company::factory()->businessportal24()->create(['name' => 'Wein & Würze / N8Schicht GmbH']); + + $customer->companies()->attach($presseechoCompany->id, ['role' => 'owner']); + $customer->companies()->attach($businessportalCompany->id, ['role' => 'owner']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('companySearch', 'Wein & Würze / N8Schicht GmbH (B24)') + ->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->pluck('id')->contains($businessportalCompany->id)); +}); + test('save with all required fields persists the press release and syncs contact', function () { /** @var TestCase $this */ $customer = User::factory()->create(['is_active' => true]); @@ -163,6 +229,68 @@ test('boilerplate override is null when toggle is off even if text is filled', f expect($pr->boilerplate_override)->toBeNull(); }); +test('ensureDraft saves a draft with current data and redirects to the editor', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('title', 'Frühzeitiger Upload') + ->set('categoryId', $category->id) + ->call('ensureDraft') + ->assertHasNoErrors(); + + $pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->firstOrFail(); + + expect($pr->status)->toBe(PressReleaseStatus::Draft); + expect($pr->company_id)->toBe($company->id); + expect($pr->category_id)->toBe($category->id); + expect($pr->title)->toBe('Frühzeitiger Upload'); +}); + +test('ensureDraft uses a placeholder title when the title is still empty', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('categoryId', $category->id) + ->call('ensureDraft') + ->assertHasNoErrors(); + + $pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->firstOrFail(); + + expect($pr->title)->not->toBe(''); + expect($pr->status)->toBe(PressReleaseStatus::Draft); +}); + +test('ensureDraft requires a category before creating a draft', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('categoryId', null) + ->call('ensureDraft') + ->assertHasErrors(['categoryId']); + + expect(PressRelease::query()->where('user_id', $customer->id)->count())->toBe(0); +}); + test('addTag appends to keywords and removeTag drops it', function () { /** @var TestCase $this */ $customer = User::factory()->create(['is_active' => true]); diff --git a/tests/Feature/CustomerPressReleaseEditPhase7Test.php b/tests/Feature/CustomerPressReleaseEditPhase7Test.php index bfa1b08..d62c905 100644 --- a/tests/Feature/CustomerPressReleaseEditPhase7Test.php +++ b/tests/Feature/CustomerPressReleaseEditPhase7Test.php @@ -1,11 +1,14 @@ $customer, 'pr' => $pr] = makeCustomerWithPressRelease([ + 'content_score' => 67, + 'content_tier' => PressReleaseContentTier::Geprueft->value, + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) + ->assertSet('contentScore', 67) + ->assertSee('67') + ->assertSee('Geprüft') + ->assertSee('Noch 13 Punkte bis zur nächsten Stufe.'); +}); + test('mount loads all Phase 7 fields and pivot contact', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease([ @@ -71,6 +90,30 @@ test('mount falls back to first company contact when no pivot exists', function ->assertSet('contactId', $contact->id); }); +test('edit page shows uploaded title image instead of placeholder controls', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([ + 'title' => 'PM mit eigenem Titelbild', + ]); + + $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press-releases/'.$pr->id.'/images/title.jpg', + 'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'], + 'author' => 'Jane Doe', + 'license_type' => ImageLicenseType::Own->value, + 'rights_confirmed_at' => now(), + 'is_preview' => true, + 'sort_order' => 1, + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) + ->assertDontSee('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') + ->assertDontSee('Platzhalter wählen'); +}); + test('save persists all new Phase 7 fields and syncs contact', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease(); @@ -150,6 +193,33 @@ test('changing the company resets the contact to the new company default', funct ->assertSet('contactId', $secondContact->id); }); +test('company options keep selected company and search without loading all companies', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr, 'company' => $selectedCompany] = makeCustomerWithPressRelease(); + + $companies = Company::factory() + ->count(12) + ->presseecho() + ->sequence(fn (Sequence $sequence): array => [ + 'name' => sprintf('Weitere Firma %02d', $sequence->index + 1), + ]) + ->create(); + + $companies->each(fn (Company $company) => $customer->companies()->attach($company->id, ['role' => 'owner'])); + + $targetCompany = $companies->first(); + $targetCompany->update(['name' => 'Suchtreffer Redaktion']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) + ->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 10 + && $companyOptions->pluck('id')->contains($selectedCompany->id)) + ->set('companySearch', 'Suchtreffer') + ->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 1 + && $companyOptions->first()->id === $targetCompany->id); +}); + test('rejected press releases can be edited and re-submitted', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([ diff --git a/tests/Feature/CustomerPressReleaseSchedulingFormTest.php b/tests/Feature/CustomerPressReleaseSchedulingFormTest.php index 1087d7d..e49ef18 100644 --- a/tests/Feature/CustomerPressReleaseSchedulingFormTest.php +++ b/tests/Feature/CustomerPressReleaseSchedulingFormTest.php @@ -31,7 +31,7 @@ function makeSchedulingCustomer(): array return compact('customer', 'company', 'contact', 'category'); } -test('create form persistiert scheduled_at und embargo_at', function () { +test('create form persistiert scheduled_at aus datum und uhrzeit ohne embargo', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 10:00:00'); @@ -43,7 +43,8 @@ test('create form persistiert scheduled_at und embargo_at', function () { ->set('text', str_repeat('Inhalt eines Tests. ', 5)) ->set('categoryId', $category->id) ->set('publishMode', 'scheduled') - ->set('scheduledAt', '2026-06-05T14:30') + ->set('scheduledDate', '2026-06-05') + ->set('scheduledTime', '14:30') ->set('useEmbargo', true) ->set('embargoAt', '2026-06-10T08:00') ->call('save') @@ -51,8 +52,11 @@ test('create form persistiert scheduled_at und embargo_at', function () { $pr = PressRelease::query()->latest('id')->firstOrFail(); - expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00'); - expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00'); + // Eingabe 14:30 erfolgt in Europe/Berlin (CEST, +02:00) und wird als + // 12:30 UTC gespeichert. + expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 12:30:00'); + expect($pr->scheduled_at?->copy()->setTimezone('Europe/Berlin')->format('Y-m-d H:i'))->toBe('2026-06-05 14:30'); + expect($pr->embargo_at)->toBeNull(); }); test('create form lehnt scheduled_at in der Vergangenheit ab', function () { @@ -72,7 +76,7 @@ test('create form lehnt scheduled_at in der Vergangenheit ab', function () { ->assertHasErrors(['scheduledAt']); }); -test('create form lehnt embargo_at in der Vergangenheit ab', function () { +test('create form lehnt geplanten termin aus datum und uhrzeit in der vergangenheit ab', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 10:00:00'); @@ -80,13 +84,11 @@ test('create form lehnt embargo_at in der Vergangenheit ab', function () { $this->actingAs($customer); LivewireVolt::test('customer.press-releases.create') - ->set('title', 'Vergangenes Embargo') - ->set('text', str_repeat('Inhalt eines Tests. ', 5)) - ->set('categoryId', $category->id) - ->set('useEmbargo', true) - ->set('embargoAt', '2026-05-30T10:00') + ->set('publishMode', 'scheduled') + ->set('scheduledDate', '2026-05-30') + ->set('scheduledTime', '10:00') ->call('save') - ->assertHasErrors(['embargoAt']); + ->assertHasErrors(['scheduledAt']); }); test('publishMode now setzt scheduled_at auf null beim Save', function () { @@ -108,7 +110,7 @@ test('publishMode now setzt scheduled_at auf null beim Save', function () { expect($pr->scheduled_at)->toBeNull(); }); -test('edit form hydriert scheduled_at und embargo_at', function () { +test('edit form hydriert scheduled_at in datum und uhrzeit ohne embargo', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 10:00:00'); @@ -120,7 +122,9 @@ test('edit form hydriert scheduled_at und embargo_at', function () { 'category_id' => $category->id, 'portal' => $company->portal->value, 'status' => 'draft', - 'scheduled_at' => '2026-06-05 14:30:00', + // 12:30 UTC entspricht 14:30 in Europe/Berlin (CEST) – so wird der + // Termin in den Eingabefeldern angezeigt. + 'scheduled_at' => '2026-06-05 12:30:00', 'embargo_at' => '2026-06-10 08:00:00', ]); $pr->contacts()->sync([$contact->id]); @@ -130,6 +134,8 @@ test('edit form hydriert scheduled_at und embargo_at', function () { LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) ->assertSet('publishMode', 'scheduled') ->assertSet('scheduledAt', '2026-06-05T14:30') - ->assertSet('useEmbargo', true) - ->assertSet('embargoAt', '2026-06-10T08:00'); + ->assertSet('scheduledDate', '2026-06-05') + ->assertSet('scheduledTime', '14:30') + ->assertSet('useEmbargo', false) + ->assertSet('embargoAt', null); }); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php new file mode 100644 index 0000000..363ae25 --- /dev/null +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -0,0 +1,203 @@ +set('scoring.classification.provider', 'openai'); + config()->set('services.openai.api_key', 'test-key'); + + Http::fake([ + '*' => Http::response([ + 'choices' => [ + ['message' => ['content' => json_encode([ + 'classification' => $classification, + 'reasons' => $reasons, + ])]], + ], + ], 200), + ]); +} + +test('classify job stores the openai classification and writes an audit', function () { + fakeOpenAiClassification('green'); + + $pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Green); + expect($fresh->classified_at)->not->toBeNull(); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($audit->type)->toBe(KiAudit::TYPE_CLASSIFICATION); + expect($audit->provider)->toBe('openai'); + expect($audit->result)->toBe('green'); +}); + +test('classify job records a yellow classification with reasons', function () { + fakeOpenAiClassification('yellow', ['grenzwertige Werbesprache']); + + $pressRelease = PressRelease::factory()->create(); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Yellow); + expect($audit->reason)->toContain('Werbesprache'); +}); + +test('classify job falls back to the deterministic driver when openai fails', function () { + config()->set('scoring.classification.provider', 'openai'); + config()->set('services.openai.api_key', 'test-key'); + config()->set('blacklist.words', ['penis']); + + Http::fake(['*' => Http::response('error', 500)]); + + // Sauberer Text -> deterministischer Fallback liefert green. + $pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green); + expect($audit->provider)->toBe('deterministic'); +}); + +test('deterministic driver classifies a banned word as red', function () { + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['penis']); + + $pressRelease = PressRelease::factory()->create(['title' => 'Titel penis', 'text' => 'Inhalt']); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Red); + expect($audit->provider)->toBe('deterministic'); +}); + +test('classify job routes a red classification to rejected and notifies the author', function () { + Mail::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['penis']); + + $author = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $author->id, + 'status' => PressReleaseStatus::Review->value, + 'title' => 'Titel penis', + 'text' => 'Inhalt', + ]); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected); + Mail::assertQueued(PressReleaseRejected::class); +}); + +test('classify job auto-publishes a green classification without a schedule', function () { + Mail::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'scheduled_at' => null, + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Published); + Mail::assertQueued(PressReleasePublished::class); +}); + +test('classify job leaves a green scheduled press release in review for the scheduler', function () { + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'scheduled_at' => now()->addWeek(), + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Green); + expect($fresh->status)->toBe(PressReleaseStatus::Review); +}); + +test('classify job keeps a yellow classification in the manual review queue', function () { + fakeOpenAiClassification('yellow', ['grenzwertig']); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'title' => 'Grenzfall', + 'text' => 'Inhalt', + ]); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); +}); + +test('submitForReview dispatches the classification job onto the classification queue', function () { + Queue::fake(); + config()->set('blacklist.words', []); + + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + app(PressReleaseService::class)->submitForReview($pressRelease); + + Queue::assertPushedOn('classification', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) { + return $job->pressReleaseId === $pressRelease->id; + }); +}); diff --git a/tests/Feature/PressReleaseClassificationModelTest.php b/tests/Feature/PressReleaseClassificationModelTest.php new file mode 100644 index 0000000..b5ff75e --- /dev/null +++ b/tests/Feature/PressReleaseClassificationModelTest.php @@ -0,0 +1,65 @@ +create([ + 'classification' => PressReleaseClassification::Yellow->value, + 'classified_at' => now(), + ]); + + $fresh = $pressRelease->fresh(); + + expect($fresh->classification)->toBe(PressReleaseClassification::Yellow); + expect($fresh->classified_at)->toBeInstanceOf(Carbon::class); +}); + +test('classification defaults to null when not set', function () { + $pressRelease = PressRelease::factory()->create(); + + expect($pressRelease->fresh()->classification)->toBeNull(); + expect($pressRelease->fresh()->classified_at)->toBeNull(); +}); + +test('press release has many ki audits ordered newest first', function () { + $pressRelease = PressRelease::factory()->create(); + + $older = KiAudit::factory()->for($pressRelease)->create(['created_at' => now()->subDay()]); + $newer = KiAudit::factory()->for($pressRelease)->create(['created_at' => now()]); + + $audits = $pressRelease->kiAudits()->get(); + + expect($audits)->toHaveCount(2); + expect($audits->first()->id)->toBe($newer->id); + expect($audits->last()->id)->toBe($older->id); +}); + +test('ki audit casts raw_response to an array and belongs to its press release', function () { + $pressRelease = PressRelease::factory()->create(); + $audit = KiAudit::factory() + ->for($pressRelease) + ->classification(PressReleaseClassification::Red) + ->create([ + 'reason' => 'Werbliche Sprache', + 'raw_response' => ['classification' => 'red', 'reasons' => ['advertising']], + ]); + + $fresh = $audit->fresh(); + + expect($fresh->type)->toBe(KiAudit::TYPE_CLASSIFICATION); + expect($fresh->result)->toBe(PressReleaseClassification::Red->value); + expect($fresh->raw_response)->toBeArray()->toHaveKey('reasons'); + expect($fresh->pressRelease->is($pressRelease))->toBeTrue(); +}); + +test('deleting a press release cascades to its ki audits', function () { + $pressRelease = PressRelease::factory()->create(); + KiAudit::factory()->for($pressRelease)->create(); + + $pressRelease->forceDelete(); + + expect(KiAudit::where('press_release_id', $pressRelease->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/PressReleaseContentScoreTest.php b/tests/Feature/PressReleaseContentScoreTest.php new file mode 100644 index 0000000..040ed0f --- /dev/null +++ b/tests/Feature/PressReleaseContentScoreTest.php @@ -0,0 +1,114 @@ +set('scoring.content_score.provider', 'openai'); + config()->set('services.openai.api_key', 'test-key'); + + Http::fake([ + '*' => Http::response([ + 'choices' => [ + ['message' => ['content' => json_encode([ + 'score' => $score, + 'breakdown' => ['pressestil' => 15], + ])]], + ], + ], 200), + ]); +} + +test('tier mapping follows the configured thresholds', function () { + config()->set('scoring.content_score.tiers', ['gepruft' => 60, 'hochwertig' => 80]); + + expect(PressReleaseContentTier::fromScore(45))->toBe(PressReleaseContentTier::Standard); + expect(PressReleaseContentTier::fromScore(60))->toBe(PressReleaseContentTier::Geprueft); + expect(PressReleaseContentTier::fromScore(79))->toBe(PressReleaseContentTier::Geprueft); + expect(PressReleaseContentTier::fromScore(80))->toBe(PressReleaseContentTier::Hochwertig); +}); + +test('only gepruft and hochwertig are publicly badged', function () { + expect(PressReleaseContentTier::Standard->isPubliclyBadged())->toBeFalse(); + expect(PressReleaseContentTier::Geprueft->isPubliclyBadged())->toBeTrue(); + expect(PressReleaseContentTier::Hochwertig->isPubliclyBadged())->toBeTrue(); +}); + +test('score job stores the openai score, derives the tier and writes an audit', function () { + fakeOpenAiScore(72); + + $pressRelease = PressRelease::factory()->create(); + + (new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class)); + + $fresh = $pressRelease->fresh(); + expect($fresh->content_score)->toBe(72); + expect($fresh->content_tier)->toBe(PressReleaseContentTier::Geprueft); + expect($fresh->scored_at)->not->toBeNull(); + + $audit = KiAudit::where('press_release_id', $pressRelease->id) + ->where('type', KiAudit::TYPE_CONTENT_SCORE) + ->firstOrFail(); + expect($audit->provider)->toBe('openai'); + expect($audit->result)->toBe('72'); +}); + +test('score job falls back to the deterministic driver when openai fails', function () { + config()->set('scoring.content_score.provider', 'openai'); + config()->set('services.openai.api_key', 'test-key'); + + Http::fake(['*' => Http::response('error', 500)]); + + $pressRelease = PressRelease::factory()->create([ + 'title' => 'Eine ausreichend lange und klare Pressemitteilungs-Headline', + 'text' => str_repeat('Inhaltlicher Satz mit Substanz. ', 80), + ]); + + (new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class)); + + $audit = KiAudit::where('press_release_id', $pressRelease->id) + ->where('type', KiAudit::TYPE_CONTENT_SCORE) + ->firstOrFail(); + expect($audit->provider)->toBe('deterministic'); + expect($pressRelease->fresh()->content_score)->toBeGreaterThan(0); +}); + +test('submitForReview dispatches the scoring job onto the classification queue', function () { + Queue::fake(); + config()->set('blacklist.words', []); + + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + app(PressReleaseService::class)->submitForReview($pressRelease); + + Queue::assertPushedOn('classification', ScorePressRelease::class, function (ScorePressRelease $job) use ($pressRelease) { + return $job->pressReleaseId === $pressRelease->id; + }); +}); + +test('rescoreIfScored dispatches only for an already scored press release', function () { + Queue::fake(); + + $scored = PressRelease::factory()->create(['content_score' => 55]); + $neverScored = PressRelease::factory()->create(['content_score' => null]); + + app(PressReleaseService::class)->rescoreIfScored($scored); + app(PressReleaseService::class)->rescoreIfScored($neverScored); + + Queue::assertPushed(ScorePressRelease::class, 1); +}); diff --git a/tests/Feature/PressReleaseImageLicenseTest.php b/tests/Feature/PressReleaseImageLicenseTest.php new file mode 100644 index 0000000..8e93008 --- /dev/null +++ b/tests/Feature/PressReleaseImageLicenseTest.php @@ -0,0 +1,300 @@ +seed(RolesAndPermissionsSeeder::class); + Storage::fake('public'); +}); + +function makeImageDraftOwner(): array +{ + $owner = User::factory()->create(['is_active' => true]); + $owner->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(); + $owner->companies()->attach($company->id, ['role' => 'owner']); + + $pr = PressRelease::factory()->create([ + 'user_id' => $owner->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'status' => 'draft', + ]); + + return compact('owner', 'pr'); +} + +test('image upload requires author, license type and rights confirmation', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->call('saveImage') + ->assertHasErrors(['newAuthor', 'newLicenseType', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsConfirmed']); + + expect($pr->images()->count())->toBe(0); +}); + +test('license type starts with an explicit placeholder before own photo option', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->assertSet('newLicenseType', '') + ->assertSee('Bitte wählen') + ->assertSee('Eigene Aufnahme'); +}); + +test('title image upload form is collapsed until requested', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->assertSee('Hier fehlt ein Titelbild') + ->assertSee('Eigenes Titelbild hochladen') + ->assertDontSee('Bild hierher ziehen oder klicken') + ->call('openUploadForm') + ->assertSee('Titelbild hochladen') + ->assertSee('Bild hierher ziehen oder klicken') + ->assertDontSee('Als Vorschaubild verwenden') + ->assertDontSee('Unsicher'); +}); + +test('unclear rights selections are not accepted', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::Own->value) + ->set('newPeopleRightsStatus', 'unsure') + ->set('newPropertyRightsStatus', 'unsure') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newPeopleRightsStatus', 'newPropertyRightsStatus']); + + expect($pr->images()->count())->toBe(0); +}); + +test('cc license requires a license url', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::CreativeCommons->value) + ->set('newLicenseDetail', 'cc_by') + ->set('newPeopleRightsStatus', 'none') + ->set('newPropertyRightsStatus', 'none') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newLicenseUrl']); + + expect($pr->images()->count())->toBe(0); +}); + +test('cc license requires a concrete cc variant', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::CreativeCommons->value) + ->set('newLicenseUrl', 'https://creativecommons.org/licenses/by/4.0/') + ->set('newPeopleRightsStatus', 'none') + ->set('newPropertyRightsStatus', 'none') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newLicenseDetail']); + + expect($pr->images()->count())->toBe(0); +}); + +test('other license requires details', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::Other->value) + ->set('newPeopleRightsStatus', 'none') + ->set('newPropertyRightsStatus', 'none') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newLicenseDetail']); + + expect($pr->images()->count())->toBe(0); +}); + +test('non previewable image uploads fail validation without preview exception', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->create('scan.tif', 100, 'image/tiff')) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::Own->value) + ->set('newPeopleRightsStatus', 'none') + ->set('newPropertyRightsStatus', 'none') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newImage']); + + expect($pr->images()->count())->toBe(0); +}); + +test('valid upload stores license metadata and confirms rights', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newTitle', 'Maschine im Einsatz') + ->set('newCopyright', 'Foto: Jane Doe / Beispiel GmbH') + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::Own->value) + ->set('newSourceUrl', 'https://example.com/source') + ->set('newPeopleRightsStatus', 'consent') + ->set('newPropertyRightsStatus', 'cleared') + ->set('newRightsNotes', 'Freigabe liegt intern vor.') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasNoErrors(); + + $image = $pr->images()->first(); + + expect($image)->not->toBeNull(); + expect($image->title)->toBe('Maschine im Einsatz'); + expect($image->copyright)->toBe('Foto: Jane Doe / Beispiel GmbH'); + expect($image->author)->toBe('Jane Doe'); + expect($image->license_type)->toBe(ImageLicenseType::Own); + expect($image->source_url)->toBe('https://example.com/source'); + expect($image->people_rights_status)->toBe('consent'); + expect($image->property_rights_status)->toBe('cleared'); + expect($image->rights_notes)->toBe('Freigabe liegt intern vor.'); + expect($image->persons_consent)->toBeTrue(); + expect($image->rights_confirmed_at)->not->toBeNull(); + expect($image->is_preview)->toBeTrue(); + expect($image->path)->toBe($image->variants['cover']); + expect($image->width)->toBe(1280); + expect($image->height)->toBe(580); + + Storage::disk('public')->assertExists($image->path); + + $originalPath = preg_replace( + '#/variants/([^/]+)-cover(\.[^.]+)$#', + '/$1$2', + $image->path, + ); + + expect($originalPath)->toBeString()->not->toBe($image->path); + Storage::disk('public')->assertMissing($originalPath); +}); + +test('valid cc upload stores license detail and license url', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::CreativeCommons->value) + ->set('newLicenseDetail', 'cc_by') + ->set('newLicenseUrl', 'https://creativecommons.org/licenses/by/4.0/') + ->set('newPeopleRightsStatus', 'none') + ->set('newPropertyRightsStatus', 'none') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasNoErrors(); + + $image = $pr->images()->first(); + + expect($image)->not->toBeNull(); + expect($image->license_type)->toBe(ImageLicenseType::CreativeCommons); + expect($image->license_detail)->toBe('cc_by'); + expect($image->license_url)->toBe('https://creativecommons.org/licenses/by/4.0/'); +}); + +test('existing title image hides upload form and can be removed', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + $image = $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press-releases/'.$pr->id.'/images/title.jpg', + 'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'], + 'title' => 'Messefoto', + 'copyright' => 'Pressefoto GmbH', + 'author' => 'Jane Doe', + 'license_type' => ImageLicenseType::Own->value, + 'rights_confirmed_at' => now(), + 'is_preview' => true, + 'sort_order' => 1, + ]); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->assertSee('Messefoto') + ->assertSee('Bildnachweis: Pressefoto GmbH') + ->assertSee('Titelbild löschen') + ->assertDontSee('Titelbild hochladen') + ->call('remove', $image->id) + ->assertSee('Eigenes Titelbild hochladen'); + + expect($pr->images()->count())->toBe(0); +}); + +test('second title image upload is blocked while one exists', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); + $this->actingAs($owner); + + $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press-releases/'.$pr->id.'/images/title.jpg', + 'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'], + 'author' => 'Jane Doe', + 'license_type' => ImageLicenseType::Own->value, + 'rights_confirmed_at' => now(), + 'is_preview' => true, + 'sort_order' => 1, + ]); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('zweites.jpg', 1200, 800)) + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::Own->value) + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newImage']); + + expect($pr->images()->count())->toBe(1); +}); diff --git a/tests/Feature/PressReleaseIndexPhase8bTest.php b/tests/Feature/PressReleaseIndexPhase8bTest.php index a0756ea..e6c6452 100644 --- a/tests/Feature/PressReleaseIndexPhase8bTest.php +++ b/tests/Feature/PressReleaseIndexPhase8bTest.php @@ -1,5 +1,7 @@ assertDontSee('geplant ·') ->assertDontSee('Embargo bis'); }); + +test('admin list zeigt Content-Score-Stufe', function () { + /** @var TestCase $this */ + $admin = makeAdminForIndexPhase8b(); + $this->actingAs($admin); + + PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Published->value, + 'content_score' => 84, + 'content_tier' => PressReleaseContentTier::Hochwertig->value, + ]); + + LivewireVolt::test('admin.press-releases.index') + ->assertSee('84 · Hochwertig'); +}); + +test('admin list zeigt KI-Klassifikations-Badge', function () { + /** @var TestCase $this */ + $admin = makeAdminForIndexPhase8b(); + $this->actingAs($admin); + + PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Yellow->value, + ]); + + LivewireVolt::test('admin.press-releases.index') + ->assertSee('KI: Gelb'); +}); + +test('admin list filtert nach KI-Klassifikation', function () { + /** @var TestCase $this */ + $admin = makeAdminForIndexPhase8b(); + $this->actingAs($admin); + + $yellow = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Yellow->value, + 'title' => 'Gelber Grenzfall', + ]); + $green = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'title' => 'Grüne Mitteilung', + ]); + + LivewireVolt::test('admin.press-releases.index') + ->set('classificationFilter', PressReleaseClassification::Yellow->value) + ->assertSee('Gelber Grenzfall') + ->assertDontSee('Grüne Mitteilung'); +}); diff --git a/tests/Feature/PressReleasePlaceholderTest.php b/tests/Feature/PressReleasePlaceholderTest.php new file mode 100644 index 0000000..9d5bf1a --- /dev/null +++ b/tests/Feature/PressReleasePlaceholderTest.php @@ -0,0 +1,110 @@ +toHaveCount(18); + expect(PressReleasePlaceholder::default())->toBe(PressReleasePlaceholder::GridBlue); +}); + +test('invalid placeholder values fall back to the default', function () { + expect(PressReleasePlaceholder::fromValueOrDefault(null))->toBe(PressReleasePlaceholder::default()); + expect(PressReleasePlaceholder::fromValueOrDefault('does-not-exist'))->toBe(PressReleasePlaceholder::default()); + expect(PressReleasePlaceholder::fromValueOrDefault('05-lines-green'))->toBe(PressReleasePlaceholder::LinesGreen); + expect(PressReleasePlaceholder::fromValueOrDefault('17-signal-green'))->toBe(PressReleasePlaceholder::SignalGreen); +}); + +test('placeholder variant from a seed is deterministic', function () { + $first = PressReleasePlaceholder::fromSeed(4242); + $second = PressReleasePlaceholder::fromSeed(4242); + + expect($first)->toBe($second); +}); + +test('every placeholder svg asset exists on disk', function () { + foreach (PressReleasePlaceholder::cases() as $variant) { + expect(public_path($variant->path()))->toBeFile(); + } +}); + +test('press releases get a deterministic placeholder variant on creation', function () { + $pr = PressRelease::factory()->create(['placeholder_variant' => null]); + + expect($pr->placeholder_variant)->toBeInstanceOf(PressReleasePlaceholder::class); +}); + +test('cover resolver falls back to the placeholder svg when no image exists', function () { + $pr = PressRelease::factory()->create(['placeholder_variant' => '05-lines-green']); + + $cover = app(PressReleaseCoverImage::class); + + expect($cover->coverIsPlaceholder($pr))->toBeTrue(); + expect($cover->coverUrl($pr))->toContain('images/press-release-placeholders/05-lines-green.svg'); +}); + +test('cover resolver prefers the real preview image over the placeholder', function () { + $pr = PressRelease::factory()->create(['placeholder_variant' => '01-grid-blue']); + + $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press/cover.jpg', + 'variants' => ['large' => 'press/cover-large.jpg'], + 'is_preview' => true, + 'sort_order' => 1, + ]); + + $cover = app(PressReleaseCoverImage::class); + + expect($cover->coverIsPlaceholder($pr->fresh()))->toBeFalse(); + expect($cover->coverUrl($pr->fresh()))->toContain('storage/press/cover-large.jpg'); +}); + +test('cover resolver prefers the 1280x580 cover variant over large', function () { + $pr = PressRelease::factory()->create(['placeholder_variant' => '01-grid-blue']); + + $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press/cover.jpg', + 'variants' => [ + 'large' => 'press/cover-large.jpg', + 'cover' => 'press/cover-cover.jpg', + ], + 'is_preview' => true, + 'sort_order' => 1, + ]); + + $cover = app(PressReleaseCoverImage::class); + + expect($cover->coverUrl($pr->fresh()))->toContain('storage/press/cover-cover.jpg'); +}); + +test('cover resolver falls back from cover to large when cover variant is missing', function () { + $pr = PressRelease::factory()->create(['placeholder_variant' => '01-grid-blue']); + + $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press/cover.jpg', + 'variants' => ['large' => 'press/cover-large.jpg'], + 'is_preview' => true, + 'sort_order' => 1, + ]); + + $cover = app(PressReleaseCoverImage::class); + + expect($cover->coverUrl($pr->fresh(), 'cover'))->toContain('storage/press/cover-large.jpg'); +}); + +test('placeholder picker mounts with the current variant and confirms a selection', function () { + Volt::test('components.press-release-placeholder-picker', ['current' => '05-lines-green']) + ->assertSet('selected', '05-lines-green') + ->call('choose', '08-dots-green') + ->assertSet('selected', '08-dots-green') + ->call('confirm') + ->assertDispatched('placeholder-selected', variant: '08-dots-green'); +}); diff --git a/tests/Feature/PressReleasePublishModalPhase8iTest.php b/tests/Feature/PressReleasePublishModalPhase8iTest.php new file mode 100644 index 0000000..3748ba1 --- /dev/null +++ b/tests/Feature/PressReleasePublishModalPhase8iTest.php @@ -0,0 +1,43 @@ +seed(RolesAndPermissionsSeeder::class); + + // Klassifikations-Job nicht inline ausführen, damit der Submit hier nur den + // Übergang nach „review" prüft (KI-Routing siehe ClassificationJobTest). + Queue::fake(); +}); + +test('customer show renders the publish confirmation modal with legal note and quota', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); + $customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 1]); + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Pressemitteilung zur Prüfung einreichen') + ->assertSee('Mit dem Einreichen versichern Sie:') + ->assertSee('PM-Kontingent diesen Monat') + ->assertSee('2 / 3'); +}); + +test('submitting from the show modal moves the draft into review and counts quota', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); + $customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 0]); + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('submitForReview') + ->assertHasNoErrors(); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); + expect($customer->fresh()->press_release_quota_used_this_month)->toBe(1); +}); diff --git a/tests/Feature/PressReleaseQuotaTest.php b/tests/Feature/PressReleaseQuotaTest.php new file mode 100644 index 0000000..ddbb2df --- /dev/null +++ b/tests/Feature/PressReleaseQuotaTest.php @@ -0,0 +1,57 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +test('remaining quota reflects the used counter', function () { + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 1, + ]); + + expect($user->pressReleaseQuotaRemaining())->toBe(2); +}); + +test('submitting a press release for review increments the monthly quota usage', function () { + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 0, + ]); + $user->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + $pr = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'status' => 'draft', + ]); + + app(PressReleaseService::class)->submitForReview($pr); + + expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); +}); + +test('monthly reset command zeroes the used counter', function () { + User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); + $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); + + $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); + + expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0); + expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0); +}); diff --git a/tests/Feature/PressReleaseReclassifyTest.php b/tests/Feature/PressReleaseReclassifyTest.php new file mode 100644 index 0000000..a93a584 --- /dev/null +++ b/tests/Feature/PressReleaseReclassifyTest.php @@ -0,0 +1,94 @@ +create([ + 'classification' => PressReleaseClassification::Green->value, + 'status' => PressReleaseStatus::Published->value, + ]); + + app(PressReleaseService::class)->reclassifyIfClassified($pressRelease); + + Queue::assertPushedOn('classification', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) { + return $job->pressReleaseId === $pressRelease->id && $job->route === false; + }); +}); + +test('reclassifyIfClassified does nothing for a never-classified press release', function () { + Queue::fake(); + + $pressRelease = PressRelease::factory()->create(['classification' => null]); + + app(PressReleaseService::class)->reclassifyIfClassified($pressRelease); + + Queue::assertNothingPushed(); +}); + +test('api update of a classified press release re-classifies when the content changes', function () { + /** @var TestCase $this */ + Queue::fake(); + + $user = User::factory()->create(); + $company = Company::factory()->presseecho()->create(); + $category = Category::factory()->withTranslations()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'category_id' => $category->id, + 'portal' => $company->portal->value, + 'status' => PressReleaseStatus::Draft->value, + 'classification' => PressReleaseClassification::Green->value, + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [ + 'text' => 'Komplett neuer Inhalt, der erneut geprüft werden muss.', + ])->assertOk(); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->route === false); +}); + +test('api update does not re-classify when content is unchanged', function () { + /** @var TestCase $this */ + Queue::fake(); + + $user = User::factory()->create(); + $company = Company::factory()->presseecho()->create(); + $category = Category::factory()->withTranslations()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'category_id' => $category->id, + 'portal' => $company->portal->value, + 'status' => PressReleaseStatus::Draft->value, + 'classification' => PressReleaseClassification::Green->value, + 'keywords' => 'alt', + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + // Nur Keywords ändern – kein Titel/Text → keine Neuklassifikation. + $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [ + 'keywords' => 'neu', + ])->assertOk(); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/PressReleaseSchedulingTest.php b/tests/Feature/PressReleaseSchedulingTest.php index 31a9987..c112cd7 100644 --- a/tests/Feature/PressReleaseSchedulingTest.php +++ b/tests/Feature/PressReleaseSchedulingTest.php @@ -1,6 +1,7 @@ create([ 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, 'scheduled_at' => '2026-06-01 11:55:00', 'published_at' => null, ]); @@ -129,6 +131,22 @@ test('Command publisht fällige Review-PMs mit scheduled_at <= now', function () expect($fresh->published_at?->toDateTimeString())->toBe('2026-06-01 11:55:00'); }); +test('Command ignoriert fällige gelbe PMs (manuelle Prüfung)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-06-01 12:00:00'); + + $yellow = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Yellow->value, + 'scheduled_at' => '2026-06-01 11:55:00', + 'published_at' => null, + ]); + + Artisan::call(PublishScheduledPressReleases::class); + + expect($yellow->fresh()->status)->toBe(PressReleaseStatus::Review); +}); + test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 12:00:00'); @@ -164,6 +182,7 @@ test('Command läuft mit dry-run ohne Statusänderung', function () { $due = PressRelease::factory()->create([ 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, 'scheduled_at' => '2026-06-01 11:50:00', 'published_at' => null, ]); @@ -179,6 +198,7 @@ test('Command publisht maximal --limit pro Lauf', function () { PressRelease::factory()->count(3)->state([ 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, 'scheduled_at' => '2026-06-01 11:50:00', 'published_at' => null, ])->create(); diff --git a/tests/Feature/PressReleaseShowPhase8aTest.php b/tests/Feature/PressReleaseShowPhase8aTest.php index d83c79f..65bdbd9 100644 --- a/tests/Feature/PressReleaseShowPhase8aTest.php +++ b/tests/Feature/PressReleaseShowPhase8aTest.php @@ -73,9 +73,10 @@ test('customer show zeigt geplante Veröffentlichung wenn gesetzt', function () ]); $this->actingAs($customer); + // 09:30 UTC wird in Europe/Berlin (CEST, +02:00) als 11:30 angezeigt. LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Geplante Veröffentlichung') - ->assertSee('01.07.2026 09:30'); + ->assertSee('01.07.2026 11:30'); }); test('customer show zeigt Sperrfrist wenn embargo_at gesetzt', function () { @@ -86,9 +87,10 @@ test('customer show zeigt Sperrfrist wenn embargo_at gesetzt', function () { ]); $this->actingAs($customer); + // 12:00 UTC wird in Europe/Berlin (CEST, +02:00) als 14:00 angezeigt. LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Sperrfrist bis') - ->assertSee('15.08.2026 12:00'); + ->assertSee('15.08.2026 14:00'); }); test('customer show zeigt Kein-Export-Hinweis wenn no_export aktiv', function () { diff --git a/tests/Feature/PressReleaseWorkflowTest.php b/tests/Feature/PressReleaseWorkflowTest.php index 9239b8a..8ec39bb 100644 --- a/tests/Feature/PressReleaseWorkflowTest.php +++ b/tests/Feature/PressReleaseWorkflowTest.php @@ -10,12 +10,18 @@ use App\Services\Admin\AdminPerformanceCache; use App\Services\PressRelease\PressReleaseService; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; beforeEach(function (): void { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); + + // Klassifikations-Job nicht inline ausführen (sync-Queue im Test), damit + // submitForReview hier nur den Übergang nach „review" prüft. Das KI-Routing + // (Grün→publish etc.) wird separat in PressReleaseClassificationJobTest getestet. + Queue::fake(); }); test('submit for review logs a status change with customer source', function () {