images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)

- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung)
- deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1)
- press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard)
- Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien
- ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite
- Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:53:49 +00:00
parent 9f44546082
commit da167b713b
24 changed files with 1202 additions and 12 deletions

View file

@ -3,6 +3,9 @@
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Enums\AiImageDeclaration;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Livewire\Attributes\Layout;
@ -30,6 +33,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
try {
app(PressReleaseService::class)->publish($pr);
} catch (ImageRightsConfirmationRequiredException $e) {
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
Flux::modal('confirm-show-publish')->close();
return;
} catch (BlacklistViolationException $e) {
Flux::toast(
heading: __('Automatisch abgelehnt'),
@ -69,6 +77,77 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
Flux::modal('confirm-show-archive')->close();
}
/**
* Korrekturweg (Bildkennzeichnung E.6): Bildnachweis oder
* Portalkennzeichnung mit Pflichtgrund ändern, Bild vorläufig zurückhalten.
*/
public string $correctionCredit = '';
public string $correctionAiDeclaration = '';
public string $correctionReason = '';
public function correctImage(ImageCorrectionService $corrections): void
{
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
if ($image === null) {
return;
}
$this->validate([
'correctionReason' => ['required', 'string', 'min:5', 'max:1000'],
'correctionCredit' => ['nullable', 'string', 'max:255'],
'correctionAiDeclaration' => ['nullable', \Illuminate\Validation\Rule::enum(AiImageDeclaration::class)],
], [
'correctionReason.required' => __('Bitte geben Sie den Grund der Korrektur an.'),
]);
$admin = auth()->user();
$changed = 0;
if ($this->correctionCredit !== '' && $this->correctionCredit !== (string) $image->copyright) {
$corrections->correctCredit($image, $admin, trim($this->correctionCredit), $this->correctionReason);
$changed++;
}
if ($this->correctionAiDeclaration !== '' && $this->correctionAiDeclaration !== $image->ai_declaration?->value) {
$corrections->correctAiDeclaration($image, $admin, AiImageDeclaration::from($this->correctionAiDeclaration), $this->correctionReason);
$changed++;
}
$this->reset(['correctionCredit', 'correctionAiDeclaration', 'correctionReason']);
Flux::toast(
text: $changed > 0 ? __('Korrektur protokolliert und übernommen.') : __('Keine Änderung: Die Angaben entsprechen dem aktuellen Stand.'),
variant: $changed > 0 ? 'success' : 'warning',
);
}
public function toggleWithheld(ImageCorrectionService $corrections): void
{
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
if ($image === null) {
return;
}
$this->validate(
['correctionReason' => ['required', 'string', 'min:5', 'max:1000']],
['correctionReason.required' => __('Bitte geben Sie den Grund an.')],
);
$image->isWithheld()
? $corrections->release($image, auth()->user(), $this->correctionReason)
: $corrections->withhold($image, auth()->user(), $this->correctionReason);
$this->reset(['correctionReason']);
Flux::toast(text: $image->fresh()->isWithheld() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
}
public function with(): array
{
$pr = PressRelease::withoutGlobalScopes()
@ -238,6 +317,46 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif
</article>
{{-- ============== KORREKTURWEG BILD (E.6) ============== --}}
@if (! $coverIsPlaceholder && isset($adminTitleImage) && $adminTitleImage && ! $adminTitleImage->isLegacyRules())
<article class="panel" data-image-correction>
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bildnachweis und Kennzeichnung korrigieren') }}</span>
@if ($adminTitleImage->isWithheld())
<span class="badge">{{ __('vorläufig zurückgehalten') }}</span>
@endif
</div>
<div class="p-5 space-y-3 max-w-[760px]">
<p class="m-0 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Kundenangabe und Bestätigung bleiben unverändert; jede Korrektur wird mit Grund und Bearbeiter protokolliert. Kundenangabe: :user · Portalkennzeichnung: :portal (:source).', [
'user' => $adminTitleImage->ai_declaration_user?->label() ?? '–',
'portal' => $adminTitleImage->ai_declaration?->label() ?? '–',
'source' => $adminTitleImage->ai_declaration_source?->publicLabel() ?? '–',
]) }}
</p>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="correctionCredit" :label="__('Neuer Bildnachweis')" :placeholder="$adminTitleImage->copyright" />
<flux:select wire:model="correctionAiDeclaration" :label="__('Neue Portalkennzeichnung')">
<flux:select.option value="">{{ __('unverändert') }}</flux:select.option>
@foreach (AiImageDeclaration::cases() as $aiOption)
<flux:select.option :value="$aiOption->value">{{ $aiOption->label() }}</flux:select.option>
@endforeach
</flux:select>
</div>
<flux:textarea wire:model="correctionReason" :label="__('Grund (Pflicht)')" rows="2" />
<div class="flex flex-wrap gap-2">
<flux:button variant="primary" wire:click="correctImage">{{ __('Korrektur übernehmen') }}</flux:button>
<flux:button variant="filled" wire:click="toggleWithheld">
{{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
</flux:button>
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Sichtprüfung im Admin']) }}" target="_blank">
{{ __('Beweisoriginal öffnen') }}
</flux:button>
</div>
</div>
</article>
@endif
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">