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 = PublishedImageSnapshot::sidecarPath($image->published_snapshot_path); if (is_string($sidecar) && $disk->exists($sidecar)) { $files['published.json'] = (string) $disk->get($sidecar); } } // Jede weitere Fassung des Veröffentlichungssnapshots (N09): OG mit // eingebranntem Nachweis sowie Cover und Sidecar früherer // Korrekturstände. Die aktuelle Fassung liegt schon als published.*. $current = array_filter([$image->published_snapshot_path, PublishedImageSnapshot::sidecarPath($image->published_snapshot_path)]); foreach (array_diff(PublishedImageSnapshot::versionPaths($image), $current) as $versionPath) { $files['published-history/'.basename($versionPath)] = (string) $disk->get($versionPath); } $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_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. * Blockierender Direktweg (deleteFromAdmin); alle anderen Wege halten den * Stand zuerst im Auftrag fest (depublicationState) und archivieren über * recordDepublished(). */ public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null, ?LegalRequest $legalRequest = null): ?ArchiveEntry { return $this->recordDepublished($pressRelease, $this->depublicationState($pressRelease), $reason, $actor, $note, $legalRequest); } /** * Stand der Meldung zum Zeitpunkt der Depublikation (Umsetzungsaudit * 29.09.2026, A-04): wird in der fachlichen Transaktion am Auftrag * gespeichert, damit ein nachgeholter Eintrag nicht den Stand nach einer * späteren Bearbeitung oder Schwärzung abbildet. Firma und Kontakte ohne * PortalScope — ein Request mit anderem Portal-Kontext darf sie nicht * aus dem Nachweis filtern. Nur JSON-taugliche Werte. * * @return array */ public function depublicationState(PressRelease $pressRelease): array { $pressRelease->load([ 'company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), 'contacts' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), 'user', 'images', ]); $images = []; foreach ($pressRelease->images as $image) { $images[] = [ '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, 'snapshot_disk' => $image->original_disk ?? (string) config('images.originals_disk'), 'snapshot_path' => $image->published_snapshot_path, ]; } return [ 'captured_at' => now()->toIso8601String(), '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']))->values()->all(), 'images' => $images, ]; } /** * Archiveintrag `pm_depublished` aus einem festgehaltenen Stand. Die * Snapshot-Dateien werden beim Schreiben gelesen, sofern noch vorhanden; * Speicherpfade bleiben außerhalb des Manifests (E.4). * * @param array $state aus depublicationState() */ public function recordDepublished(PressRelease $pressRelease, array $state, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null, ?LegalRequest $legalRequest = null): ?ArchiveEntry { $files = []; $images = []; foreach ((array) ($state['images'] ?? []) as $image) { $path = $image['snapshot_path'] ?? null; $diskName = $image['snapshot_disk'] ?? null; unset($image['snapshot_path'], $image['snapshot_disk']); if (is_string($path) && $path !== '' && is_string($diskName)) { $disk = Storage::disk($diskName); if ($disk->exists($path)) { $name = 'image-'.$image['id'].'.'.pathinfo($path, PATHINFO_EXTENSION); $files[$name] = (string) $disk->get($path); $image['file'] = $name; } else { // Datei weg (Umsetzungsaudit 02.10.2026, O08): Wurde das // Bild inzwischen selbst archiviert, verweist der Nachweis // verbindlich darauf — dort liegen alle Snapshot-Fassungen. // Sonst bleibt der Auftrag mit sichtbarem Fehler offen, // statt still einen unvollständigen Nachweis abzulegen. $image['archived_in'] = $this->imageArchiveReference((int) $image['id']); } } else { // Altbestand ohne Veröffentlichungssnapshot: ausdrücklich // kennzeichnen, nicht stillschweigend weglassen. $image['snapshot'] = 'none'; } $images[] = $image; } return $this->archive->record( ArchiveEvent::PressReleaseDepublished, $pressRelease, $pressRelease->getKey(), $reason, $this->actorType($actor), $actor?->getKey(), [ 'press_release' => (array) ($state['press_release'] ?? []), 'contacts' => (array) ($state['contacts'] ?? []), 'images' => $images, 'state_captured_at' => $state['captured_at'] ?? null, 'legal_request' => $this->legalRequestBlock($legalRequest), 'note' => $note, ], $files, ['images' => count($images), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()], $legalRequest?->getKey(), ); } /** * Verweis auf den Archiveintrag, in dem das Bild samt Snapshot-Fassungen * liegt; ohne einen solchen fehlt der Nachweis — Ausnahme, der Auftrag * bleibt offen. * * @return array{archive_entry_id: int, event: string} */ private function imageArchiveReference(int $imageId): array { $entry = ArchiveEntry::query() ->where('subject_type', (new PressReleaseImage)->getMorphClass()) ->where('subject_id', $imageId) ->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value]) ->latest('id') ->first(); if ($entry === null) { throw new RuntimeException(sprintf('Beweisarchiv: Snapshot von Bild #%d fehlt und ist nicht anderweitig archiviert — Depublikationsnachweis bleibt offen.', $imageId)); } return ['archive_entry_id' => (int) $entry->getKey(), 'event' => $entry->event->value]; } /** * 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 $before * @param array $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|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 */ private function pressReleaseHeader(PressRelease $pressRelease): array { if (! $pressRelease->relationLoaded('company') || $pressRelease->company === null) { $pressRelease->load(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)]); } $pressRelease->loadMissing('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'; } }