presseportale/resources/views/livewire/components/press-release-attachments-manager.blade.php
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00

404 lines
17 KiB
PHP

<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Services\PressRelease\PressReleaseAttachmentStorage;
use App\Scopes\PortalScope;
use Flux\Flux;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Reusable attachments manager for a single press release. Mirrors the API
* of the press-release-images-manager: upload, edit metadata, reorder,
* delete. Authorisation is delegated to the `update` ability on the
* PressReleasePolicy so the component is safe for admins and customers.
*
* Storage uses the private disk. Downloads run through a controller that
* checks publication state or the PressRelease policy.
*/
new class extends Component
{
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newFile = null;
public string $newTitle = '';
public string $newDescription = '';
public ?int $editingId = null;
public string $editTitle = '';
public string $editDescription = '';
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function upload(PressReleaseAttachmentStorage $storage): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
$this->addError('newFile', __('Anhänge können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
$maxKb = (int) (PressReleaseAttachmentStorage::MAX_BYTES / 1024);
$allowedExtensions = implode(',', PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS);
$this->validate([
'newFile' => ['required', 'file', 'mimes:'.$allowedExtensions, 'max:'.$maxKb],
'newTitle' => ['nullable', 'string', 'max:120'],
'newDescription' => ['nullable', 'string', 'max:500'],
]);
$stored = $storage->store($this->newFile, $pressRelease->id);
$pressRelease->attachments()->create([
'disk' => $stored['disk'],
'path' => $stored['path'],
'original_name' => $stored['original_name'],
'mime' => $stored['mime'],
'size' => $stored['size'],
'title' => $this->newTitle ?: null,
'description' => $this->newDescription ?: null,
'sort_order' => ((int) $pressRelease->attachments()->max('sort_order')) + 1,
]);
$this->reset(['newFile', 'newTitle', 'newDescription']);
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);
if (! $attachment) {
return;
}
$this->editingId = $attachment->id;
$this->editTitle = $attachment->title ?? '';
$this->editDescription = $attachment->description ?? '';
}
public function cancelEdit(): void
{
$this->reset(['editingId', 'editTitle', 'editDescription']);
}
public function updateAttachment(): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease) || $this->editingId === null) {
return;
}
$this->validate([
'editTitle' => ['nullable', 'string', 'max:120'],
'editDescription' => ['nullable', 'string', 'max:500'],
]);
$attachment = $this->getAttachment($this->editingId);
if (! $attachment) {
return;
}
$attachment->update([
'title' => trim($this->editTitle) ?: null,
'description' => trim($this->editDescription) ?: null,
]);
$this->cancelEdit();
Flux::toast(text: __('Anhang aktualisiert.'), variant: 'success');
}
public function moveUp(int $attachmentId): void
{
$this->swapSortOrder($attachmentId, -1);
}
public function moveDown(int $attachmentId): void
{
$this->swapSortOrder($attachmentId, 1);
}
public function remove(int $attachmentId, PressReleaseAttachmentStorage $storage): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
return;
}
$attachment = $this->getAttachment($attachmentId);
if (! $attachment) {
return;
}
$storage->delete($attachment->disk, $attachment->path);
$attachment->delete();
Flux::toast(text: __('Anhang entfernt.'), variant: 'success');
}
public function with(): array
{
$pressRelease = $this->getPressRelease();
return [
'attachments' => $pressRelease->attachments()
->orderBy('sort_order')
->orderBy('id')
->get(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
&& $this->canChangeAttachments($pressRelease),
'maxMb' => round(PressReleaseAttachmentStorage::MAX_BYTES / 1024 / 1024),
'allowedExtensions' => PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS,
];
}
private function swapSortOrder(int $attachmentId, int $direction): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
return;
}
$attachments = $pressRelease->attachments()->orderBy('sort_order')->orderBy('id')->get();
$currentIndex = $attachments->search(fn (PressReleaseAttachment $att) => $att->id === $attachmentId);
if ($currentIndex === false) {
return;
}
$targetIndex = $currentIndex + $direction;
if ($targetIndex < 0 || $targetIndex >= $attachments->count()) {
return;
}
$current = $attachments[$currentIndex];
$target = $attachments[$targetIndex];
$currentSort = $current->sort_order;
$current->update(['sort_order' => $target->sort_order]);
$target->update(['sort_order' => $currentSort]);
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScope(PortalScope::class)
->findOrFail($this->pressReleaseId);
}
private function getAttachment(int $attachmentId): ?PressReleaseAttachment
{
return PressReleaseAttachment::query()
->where('press_release_id', $this->pressReleaseId)
->whereKey($attachmentId)
->first();
}
private function canChangeAttachments(PressRelease $pressRelease): bool
{
if (auth()->user()?->canAccessAdmin()) {
return ! in_array($pressRelease->status, [PressReleaseStatus::Archived], true);
}
return in_array(
$pressRelease->status,
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
true,
);
}
}; ?>
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4 flex-wrap">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Anhänge / Downloads') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
— {{ count($attachments) }}/10
</span>
</span>
<div class="flex items-center gap-2">
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
{{ strtoupper(implode(' · ', $allowedExtensions)) }} · max. {{ $maxMb }} MB
</span>
</div>
</div>
@if ($canEdit)
<form wire:submit="upload"
class="rounded-[5px] border border-dashed border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
<flux:file-upload
wire:model="newFile"
accept=".{{ implode(',.', $allowedExtensions) }}"
>
<flux:file-upload.dropzone
:heading="__('Datei hierher ziehen oder klicken')"
:text="strtoupper(implode(' · ', $allowedExtensions)).' · max. '.$maxMb.' MB'"
with-progress
inline
/>
</flux:file-upload>
<flux:error name="newFile" />
@if ($newFile)
<flux:file-item
:heading="$newFile->getClientOriginalName()"
:size="$newFile->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="removeNewFile" :aria-label="__('Datei entfernen')" />
</x-slot>
</flux:file-item>
@endif
<div class="flex justify-end">
<flux:button type="submit" variant="primary" icon="arrow-up-tray" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="upload,newFile">{{ __('Hochladen') }}</span>
<span wire:loading wire:target="upload,newFile">{{ __('Lädt…') }}</span>
</flux:button>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Titel (optional)') }}</flux:label>
<flux:input wire:model="newTitle" placeholder="{{ __('z.B. Pressemappe Frühjahr 2026') }}" />
<flux:error name="newTitle" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung (optional)') }}</flux:label>
<flux:input wire:model="newDescription" placeholder="{{ __('Kurze Beschreibung des Dokuments') }}" />
<flux:error name="newDescription" />
</flux:field>
</div>
</form>
@endif
@if ($attachments->isEmpty())
<div class="mt-4 rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-6 text-center">
<flux:icon name="document" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Anhänge — Pressemappen, Factsheets oder Bildmaterial-Pakete passen hier rein.') }}
</p>
</div>
@else
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@foreach ($attachments as $attachment)
<article class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] p-3"
wire:key="att-{{ $attachment->id }}">
@if ($editingId === $attachment->id && $canEdit)
{{-- Inline-Edit-Form --}}
<div class="space-y-2">
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="editTitle" />
<flux:error name="editTitle" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:input wire:model="editDescription" />
<flux:error name="editDescription" />
</flux:field>
<div class="flex justify-end gap-1 pt-1">
<flux:button size="xs" variant="filled" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
<flux:button size="xs" variant="primary" icon="check" wire:click="updateAttachment">{{ __('Speichern') }}</flux:button>
</div>
</div>
@else
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-11 h-11 rounded-[4px] bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)] flex items-center justify-center">
@php
$ext = strtolower(pathinfo($attachment->original_name ?? '', PATHINFO_EXTENSION));
$iconName = match (true) {
$ext === 'pdf' => 'document-text',
in_array($ext, ['doc', 'docx'], true) => 'document-text',
in_array($ext, ['xls', 'xlsx'], true) => 'table-cells',
in_array($ext, ['ppt', 'pptx'], true) => 'presentation-chart-bar',
$ext === 'zip' => 'archive-box',
default => 'document',
};
@endphp
<flux:icon :name="$iconName" variant="mini" class="size-5" />
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0 truncate"
title="{{ $attachment->title ?? $attachment->original_name }}">
{{ $attachment->title ?? $attachment->original_name }}
</p>
@if ($attachment->title)
<p class="text-[11px] text-[color:var(--color-ink-4)] m-0 truncate"
title="{{ $attachment->original_name }}">
{{ $attachment->original_name }}
</p>
@endif
@if ($attachment->description)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] mt-1 mb-0 line-clamp-2">
{{ $attachment->description }}
</p>
@endif
<p class="text-[10.5px] text-[color:var(--color-ink-4)] font-mono mt-1 mb-0">
{{ strtoupper($ext ?: '?') }} ·
@php
$bytes = (int) $attachment->size;
$sizeLabel = $bytes >= 1048576
? number_format($bytes / 1048576, 1, ',', '.').' MB'
: number_format(max(1, (int) round($bytes / 1024)), 0, ',', '.').' KB';
@endphp
{{ $sizeLabel }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-1 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)]">
@if ($attachment->url())
<flux:button size="xs" variant="filled" icon="arrow-down-tray"
href="{{ $attachment->url() }}" target="_blank" rel="noopener">
{{ __('Download') }}
</flux:button>
@endif
@if ($canEdit)
<flux:button size="xs" variant="filled" icon="pencil-square" wire:click="startEdit({{ $attachment->id }})" :title="__('Bearbeiten')" />
<flux:button size="xs" variant="filled" icon="arrow-up" wire:click="moveUp({{ $attachment->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="filled" icon="arrow-down" wire:click="moveDown({{ $attachment->id }})" :title="__('Runter')" />
<span class="flex-1"></span>
<flux:button size="xs" variant="filled" icon="trash" wire:click="remove({{ $attachment->id }})"
wire:confirm="{{ __('Anhang wirklich entfernen?') }}" :title="__('Entfernen')" />
@endif
</div>
@endif
</article>
@endforeach
</div>
@endif
</div>
</section>