User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline

Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 08:30:13 +00:00
parent 0efabaf446
commit a000238ca8
141 changed files with 5922 additions and 1001 deletions

51
app/Models/KiAudit.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Database\Factories\KiAuditFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Audit-Log jeder KI-Entscheidung (Klassifikation / Content-Score).
*
* Schreibt für jede Bewertung einen unveränderlichen Eintrag inkl.
* Anbieter, Modell, Ergebnis, Begründung und Roh-Antwort für
* Nachvollziehbarkeit und Nachweispflicht (DSGVO).
*/
class KiAudit extends Model
{
/** @use HasFactory<KiAuditFactory> */
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);
}
}

View file

@ -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<PressReleaseFactory> */
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<string>
*/
@ -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
* <p>/<br>-wrapped legacy plain text for older imports.

View file

@ -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',

View file

@ -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
*/