presseportale/app/Services/Archive/PendingArchiveRetry.php
Kevin Adametz 2a2215b3c3 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

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());
}
}