presseportale/app/Services/Archive/PressReleaseArchiver.php
Kevin Adametz 48129e99a0 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

234 lines
10 KiB
PHP

<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveEntry;
use App\Models\ImageDeclarationCorrection;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Support\ImageCredit;
use Illuminate\Support\Facades\Storage;
/**
* Baut die Archiv-Payloads für die Ereignisse aus Decision-Update 4.1 und
* ruft das Beweisarchiv. Fachdaten liegen im Manifest, Dateien (Original,
* veröffentlichte Fassung) im ZIP. Fehlt der Schlüssel, protokolliert das
* Archiv den Ausfall; die Geschäftsvorgänge laufen trotzdem weiter. Der
* Bezug zum Vorgang im Meldeverfahren liegt als Spalte am Eintrag (5.1 d).
*/
class PressReleaseArchiver
{
public function __construct(private readonly EvidenceArchive $archive) {}
/**
* Veröffentlichtes Bild entfernt oder ersetzt: Original, veröffentlichte
* Fassung, Bestätigungen und Korrekturen wandern ins Archiv.
*/
public function imageRemoved(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?string $note = null): ?ArchiveEntry
{
$files = [];
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
if ($image->original_path !== null && $disk->exists($image->original_path)) {
$files['original.'.pathinfo($image->original_path, PATHINFO_EXTENSION)] = (string) $disk->get($image->original_path);
}
if ($image->published_snapshot_path !== null && $disk->exists($image->published_snapshot_path)) {
$files['published.'.pathinfo($image->published_snapshot_path, PATHINFO_EXTENSION)] = (string) $disk->get($image->published_snapshot_path);
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
if (is_string($sidecar) && $disk->exists($sidecar)) {
$files['published.json'] = (string) $disk->get($sidecar);
}
}
$pressRelease = $image->pressRelease;
return $this->archive->record(
$event,
$image,
$image->press_release_id,
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'image' => $image->only([
'id', 'press_release_id', 'path', 'variants', 'title', 'description', 'copyright', 'credit_source', 'author',
'license_url', 'source_url', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'ai_declaration_locked',
'provenance_status', 'provenance', 'original_sha256', 'published_snapshot_sha256', 'rules_version',
'published_ever_at', 'withheld_at', 'precheck', 'precheck_outcome', 'precheck_review_required_at',
'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'created_at', 'deleted_at',
]),
'credit_line' => $pressRelease ? ImageCredit::for($image, $pressRelease->company)->line() : null,
'confirmations' => $image->rightsConfirmations()->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
'corrections' => ImageDeclarationCorrection::query()->where('press_release_image_id', $image->getKey())->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
'press_release' => $pressRelease ? $this->pressReleaseHeader($pressRelease) : null,
'legal_request' => $this->legalRequestBlock($legalRequest),
'note' => $note,
],
$files,
['image_id' => $image->getKey(), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()],
$legalRequest?->getKey(),
);
}
/**
* Meldung aus dem öffentlichen Angebot genommen: letzter veröffentlichter
* Stand mit Text, Bildern (Snapshots), Kontaktfeldern, Einreicher, Zeiten.
*/
public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null): ?ArchiveEntry
{
$pressRelease->loadMissing(['company', 'user', 'contacts', 'images']);
$files = [];
$images = [];
foreach ($pressRelease->images as $image) {
$entry = [
'id' => $image->getKey(),
'credit_line' => ImageCredit::for($image, $pressRelease->company)->line(),
'ai_declaration' => $image->ai_declaration?->value,
'ai_declaration_source' => $image->ai_declaration_source?->value,
'original_sha256' => $image->original_sha256,
'published_snapshot_sha256' => $image->published_snapshot_sha256,
'rules_version' => $image->rules_version,
];
if ($image->published_snapshot_path !== null) {
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
if ($disk->exists($image->published_snapshot_path)) {
$name = 'image-'.$image->getKey().'.'.pathinfo($image->published_snapshot_path, PATHINFO_EXTENSION);
$files[$name] = (string) $disk->get($image->published_snapshot_path);
$entry['file'] = $name;
}
}
$images[] = $entry;
}
return $this->archive->record(
ArchiveEvent::PressReleaseDepublished,
$pressRelease,
$pressRelease->getKey(),
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'press_release' => $this->pressReleaseHeader($pressRelease) + $pressRelease->only([
'subtitle', 'text', 'keywords', 'backlink_url', 'language', 'category_id', 'boilerplate_override',
]),
'contacts' => $pressRelease->contacts->map(fn ($contact) => $contact->only(['id', 'title', 'first_name', 'last_name', 'email', 'phone', 'responsibility']))->all(),
'images' => $images,
'note' => $note,
],
$files,
['images' => count($images), 'files' => count($files)],
);
}
/**
* DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf
* den Antrag. Der Vorher-Stand kommt aus dem Redaction-Snapshot, damit
* eine Wiederholung denselben Nachweis liefert (N11).
*
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
public function gdprChange(PressRelease $pressRelease, array $before, array $after, ?LegalRequest $legalRequest, ?User $actor = null, ArchiveReasonCategory $reason = ArchiveReasonCategory::Gdpr, ?string $note = null, ?int $redactionSnapshotId = null): ?ArchiveEntry
{
return $this->archive->record(
ArchiveEvent::GdprChange,
$pressRelease,
$pressRelease->getKey(),
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'press_release' => $this->pressReleaseHeader($pressRelease),
'before' => $before,
'after' => $after,
'legal_request' => $this->legalRequestBlock($legalRequest),
'redaction_snapshot_id' => $redactionSnapshotId,
'note' => $note,
],
[],
['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey(), 'redaction_snapshot_id' => $redactionSnapshotId],
$legalRequest?->getKey(),
);
}
/**
* Korrekturweg (E.6): alter und neuer Wert, Quelle, Grund, Bearbeiter.
*/
public function labelCorrected(PressReleaseImage $image, ImageDeclarationCorrection $correction, ?User $actor = null): ?ArchiveEntry
{
return $this->archive->record(
ArchiveEvent::LabelCorrected,
$image,
$image->press_release_id,
ArchiveReasonCategory::Correction,
$this->actorType($actor),
$actor?->getKey(),
[
'correction' => $correction->toArray(),
'image' => $image->only(['id', 'copyright', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'withheld_at']),
],
[],
['correction_id' => $correction->getKey(), 'field' => $correction->field],
);
}
/**
* @return array<string, mixed>|null
*/
private function legalRequestBlock(?LegalRequest $legalRequest): ?array
{
if ($legalRequest === null) {
return null;
}
return [
'id' => $legalRequest->getKey(),
'reference' => $legalRequest->reference(),
'type' => $legalRequest->type->value,
'requester_email' => $legalRequest->requester_email,
];
}
/**
* @return array<string, mixed>
*/
private function pressReleaseHeader(PressRelease $pressRelease): array
{
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), 'user']);
return [
'id' => $pressRelease->getKey(),
'uuid' => $pressRelease->uuid,
'portal' => $pressRelease->portal->value,
'title' => $pressRelease->title,
'slug' => $pressRelease->slug,
'status' => $pressRelease->status->value,
'published_at' => $pressRelease->published_at?->toIso8601String(),
'published_ever_at' => $pressRelease->published_ever_at?->toIso8601String(),
'depublished_at' => $pressRelease->depublished_at?->toIso8601String(),
'company' => $pressRelease->company ? ['id' => $pressRelease->company->getKey(), 'name' => $pressRelease->company->name] : null,
'submitter' => $pressRelease->user ? ['id' => $pressRelease->user->getKey(), 'email' => $pressRelease->user->email, 'name' => $pressRelease->user->name] : null,
];
}
private function actorType(?User $actor): string
{
if ($actor === null) {
return 'system';
}
return $actor->canAccessAdmin() ? 'admin' : 'customer';
}
}