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>
176 lines
6.1 KiB
PHP
176 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Archive;
|
|
|
|
use App\Enums\ArchiveEvent;
|
|
use App\Enums\ArchiveReasonCategory;
|
|
use App\Enums\LegalRequestType;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\ArchiveEntry;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseImage;
|
|
use App\Models\PressReleaseRedactionSnapshot;
|
|
use App\Scopes\PortalScope;
|
|
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Nachholer für Archiveinträge (Umsetzungsaudit 25.09.2026, N11): Der
|
|
* Geschäftsvorgang setzt `press_releases.archive_pending_at` in seiner
|
|
* Transaktion und löscht den Marker erst nach erfolgreichem Eintrag.
|
|
* Bleibt er stehen, holt retry() alles nach, was seit dem Marker fehlt:
|
|
*
|
|
* - `pm_depublished`, wenn die Meldung nicht mehr veröffentlicht ist;
|
|
* - `gdpr_change` je Redaction-Snapshot ohne Eintrag (Vorher-Stand aus dem
|
|
* Snapshot, Nachher-Stand aus der Meldung);
|
|
* - `image_removed` je weich gelöschtem v2-Bild ohne Eintrag.
|
|
*/
|
|
class PendingArchiveRetry
|
|
{
|
|
public function __construct(
|
|
private readonly PressReleaseArchiver $archiver,
|
|
private readonly PressReleaseImageRemover $remover,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{recorded: int, failed: int}
|
|
*/
|
|
public function retry(PressRelease $pressRelease): array
|
|
{
|
|
$result = ['recorded' => 0, 'failed' => 0];
|
|
$since = $pressRelease->archive_pending_at;
|
|
|
|
if ($since === null) {
|
|
return $result;
|
|
}
|
|
|
|
$snapshots = PressReleaseRedactionSnapshot::query()
|
|
->where('press_release_id', $pressRelease->getKey())
|
|
->where('created_at', '>=', $since->copy()->subMinute())
|
|
->with('legalRequest')
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
$this->attempt($result, fn () => $this->retryDepublished($pressRelease, $since));
|
|
|
|
foreach ($snapshots as $snapshot) {
|
|
$this->attempt($result, fn () => $this->retryGdprChange($pressRelease, $snapshot));
|
|
}
|
|
|
|
$latestRequest = $snapshots->last()?->legalRequest;
|
|
|
|
foreach ($this->trashedImagesWithoutEntry($pressRelease) as $image) {
|
|
$this->attempt($result, fn () => $this->remover->archiveTrashed(
|
|
$image,
|
|
null,
|
|
ArchiveEvent::ImageRemoved,
|
|
$latestRequest !== null ? ArchiveReasonCategory::Gdpr : ArchiveReasonCategory::OwnDecision,
|
|
$latestRequest,
|
|
'Nachgeholt durch archive:retry-pending',
|
|
));
|
|
}
|
|
|
|
if ($result['failed'] === 0) {
|
|
$pressRelease->forceFill(['archive_pending_at' => null])->save();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array{recorded: int, failed: int} $result
|
|
* @param callable(): ?ArchiveEntry $step
|
|
*/
|
|
private function attempt(array &$result, callable $step): void
|
|
{
|
|
try {
|
|
$entry = $step();
|
|
} catch (Throwable $exception) {
|
|
$result['failed']++;
|
|
Log::error('Beweisarchiv: Nachholen eines Eintrags fehlgeschlagen.', ['error' => $exception->getMessage()]);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($entry === null) {
|
|
return;
|
|
}
|
|
|
|
$result['recorded']++;
|
|
}
|
|
|
|
private function retryDepublished(PressRelease $pressRelease, \DateTimeInterface $since): ?ArchiveEntry
|
|
{
|
|
if ($pressRelease->status === PressReleaseStatus::Published || $pressRelease->published_ever_at === null) {
|
|
return null;
|
|
}
|
|
|
|
$exists = ArchiveEntry::query()
|
|
->where('press_release_id', $pressRelease->getKey())
|
|
->where('event', ArchiveEvent::PressReleaseDepublished->value)
|
|
->where('created_at', '>=', $since)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
return null;
|
|
}
|
|
|
|
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)]);
|
|
|
|
return $this->archiver->pressReleaseDepublished(
|
|
$pressRelease,
|
|
$pressRelease->depublished_at !== null ? ArchiveReasonCategory::CustomerRequest : ArchiveReasonCategory::OwnDecision,
|
|
null,
|
|
'Nachgeholt durch archive:retry-pending',
|
|
);
|
|
}
|
|
|
|
private function retryGdprChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot): ?ArchiveEntry
|
|
{
|
|
$exists = ArchiveEntry::query()
|
|
->where('press_release_id', $pressRelease->getKey())
|
|
->where('event', ArchiveEvent::GdprChange->value)
|
|
->get(['id', 'summary'])
|
|
->contains(fn (ArchiveEntry $entry): bool => (int) ($entry->summary['redaction_snapshot_id'] ?? 0) === $snapshot->getKey());
|
|
|
|
if ($exists) {
|
|
return null;
|
|
}
|
|
|
|
$before = (array) ($snapshot->encrypted_before['fields'] ?? []);
|
|
$legalRequest = $snapshot->legalRequest;
|
|
|
|
return $this->archiver->gdprChange(
|
|
$pressRelease,
|
|
$before,
|
|
$pressRelease->only(array_keys($before)),
|
|
$legalRequest,
|
|
null,
|
|
$legalRequest?->type === LegalRequestType::Correction ? ArchiveReasonCategory::Correction : ArchiveReasonCategory::Gdpr,
|
|
'Nachgeholt durch archive:retry-pending',
|
|
$snapshot->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, PressReleaseImage>
|
|
*/
|
|
private function trashedImagesWithoutEntry(PressRelease $pressRelease): Collection
|
|
{
|
|
$morph = (new PressReleaseImage)->getMorphClass();
|
|
|
|
return PressReleaseImage::withTrashed()
|
|
->where('press_release_id', $pressRelease->getKey())
|
|
->whereNotNull('deleted_at')
|
|
->whereNotNull('published_ever_at')
|
|
->whereNotNull('rules_version')
|
|
->get()
|
|
->reject(fn (PressReleaseImage $image): bool => ArchiveEntry::query()
|
|
->where('subject_type', $morph)
|
|
->where('subject_id', $image->getKey())
|
|
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
|
|
->exists());
|
|
}
|
|
}
|