From 2a2215b3c3eaac87f80b0ab1a8c79a1441c28bbb Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 25 Sep 2026 14:26:49 +0000 Subject: [PATCH] =?UTF-8?q?audit:=20Nacharbeit=20Umsetzungsaudit=2025.09.2?= =?UTF-8?q?026=20=E2=80=94=20Bildfreigabe,=20Beweisarchiv,=20KI-Atomarit?= =?UTF-8?q?=C3=A4t,=20Berichte=20(Block=208,=20N01=E2=80=93N05,=20N07,=20N?= =?UTF-8?q?08,=20N10=E2=80=93N21,=205.1,=205.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/AggregatePressReleaseViews.php | 9 +- .../Commands/ArchiveExportTombstones.php | 13 +- app/Console/Commands/ArchivePurge.php | 20 +- .../Commands/ArchiveReconcileAfterRestore.php | 10 +- app/Console/Commands/ArchiveRetryPending.php | 49 +++ app/Console/Commands/ArchiveSweepOrphans.php | 35 ++ app/Console/Commands/ExpireGrandfathering.php | 63 +++- .../PurgeExpiredPressReleaseDrafts.php | 58 +++- .../Commands/PurgePressReleaseEvents.php | 12 +- .../Api/V1/PressReleaseImageController.php | 8 +- .../Web/PressReleaseImageMediaController.php | 7 +- app/Jobs/ClassifyPressRelease.php | 116 ++++--- app/Jobs/ScorePressRelease.php | 72 +++-- app/Models/AppSetting.php | 23 +- app/Models/ArchiveEntry.php | 1 + app/Models/ImageDeclarationCorrection.php | 21 +- app/Models/ImageRightsConfirmation.php | 17 + app/Models/PressRelease.php | 2 + app/Models/PressReleaseImage.php | 30 +- .../ImageRightsConfirmationObserver.php | 4 +- app/Policies/PressReleasePolicy.php | 24 +- app/Providers/AppServiceProvider.php | 4 +- app/Services/Admin/AdminSystemHealth.php | 26 ++ app/Services/Archive/EvidenceArchive.php | 304 ++++++++++++++---- app/Services/Archive/PendingArchiveRetry.php | 176 ++++++++++ app/Services/Archive/PressReleaseArchiver.php | 45 ++- .../Drivers/OpenAiImagePrecheckDriver.php | 37 +++ .../Image/Precheck/ImagePrecheckResult.php | 4 +- .../PressRelease/AnonymizationService.php | 125 +++++-- .../PressRelease/CanonicalAssigner.php | 29 +- .../PressRelease/CorrectionService.php | 81 +++-- app/Services/PressRelease/FeedFetchReport.php | 8 +- .../Images/ImageCorrectionService.php | 7 +- .../Images/PressReleaseImageRemover.php | 97 ++++-- .../Images/PressReleaseImageUploader.php | 100 ++++-- .../PressRelease/PressReleaseService.php | 170 ++++++++-- .../PressRelease/ViewDistributionReport.php | 16 +- app/Services/PressRelease/VisibilityStats.php | 22 +- .../SearchConsole/IndexStatusReport.php | 34 +- app/Support/FileDeletion.php | 68 ++++ ...al_request_id_to_archive_entries_table.php | 30 ++ ...ive_pending_at_to_press_releases_table.php | 33 ++ dev/2026-09-01/basiswerte-block0.md | 12 + dev/betrieb/deploy-runbook.md | 15 +- .../livewire/admin/archive/index.blade.php | 28 +- .../livewire/admin/categories/edit.blade.php | 17 +- .../press-release-images-manager-v2.blade.php | 43 ++- .../customer/press-releases/show.blade.php | 38 ++- routes/console.php | 16 + .../Admin/AdminCategoryManagementTest.php | 22 ++ .../V1/PressReleaseImageRightsV2ApiTest.php | 37 +++ tests/Feature/Archive/EvidenceArchiveTest.php | 258 +++++++++++++++ .../Billing/ExpireGrandfatheringTest.php | 71 ++++ tests/Feature/CanonicalAssignmentTest.php | 62 ++++ .../Feature/ConversionBaselineReportsTest.php | 33 ++ tests/Feature/Images/ImageLifecycleTest.php | 119 +++++++ tests/Feature/Images/ImagePrecheckTest.php | 47 +++ .../Images/PressReleaseImageRightsV2Test.php | 223 ++++++++++++- .../Images/PressReleaseImageUploaderTest.php | 61 ++++ tests/Feature/IndexStatusCheckTest.php | 24 ++ .../PressReleaseClassificationJobTest.php | 139 ++++++++ tests/Feature/PurgePressReleaseEventsTest.php | 31 +- tests/Feature/VisibilityStatsTest.php | 34 ++ tests/Feature/Web/FeedFetchLogTest.php | 14 + .../Feature/Web/PressReleaseTrackingTest.php | 28 ++ tests/Support/FailingDisk.php | 78 +++++ 66 files changed, 3092 insertions(+), 368 deletions(-) create mode 100644 app/Console/Commands/ArchiveRetryPending.php create mode 100644 app/Console/Commands/ArchiveSweepOrphans.php create mode 100644 app/Services/Archive/PendingArchiveRetry.php create mode 100644 app/Support/FileDeletion.php create mode 100644 database/migrations/2026_09_25_153354_add_legal_request_id_to_archive_entries_table.php create mode 100644 database/migrations/2026_09_25_153355_add_archive_pending_at_to_press_releases_table.php create mode 100644 tests/Support/FailingDisk.php diff --git a/app/Console/Commands/AggregatePressReleaseViews.php b/app/Console/Commands/AggregatePressReleaseViews.php index 34abb550..64f17d4a 100644 --- a/app/Console/Commands/AggregatePressReleaseViews.php +++ b/app/Console/Commands/AggregatePressReleaseViews.php @@ -83,7 +83,10 @@ class AggregatePressReleaseViews extends Command $chunk = max(100, (int) $this->option('chunk')); $budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget'))); $startedAt = microtime(true); - $watermark = AppSetting::getValue(self::WatermarkKey); + // Ungecacht (Audit N21): Die Marke ist Betriebszustand, kein + // Admin-Schalter. Ein veralteter Cachewert ließe jeden Lauf an der + // Prüfung unter der Sperre scheitern — dauerhaft. + $watermark = AppSetting::getValueFresh(self::WatermarkKey); // Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu // lesen wäre fatal — `press_releases.hits` enthält den Bestand bereits, @@ -174,6 +177,10 @@ class AggregatePressReleaseViews extends Command $current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first(); if ($current === null || (int) $current->value !== $watermark) { + // Sollte je ein Cache-Leser die Marke veraltet halten: hier + // verwerfen, damit der nächste Lauf sauber neu beginnt. + AppSetting::forgetCached(self::WatermarkKey); + throw new RuntimeException(sprintf( 'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.', $current === null ? 'null' : (string) $current->value, diff --git a/app/Console/Commands/ArchiveExportTombstones.php b/app/Console/Commands/ArchiveExportTombstones.php index c93ac49f..fe9c135c 100644 --- a/app/Console/Commands/ArchiveExportTombstones.php +++ b/app/Console/Commands/ArchiveExportTombstones.php @@ -4,10 +4,12 @@ namespace App\Console\Commands; use App\Services\Archive\EvidenceArchive; use Illuminate\Console\Command; +use Throwable; /** * Beweisarchiv (Block F.3): Tombstone-Stand täglich auf die Archiv-Disk - * exportieren, damit er einen Datenbank-Restore überlebt. + * exportieren, damit er einen Datenbank-Restore überlebt. Ein + * fehlgeschlagener Export endet mit Fehlerstatus (N10). */ class ArchiveExportTombstones extends Command { @@ -17,7 +19,14 @@ class ArchiveExportTombstones extends Command public function handle(EvidenceArchive $archive): int { - $count = $archive->exportTombstones(); + try { + $count = $archive->exportTombstones(); + } catch (Throwable $exception) { + $this->error('Tombstone-Export fehlgeschlagen: '.$exception->getMessage()); + + return self::FAILURE; + } + $this->info("Tombstone-Export geschrieben: {$count} Einträge."); return self::SUCCESS; diff --git a/app/Console/Commands/ArchivePurge.php b/app/Console/Commands/ArchivePurge.php index 4d188d65..2eeb6cea 100644 --- a/app/Console/Commands/ArchivePurge.php +++ b/app/Console/Commands/ArchivePurge.php @@ -7,7 +7,9 @@ use Illuminate\Console\Command; /** * Beweisarchiv (Block F.3): Einträge nach Fristablauf ohne legal_hold - * löschen — Payload weg, Tombstone bleibt. Läuft täglich. + * löschen — Payload weg, Tombstone bleibt. Läuft täglich. Ein Eintrag, + * dessen Datei sich nicht löschen ließ, bleibt aktiv und wird beim + * nächsten Lauf wiederholt; der Lauf endet dann mit Fehlerstatus (N10). */ class ArchivePurge extends Command { @@ -17,9 +19,21 @@ class ArchivePurge extends Command public function handle(EvidenceArchive $archive): int { - $count = $archive->purgeDue((bool) $this->option('dry-run')); + $result = $archive->purgeDue((bool) $this->option('dry-run')); - $this->info(($this->option('dry-run') ? 'Fällig (Trockenlauf): ' : 'Gelöscht: ').$count.' Archiveinträge.'); + if ($this->option('dry-run')) { + $this->info('Fällig (Trockenlauf): '.$result['due'].' Archiveinträge.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Gelöscht: %d Archiveinträge (fällig: %d, übersprungen: %d, fehlgeschlagen: %d).', $result['purged'], $result['due'], $result['due'] - $result['purged'] - $result['failed'], $result['failed'])); + + if ($result['failed'] > 0) { + $this->error('Löschung nicht vollständig — Details im Log; die Einträge bleiben für den nächsten Lauf fällig.'); + + return self::FAILURE; + } return self::SUCCESS; } diff --git a/app/Console/Commands/ArchiveReconcileAfterRestore.php b/app/Console/Commands/ArchiveReconcileAfterRestore.php index 9bc62e8d..ae029949 100644 --- a/app/Console/Commands/ArchiveReconcileAfterRestore.php +++ b/app/Console/Commands/ArchiveReconcileAfterRestore.php @@ -7,21 +7,21 @@ use Illuminate\Console\Command; /** * Beweisarchiv (Block F.3): nach dem Einspielen einer Sicherung alles, was - * laut Tombstone-Export bereits gelöscht war, erneut entfernen. Teil des - * Restore-Runbooks (dev/betrieb/deploy-runbook.md). + * laut Tombstone-Export oder laut Datenbank bereits gelöscht war, erneut + * entfernen. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md). */ class ArchiveReconcileAfterRestore extends Command { protected $signature = 'archive:reconcile-after-restore'; - protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports'; + protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports und der Datenbank-Tombstones'; public function handle(EvidenceArchive $archive): int { $result = $archive->reconcileAfterRestore(); - $this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt.', $result['re_tombstoned'], $result['files_removed'])); + $this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt, %d fehlgeschlagen.', $result['re_tombstoned'], $result['files_removed'], $result['failed'])); - return self::SUCCESS; + return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS; } } diff --git a/app/Console/Commands/ArchiveRetryPending.php b/app/Console/Commands/ArchiveRetryPending.php new file mode 100644 index 00000000..0d46138d --- /dev/null +++ b/app/Console/Commands/ArchiveRetryPending.php @@ -0,0 +1,49 @@ +withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->orderBy('id'); + $pending = $query->count(); + + if ($this->option('dry-run')) { + $this->info("Offen (Trockenlauf): {$pending} Meldungen mit ausstehender Archivierung."); + + return self::SUCCESS; + } + + $recorded = 0; + $failed = 0; + + $query->chunkById(100, function ($pressReleases) use ($retry, &$recorded, &$failed): void { + foreach ($pressReleases as $pressRelease) { + $result = $retry->retry($pressRelease); + $recorded += $result['recorded']; + $failed += $result['failed']; + } + }); + + $remaining = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count(); + $this->info(sprintf('Nachgeholt: %d Archiveinträge für %d Meldungen; fehlgeschlagen: %d; weiterhin offen: %d.', $recorded, $pending, $failed, $remaining)); + + return $failed > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Commands/ArchiveSweepOrphans.php b/app/Console/Commands/ArchiveSweepOrphans.php new file mode 100644 index 00000000..124049d7 --- /dev/null +++ b/app/Console/Commands/ArchiveSweepOrphans.php @@ -0,0 +1,35 @@ +sweepOrphans((bool) $this->option('dry-run'), max(1, (int) $this->option('hours'))); + + $this->info(sprintf( + '%s: %d Dateien geprüft, %d verwaist, %d entfernt.', + $this->option('dry-run') ? 'Trockenlauf' : 'Bereinigung', + $result['scanned'], + $result['orphans'], + $result['removed'], + )); + + return ! $this->option('dry-run') && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Commands/ExpireGrandfathering.php b/app/Console/Commands/ExpireGrandfathering.php index 1e7e4ae7..3c6af30f 100644 --- a/app/Console/Commands/ExpireGrandfathering.php +++ b/app/Console/Commands/ExpireGrandfathering.php @@ -142,8 +142,20 @@ class ExpireGrandfathering extends Command } /** - * Phase 2: je Kunde genau eine Mail für alle offenen Posten, danach - * Marker setzen. Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden]. + * Phase 2: je Kunde genau eine Mail für alle offenen Posten. + * Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden]. + * + * Idempotent (Audit N14): Je Kunde eine Transaktion — offene Posten unter + * Zeilensperre erneut lesen, den Marker per bedingtem UPDATE beanspruchen + * (0 Zeilen = ein anderer Lauf war schneller, nichts einplanen) und erst + * DANACH die Mail in derselben Transaktion einplanen. Scheitert das + * Einplanen, rollt der Marker mit zurück; der nächste Lauf holt nach. + * + * Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION` + * (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und + * ist damit Teil der Transaktion. Bei einem Wechsel auf Redis o. ä. bräuchte + * es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall, + * `notified_at` nach Zustellung). * * @param Collection $pending * @return array{0: int, 1: int} @@ -158,26 +170,45 @@ class ExpireGrandfathering extends Command $user = $options->first()?->user; $ids = $options->pluck('id')->all(); - if ($user !== null && ! $suppressions->isSuppressed($user->email)) { - $latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until); + try { + $queued = DB::transaction(function () use ($ids, $user, $suppressions): bool { + /** @var Collection $open */ + $open = $this->pendingNotificationQuery()->whereIn('id', $ids)->lockForUpdate()->get(); + + if ($open->isEmpty()) { + return false; + } + + $claimed = $this->pendingNotificationQuery() + ->whereIn('id', $open->pluck('id')->all()) + ->update(['grandfathering_notified_at' => now()]); + + if ($claimed === 0) { + return false; + } + + if ($user === null || $suppressions->isSuppressed($user->email)) { + // Gesperrte Adresse: ohne Mail als erledigt markiert. + return false; + } + + $latestEnd = $open->max(fn (UserPaymentOption $option) => $option->grandfathered_until); - try { Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd)); - } catch (\Throwable $exception) { - report($exception); - $this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage())); - $failed++; - continue; - } + return true; + }); + } catch (\Throwable $exception) { + report($exception); + $this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage())); + $failed++; - $notified++; + continue; } - UserPaymentOption::query() - ->whereIn('id', $ids) - ->whereNull('grandfathering_notified_at') - ->update(['grandfathering_notified_at' => now()]); + if ($queued) { + $notified++; + } } return [$notified, $failed]; diff --git a/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php b/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php index 2fdc9eac..eba3d7b1 100644 --- a/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php +++ b/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php @@ -6,10 +6,18 @@ use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Services\PressRelease\Images\PressReleaseImageRemover; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; /** - * Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden. + * Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden, + * und bereinigt die Medien abgelehnter, nie veröffentlichter Einreichungen + * nach derselben Frist (Bildkennzeichnung O1, Umsetzungsaudit N13). * Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar. + * + * Abgelehnte Meldungen behalten ihren Status (Ablehnungsdatensatz bleibt + * als Vertragsdokumentation, DU 4.1); nur die Medien fallen. Altbestand + * mit `published_ever_at` (im Altsystem „offline genommen") bleibt + * unangetastet. */ class PurgeExpiredPressReleaseDrafts extends Command { @@ -17,7 +25,7 @@ class PurgeExpiredPressReleaseDrafts extends Command {--days=180 : Entwürfe älter als X Tage archivieren} {--dry-run : Nur zählen, nichts ändern}'; - protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).'; + protected $description = 'Archiviert inaktive PM-Entwürfe und bereinigt Medien abgelehnter Einreichungen (älter als X Tage).'; public function handle(): int { @@ -25,14 +33,23 @@ class PurgeExpiredPressReleaseDrafts extends Command $isDryRun = $this->option('dry-run'); $cutoff = now()->subDays($days); - $query = PressRelease::withoutGlobalScopes() + $drafts = PressRelease::withoutGlobalScopes() ->where('status', PressReleaseStatus::Draft->value) ->where('updated_at', '<', $cutoff); - $count = $query->count(); + // Abgelehnt und nie veröffentlicht: nur Meldungen, an denen noch + // Bilder hängen — sonst wäre der Lauf jede Woche derselbe Zähler. + $rejected = PressRelease::withoutGlobalScopes() + ->where('status', PressReleaseStatus::Rejected->value) + ->whereNull('published_ever_at') + ->where('updated_at', '<', $cutoff) + ->whereHas('images'); + + $draftCount = $drafts->count(); + $rejectedCount = $rejected->count(); if ($isDryRun) { - $this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang."); + $this->warn("[DRY-RUN] {$draftCount} Entwürfe würden archiviert, {$rejectedCount} abgelehnte Einreichungen würden bereinigt. Kein tatsächlicher Vorgang."); return self::SUCCESS; } @@ -40,21 +57,32 @@ class PurgeExpiredPressReleaseDrafts extends Command // Bilder nie veröffentlichter Entwürfe werden mit dem Purge sofort // und vollständig gelöscht (Bildkennzeichnung E.3, Decision-Update E4). $remover = app(PressReleaseImageRemover::class); - $imagesRemoved = 0; + $imagesRemoved = $this->removeImages((clone $drafts)->whereNull('published_ever_at'), $remover) + + $this->removeImages(clone $rejected, $remover); - (clone $query)->whereNull('published_ever_at')->with('images')->chunkById(200, function ($drafts) use ($remover, &$imagesRemoved): void { - foreach ($drafts as $draft) { - foreach ($draft->images as $image) { + $drafts->update(['status' => PressReleaseStatus::Archived->value]); + + $this->info("PM-Entwürfe archiviert: {$draftCount} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}."); + + return self::SUCCESS; + } + + /** + * @param Builder $query + */ + private function removeImages(Builder $query, PressReleaseImageRemover $remover): int + { + $removed = 0; + + $query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed): void { + foreach ($pressReleases as $pressRelease) { + foreach ($pressRelease->images as $image) { $remover->remove($image); - $imagesRemoved++; + $removed++; } } }); - $query->update(['status' => PressReleaseStatus::Archived->value]); - - $this->info("PM-Entwürfe archiviert: {$count} Einträge, Bilder gelöscht: {$imagesRemoved}."); - - return self::SUCCESS; + return $removed; } } diff --git a/app/Console/Commands/PurgePressReleaseEvents.php b/app/Console/Commands/PurgePressReleaseEvents.php index 127d464b..72ec29ec 100644 --- a/app/Console/Commands/PurgePressReleaseEvents.php +++ b/app/Console/Commands/PurgePressReleaseEvents.php @@ -47,7 +47,9 @@ class PurgePressReleaseEvents extends Command return self::FAILURE; } - $watermark = AppSetting::getValue(AggregatePressReleaseViews::WatermarkKey); + // Ungecacht (Audit N21): Dieser Leser darf die Marke nie veraltet in + // den Cache zurückschreiben — siehe AggregatePressReleaseViews. + $watermark = AppSetting::getValueFresh(AggregatePressReleaseViews::WatermarkKey); if ($watermark === null) { $this->error( @@ -73,6 +75,14 @@ class PurgePressReleaseEvents extends Command $cutoff->format('d.m.Y'), $watermark, )); + // Der echte Lauf räumt auch die beiden Abruf-Protokolle — der + // Probelauf muss denselben Umfang nennen. + $this->info(sprintf( + '[Dry-Run] %d Einträge im Crawler-Abruf-Protokoll und %d im Feed-Abruf-Protokoll älter als %s.', + PressReleaseCrawlerHit::query()->where('created_at', '<', $cutoff)->count(), + FeedFetch::query()->where('created_at', '<', $cutoff)->count(), + $cutoff->format('d.m.Y'), + )); return self::SUCCESS; } diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index f3a121e5..bdc0b98f 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -163,7 +163,13 @@ class PressReleaseImageController extends Controller ); try { - $image = $this->uploader->store($file, $pressRelease, $submission, $actor); + $image = $this->uploader->store( + $file, + $pressRelease, + $submission, + $actor, + allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], + ); } catch (ImageUploadException $exception) { return response()->json([ 'message' => $exception->getMessage(), diff --git a/app/Http/Controllers/Web/PressReleaseImageMediaController.php b/app/Http/Controllers/Web/PressReleaseImageMediaController.php index 537385ce..d3a83286 100644 --- a/app/Http/Controllers/Web/PressReleaseImageMediaController.php +++ b/app/Http/Controllers/Web/PressReleaseImageMediaController.php @@ -25,7 +25,12 @@ class PressReleaseImageMediaController extends Controller $pressRelease = $image->pressRelease; abort_unless($pressRelease instanceof PressRelease, 404); - $isPublic = $this->pressReleaseMediaIsPublic($pressRelease); + + // Dieselbe Freigaberegel wie Seitenabfragen und OG-Auswahl (Audit + // N01): ein zurückgehaltenes oder noch unbestätigtes v2-Bild ist über + // die Direktadresse nicht öffentlich, sondern nur mit Signatur oder + // `view`-Recht (Vorschau, Konto, Admin) und ohne öffentlichen Cache. + $isPublic = $this->pressReleaseMediaIsPublic($pressRelease) && $image->isPubliclyDeliverable(); $this->authorizePressReleaseMediaAccess($request, $pressRelease, $isPublic); $path = $image->pathForVariant($variant); diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php index 7cb2e435..062ced60 100644 --- a/app/Jobs/ClassifyPressRelease.php +++ b/app/Jobs/ClassifyPressRelease.php @@ -15,6 +15,7 @@ use App\Services\PressRelease\QuotaExceededException; use App\Services\PressRelease\StaleClassificationException; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; /** @@ -99,23 +100,54 @@ class ClassifyPressRelease implements ShouldQueue // Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die geprüfte // Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt // geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg. + // + // Ergebnis und Audit in EINER Transaktion (Audit N03): Der Scheduler + // liest die Klassifikation und den Audit-Hash getrennt; ein Commit + // zwischen beiden ließe eine grüne PM kurz ohne Nachweis dastehen — + // und „ohne Nachweis" gilt als auditloser Altbestand, der durchgeht. $classifiedAt = now(); - $stored = PressRelease::withoutGlobalScope(PortalScope::class) - ->whereKey($pressRelease->id) - ->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash)) - ->update([ + + /** @var array{stored: bool, current_hash: string|null} $outcome */ + $outcome = DB::transaction(function () use ($pressRelease, $result, $analyzedHash, $classifiedAt): array { + $stored = PressRelease::withoutGlobalScope(PortalScope::class) + ->whereKey($pressRelease->id) + ->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash)) + ->update([ + 'classification' => $result->classification->value, + 'classified_at' => $classifiedAt, + 'updated_at' => $classifiedAt, + ]); + + if ($stored === 0) { + $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash'); + + if ($currentHash === null) { + // Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK). + return ['stored' => false, 'current_hash' => null]; + } + + KiAudit::query()->create([ + 'press_release_id' => $pressRelease->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => $result->classification->value, + 'reason' => 'Verworfen: Inhalt wurde während der Prüfung geändert. '.$result->reasonText(), + 'raw_response' => $result->rawResponse, + 'content_hash' => $analyzedHash, + 'discarded_at' => now(), + 'created_at' => now(), + ]); + + return ['stored' => false, 'current_hash' => $currentHash]; + } + + // Modell auf den geschriebenen Stand bringen, ohne ein zweites + // (unbedingtes) UPDATE auszulösen. + $pressRelease->forceFill([ 'classification' => $result->classification->value, 'classified_at' => $classifiedAt, - 'updated_at' => $classifiedAt, - ]); - - if ($stored === 0) { - $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash'); - - if ($currentHash === null) { - // Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK). - return; - } + ])->syncOriginal(); KiAudit::query()->create([ 'press_release_id' => $pressRelease->id, @@ -123,49 +155,36 @@ class ClassifyPressRelease implements ShouldQueue 'provider' => $result->provider, 'model' => $result->model, 'result' => $result->classification->value, - 'reason' => 'Verworfen: Inhalt wurde während der Prüfung geändert. '.$result->reasonText(), + 'reason' => $result->reasonText(), 'raw_response' => $result->rawResponse, 'content_hash' => $analyzedHash, - 'discarded_at' => now(), 'created_at' => now(), ]); - self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $currentHash)->onQueue('classification'); + return ['stored' => true, 'current_hash' => $analyzedHash]; + }); + + if (! $outcome['stored']) { + if ($outcome['current_hash'] !== null) { + self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $outcome['current_hash'])->onQueue('classification'); + } return; } - // Modell auf den geschriebenen Stand bringen, ohne ein zweites - // (unbedingtes) UPDATE auszulösen. - $pressRelease->forceFill([ - 'classification' => $result->classification->value, - 'classified_at' => $classifiedAt, - ])->syncOriginal(); - - KiAudit::query()->create([ - 'press_release_id' => $pressRelease->id, - 'type' => KiAudit::TYPE_CLASSIFICATION, - 'provider' => $result->provider, - 'model' => $result->model, - 'result' => $result->classification->value, - 'reason' => $result->reasonText(), - 'raw_response' => $result->rawResponse, - 'content_hash' => $analyzedHash, - 'created_at' => now(), - ]); - - if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result)) { + if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result, $analyzedHash)) { return; } try { - $service->routeByClassification($pressRelease, $result->classification, $result->reasonText()); + $service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash); } catch (BlacklistViolationException) { // publish() hat die PM bereits abgelehnt und den Autor benachrichtigt. } catch (StaleClassificationException $exception) { - // Inhalt zwischen Audit und Publish-Sperre geändert: publish() hat - // bereits eine Prüfung für den aktuellen Stand eingeplant (U02). - Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [ + // Inhalt zwischen Audit und Sperre geändert: publish() bzw. + // reject()/Hold haben bereits eine Prüfung für den aktuellen + // Stand eingeplant (U02, N04). + Log::channel('ki')->info('Automatisches Routing übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [ 'press_release_id' => $pressRelease->id, ]); } catch (QuotaExceededException $exception) { @@ -182,9 +201,12 @@ class ClassifyPressRelease implements ShouldQueue * Roter Re-Check ohne Routing einer noch wartenden PM (Audit U03): nur * dann greift der Rot-Zweig der Erstprüfung. Status frisch lesen — der * KI-Aufruf kann gedauert haben, und ein Admin hat womöglich inzwischen - * entschieden. + * entschieden. Ist der Inhalt seit dem Ergebnis schon wieder geändert + * (Audit N04), gehört das Rot zu einer alten Fassung: nicht ablehnen — + * die Bearbeitung hat bereits einen Re-Check für den neuen Stand + * eingeplant. Verbindlich prüft reject() das noch einmal unter der Sperre. */ - private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result): bool + private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result, ?string $analyzedHash): bool { if ($result->classification !== PressReleaseClassification::Red) { return false; @@ -192,6 +214,14 @@ class ClassifyPressRelease implements ShouldQueue $pressRelease->refresh(); + if ($analyzedHash !== null && $pressRelease->content_hash !== null && $pressRelease->content_hash !== $analyzedHash) { + Log::channel('ki')->info('Roter Re-Check übersprungen: Inhalt seit dem Ergebnis geändert, neuer Re-Check läuft.', [ + 'press_release_id' => $pressRelease->id, + ]); + + return false; + } + return $pressRelease->status === PressReleaseStatus::Review && ! $pressRelease->requiresManualReview(); } diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php index 9948f4c0..8030ed7a 100644 --- a/app/Jobs/ScorePressRelease.php +++ b/app/Jobs/ScorePressRelease.php @@ -10,6 +10,7 @@ use App\Services\PressRelease\ContentScore\ContentScoreManager; use App\Services\PressRelease\ContentScore\ContentScoreResult; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; /** @@ -63,23 +64,45 @@ class ScorePressRelease implements ShouldQueue // Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die bewertete // Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt // geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg. + // Ergebnis und Audit in EINER Transaktion — gleiches Schreibmuster wie + // ClassifyPressRelease (Audit N03), damit Bewertung und Nachweis nie + // getrennt sichtbar werden. $scoredAt = now(); - $stored = PressRelease::withoutGlobalScope(PortalScope::class) - ->whereKey($pressRelease->id) - ->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash)) - ->update([ - 'content_score' => $result->score, - 'content_tier' => $tier->value, - 'scored_at' => $scoredAt, - 'updated_at' => $scoredAt, - ]); - if ($stored === 0) { - $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash'); + /** @var string|null $rescheduleFor */ + $rescheduleFor = DB::transaction(function () use ($pressRelease, $result, $tier, $analyzedHash, $scoredAt): ?string { + $stored = PressRelease::withoutGlobalScope(PortalScope::class) + ->whereKey($pressRelease->id) + ->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash)) + ->update([ + 'content_score' => $result->score, + 'content_tier' => $tier->value, + 'scored_at' => $scoredAt, + 'updated_at' => $scoredAt, + ]); - if ($currentHash === null) { - // Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK). - return; + if ($stored === 0) { + $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash'); + + if ($currentHash === null) { + // Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK). + return null; + } + + KiAudit::query()->create([ + 'press_release_id' => $pressRelease->id, + 'type' => KiAudit::TYPE_CONTENT_SCORE, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => (string) $result->score, + 'reason' => 'Verworfen: Inhalt wurde während der Bewertung geändert.', + 'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse, + 'content_hash' => $analyzedHash, + 'discarded_at' => now(), + 'created_at' => now(), + ]); + + return $currentHash; } KiAudit::query()->create([ @@ -88,29 +111,18 @@ class ScorePressRelease implements ShouldQueue 'provider' => $result->provider, 'model' => $result->model, 'result' => (string) $result->score, - 'reason' => 'Verworfen: Inhalt wurde während der Bewertung geändert.', + 'reason' => $tier->label(), 'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse, 'content_hash' => $analyzedHash, - 'discarded_at' => now(), 'created_at' => now(), ]); - self::dispatch($pressRelease->id, $this->providerOverride, $currentHash)->onQueue('classification'); + return null; + }); - return; + if ($rescheduleFor !== null) { + self::dispatch($pressRelease->id, $this->providerOverride, $rescheduleFor)->onQueue('classification'); } - - KiAudit::query()->create([ - 'press_release_id' => $pressRelease->id, - 'type' => KiAudit::TYPE_CONTENT_SCORE, - 'provider' => $result->provider, - 'model' => $result->model, - 'result' => (string) $result->score, - 'reason' => $tier->label(), - 'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse, - 'content_hash' => $analyzedHash, - 'created_at' => now(), - ]); } /** diff --git a/app/Models/AppSetting.php b/app/Models/AppSetting.php index 0cf4ccad..caf2fc5e 100644 --- a/app/Models/AppSetting.php +++ b/app/Models/AppSetting.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; /** * Laufzeit-Einstellungen mit Admin-UI (Key-Value, JSON-Wert, gecacht). @@ -40,10 +41,30 @@ class AppSetting extends Model return (bool) static::getValue($key, $default); } + /** + * Liest direkt aus der Datenbank, am Cache vorbei — für operative + * Fortschrittsmarken (Wasserstand der Aufruf-Aggregation, Audit N21): + * Ein veralteter Cachewert würde dort jeden Lauf gegen die Zeilensperre + * scheitern lassen, ohne sich je zu korrigieren. + */ + public static function getValueFresh(string $key, mixed $default = null): mixed + { + $value = static::query()->find($key)?->value; + + return $value ?? $default; + } + + /** + * Cache erst NACH dem Commit verwerfen (Audit N21): Innerhalb einer + * Transaktion sähe ein Leser zwischen Forget und Commit noch den alten + * DB-Wert und schriebe ihn dauerhaft zurück in den Cache. Ohne offene + * Transaktion läuft der Callback sofort. + */ public static function setValue(string $key, mixed $value): void { static::query()->updateOrCreate(['key' => $key], ['value' => $value]); - static::forgetCached($key); + + DB::afterCommit(static fn () => static::forgetCached($key)); } /** diff --git a/app/Models/ArchiveEntry.php b/app/Models/ArchiveEntry.php index 83de2efc..23a34996 100644 --- a/app/Models/ArchiveEntry.php +++ b/app/Models/ArchiveEntry.php @@ -28,6 +28,7 @@ class ArchiveEntry extends Model 'subject_type', 'subject_id', 'press_release_id', + 'legal_request_id', 'event', 'actor_type', 'actor_id', diff --git a/app/Models/ImageDeclarationCorrection.php b/app/Models/ImageDeclarationCorrection.php index d34f31a8..3eac6dee 100644 --- a/app/Models/ImageDeclarationCorrection.php +++ b/app/Models/ImageDeclarationCorrection.php @@ -38,10 +38,29 @@ class ImageDeclarationCorrection extends Model ]; } + /** + * Freischein für die Löschung nach Fristende des Beweisarchivs + * (Umsetzungsaudit 25.09.2026, 5.1 a). Nie von außen setzen. + */ + public bool $purgingAfterArchive = false; + protected static function booted(): void { static::updating(fn () => throw new \LogicException('image_declaration_corrections ist insert-only.')); - static::deleting(fn () => throw new \LogicException('image_declaration_corrections wird nicht gelöscht.')); + static::deleting(function (self $correction): void { + if (! $correction->purgingAfterArchive) { + throw new \LogicException('image_declaration_corrections wird nicht gelöscht; nur Kopien nach Fristende des Beweisarchivs (purgeAfterArchive) dürfen verschwinden.'); + } + }); + } + + /** + * Löschung nach Fristende des Beweisarchivs (5.1 a). + */ + public function purgeAfterArchive(): void + { + $this->purgingAfterArchive = true; + $this->delete(); } /** diff --git a/app/Models/ImageRightsConfirmation.php b/app/Models/ImageRightsConfirmation.php index a9b40e31..bf7a4907 100644 --- a/app/Models/ImageRightsConfirmation.php +++ b/app/Models/ImageRightsConfirmation.php @@ -30,6 +30,13 @@ class ImageRightsConfirmation extends Model */ public bool $purgingUnpublished = false; + /** + * Zweiter Freischein: Löschung der Anwendungskopie, nachdem der + * zugehörige Archiveintrag nach Fristablauf getombstont wurde + * (Umsetzungsaudit 25.09.2026, 5.1 a). Nie von außen setzen. + */ + public bool $purgingAfterArchive = false; + protected $fillable = [ 'subject_type', 'subject_id', @@ -88,6 +95,16 @@ class ImageRightsConfirmation extends Model $this->delete(); } + /** + * Löschung nach Fristende des Beweisarchivs (5.1 a): die Kopie im Archiv + * ist bereits weg, die Anwendungszeile darf nicht länger leben. + */ + public function purgeAfterArchive(): void + { + $this->purgingAfterArchive = true; + $this->delete(); + } + /** * @return MorphTo */ diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 99561d1d..81d07699 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -106,6 +106,7 @@ class PressRelease extends Model 'published_at', 'depublished_at', 'published_ever_at', + 'archive_pending_at', 'gone_at', 'no_export_before_gone', 'scheduled_at', @@ -137,6 +138,7 @@ class PressRelease extends Model 'published_at' => 'datetime', 'depublished_at' => 'datetime', 'published_ever_at' => 'datetime', + 'archive_pending_at' => 'datetime', 'gone_at' => 'datetime', 'no_export_before_gone' => 'boolean', 'sync_locked_at' => 'datetime', diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 75594d4b..a26de52a 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -241,10 +241,6 @@ class PressReleaseImage extends Model return $this->published_ever_at !== null; } - /** - * Vorläufig zurückgehalten (Korrekturweg E.6): öffentlich greift der - * Platzhalter, im Panel bleibt das Bild sichtbar. - */ /** * Ausgang `queue` des Vorab-Checks (Block K): Bild ist zurückgehalten, * bis ein Admin freigibt oder ablehnt. @@ -267,20 +263,42 @@ class PressReleaseImage extends Model return $this->belongsTo(User::class, 'precheck_reviewed_by'); } + /** + * Vorläufig zurückgehalten (Korrekturweg E.6, Vorab-Check K.4): + * öffentlich greift der Platzhalter, im Panel bleibt das Bild sichtbar. + */ public function isWithheld(): bool { return $this->withheld_at !== null; } /** - * Öffentlich ausspielbare Bilder: nicht zurückgehalten. + * Gemeinsamer Freigabezustand für die öffentliche Auslieferung (Audit + * 25.09. N01): nicht zurückgehalten und — für Bilder aus Formular v2 — + * mit Rechtebestätigung (ein Admin-Upload wartet bis zum Kundenklick, + * DU E10). Der Bestand (`rules_version` NULL) hat kein Protokoll und + * bleibt ausdrücklich sichtbar. Seitenabfragen, OG-Auswahl und + * Medienroute wenden dieselbe Regel an; der Scope ist gleichlautend. + */ + public function isPubliclyDeliverable(): bool + { + return $this->withheld_at === null + && ($this->rules_version === null || $this->rights_confirmed_at !== null); + } + + /** + * Öffentlich ausspielbare Bilder — gleichlautend mit isPubliclyDeliverable(). * * @param Builder $query * @return Builder */ public function scopePubliclyVisible(Builder $query): Builder { - return $query->whereNull('withheld_at'); + return $query + ->whereNull('withheld_at') + ->where(fn (Builder $inner) => $inner + ->whereNull('rules_version') + ->orWhereNotNull('rights_confirmed_at')); } public function url(bool $signed = false): ?string diff --git a/app/Observers/ImageRightsConfirmationObserver.php b/app/Observers/ImageRightsConfirmationObserver.php index 0d6b67f7..cd34e127 100644 --- a/app/Observers/ImageRightsConfirmationObserver.php +++ b/app/Observers/ImageRightsConfirmationObserver.php @@ -19,10 +19,10 @@ class ImageRightsConfirmationObserver public function deleting(ImageRightsConfirmation $confirmation): void { - if ($confirmation->purgingUnpublished) { + if ($confirmation->purgingUnpublished || $confirmation->purgingAfterArchive) { return; } - throw new LogicException('image_rights_confirmations wird nicht gelöscht; nur nie veröffentlichte Uploads dürfen über purgeForUnpublished() verschwinden.'); + throw new LogicException('image_rights_confirmations wird nicht gelöscht; nur nie veröffentlichte Uploads (purgeForUnpublished) und Kopien nach Fristende des Beweisarchivs (purgeAfterArchive) dürfen verschwinden.'); } } diff --git a/app/Policies/PressReleasePolicy.php b/app/Policies/PressReleasePolicy.php index a0991e7b..fec164d6 100644 --- a/app/Policies/PressReleasePolicy.php +++ b/app/Policies/PressReleasePolicy.php @@ -12,8 +12,14 @@ class PressReleasePolicy * Kurzschluss nur für AKTIVE Super-Admins (Audit 7.2): ein deaktiviertes * Konto fällt auf die regulären Prüfungen zurück, die es ablehnen. */ - public function before(User $user): ?bool + public function before(User $user, string $ability): ?bool { + // Die Kundenbestätigung eines Admin-Uploads ist an die Identität des + // Kontoinhabers gebunden (DU E1/E10, Audit N05) — kein Kurzschluss. + if ($ability === 'confirmImageRights') { + return null; + } + return ($user->is_super_admin && $user->is_active) ? true : null; } @@ -75,6 +81,22 @@ class PressReleasePolicy return $user->is_super_admin === true; } + /** + * „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4, + * DU E10): ausschließlich der Kontoinhaber der Meldung — er ist der + * Vertragspartner (DU E1) —, auch bei bereits veröffentlichter PM + * (Admin-Bildtausch). Nicht von `update` geerbt (Audit N05). + */ + public function confirmImageRights(User $user, PressRelease $pressRelease): bool + { + return $this->isAuthor($user, $pressRelease) + && in_array( + $pressRelease->status, + [PressReleaseStatus::Draft, PressReleaseStatus::Rejected, PressReleaseStatus::Review, PressReleaseStatus::Published], + true, + ); + } + public function publish(User $user, PressRelease $pressRelease): bool { return $user->canAccessAdmin() && $user->can('press-releases:publish'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 63f4a2f8..bfd8dfc4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -128,7 +128,9 @@ class AppServiceProvider extends ServiceProvider // Deaktivierung geht vor (Audit F09): ein gesperrter Super-Admin fällt // auf die regulären Gates zurück, die `is_active` prüfen – sonst wäre // die Sperre für bestehende Sessions wirkungslos. - Gate::before(fn (User $user, string $ability): ?bool => ($user->is_super_admin && $user->is_active) ? true : null); + // Ausnahme: die Kundenbestätigung eines Admin-Uploads ist an die + // Identität des Kontoinhabers gebunden (PressReleasePolicy, Audit N05). + Gate::before(fn (User $user, string $ability): ?bool => $ability !== 'confirmImageRights' && $user->is_super_admin && $user->is_active ? true : null); Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin()); Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer()); diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php index 43637000..6fed34ff 100644 --- a/app/Services/Admin/AdminSystemHealth.php +++ b/app/Services/Admin/AdminSystemHealth.php @@ -3,8 +3,11 @@ namespace App\Services\Admin; use App\Authorization\Permission as PermissionCatalog; +use App\Models\PressRelease; use App\Models\PressReleaseAttachment; use App\Models\PressReleaseImage; +use App\Scopes\PortalScope; +use App\Services\PressRelease\Images\PressReleaseImageRemover; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -209,6 +212,29 @@ class AdminSystemHealth : $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).', ]; + // Beweisarchiv: Vorgänge, deren Archiveintrag fehlgeschlagen ist + // (archive_pending_at, Umsetzungsaudit N11), und weich gelöschte + // v2-Bilder, deren lokale Beweisdateien noch nicht bereinigt sind (5.1 e). + $archivePending = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count(); + $checks[] = [ + 'key' => 'archive_pending', + 'label' => 'Beweisarchiv-Nachholer', + 'status' => $archivePending === 0 ? self::StatusOk : self::StatusWarn, + 'detail' => $archivePending === 0 + ? 'Alle Archiveinträge wurden beim Vorgang geschrieben.' + : $archivePending.' Meldung(en) mit ausstehendem Archiveintrag — `archive:retry-pending` läuft täglich; Log auf ARCHIVE_KEY/Archiv-Disk prüfen.', + ]; + + $evidenceLeftovers = PressReleaseImageRemover::countLocalEvidenceLeftovers(); + $checks[] = [ + 'key' => 'image_evidence_leftovers', + 'label' => 'Bildnachweise lokal', + 'status' => $evidenceLeftovers === 0 ? self::StatusOk : self::StatusWarn, + 'detail' => $evidenceLeftovers === 0 + ? 'Keine entfernten Bilder mit lokalen Beweisdateien.' + : $evidenceLeftovers.' entfernte(s) Bild(er) mit noch vorhandenen lokalen Beweisdateien (Original/Snapshot) — Überführung ausstehend oder Löschung fehlgeschlagen, siehe Log.', + ]; + // Offene Bild-Sichtungen aus dem KI-Vorab-Check (Block K). $pendingPrechecks = PressReleaseImage::query()->awaitingPrecheckReview()->count(); $checks[] = [ diff --git a/app/Services/Archive/EvidenceArchive.php b/app/Services/Archive/EvidenceArchive.php index 14fec571..6d685d04 100644 --- a/app/Services/Archive/EvidenceArchive.php +++ b/app/Services/Archive/EvidenceArchive.php @@ -6,7 +6,11 @@ use App\Enums\ArchiveEvent; use App\Enums\ArchiveReasonCategory; use App\Models\ArchiveAccessLog; use App\Models\ArchiveEntry; +use App\Models\ImageDeclarationCorrection; +use App\Models\ImageRightsConfirmation; +use App\Models\PressReleaseImage; use App\Models\User; +use App\Support\FileDeletion; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Database\Eloquent\Model; use Illuminate\Encryption\Encrypter; @@ -15,6 +19,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use RuntimeException; +use Throwable; use ZipArchive; /** @@ -26,7 +31,13 @@ use ZipArchive; * Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne * legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer * Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads - * anhand des täglich exportierten Tombstone-Stands. + * anhand des täglich exportierten Tombstone-Stands und der Tombstones in + * der Datenbank. + * + * Dateioperationen werden ausgewertet (Umsetzungsaudit 25.09.2026, N10): + * ein Tombstone entsteht erst nach bestätigter Löschung, Purge und + * Restore-Abgleich melden Fehlschläge, der Export wird atomar ersetzt. + * Hold und Purge koordinieren sich über dieselbe Zeilensperre (N02). */ class EvidenceArchive { @@ -36,6 +47,11 @@ class EvidenceArchive } /** + * Payload zuerst als .tmp, dann die Zeile in einer Transaktion, dann + * die Datei an ihren Platz — schlägt etwas fehl, bleibt weder Zeile + * noch .tmp zurück (N11). Eine Datei ohne Zeile (Commit nach dem + * Verschieben gescheitert) räumt sweepOrphans() auf. + * * @param array $manifest Fachdaten (werden als manifest.json abgelegt) * @param array $files Dateiname → Bytes * @param array $summary Kleine, nicht personenbezogene Kennzahlen für die Liste @@ -50,6 +66,7 @@ class EvidenceArchive array $manifest, array $files = [], array $summary = [], + ?int $legalRequestId = null, ): ?ArchiveEntry { if (! $this->isConfigured()) { Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [ @@ -65,6 +82,7 @@ class EvidenceArchive 'subject_type' => $subject->getMorphClass(), 'subject_id' => $subject->getKey(), 'press_release_id' => $pressReleaseId, + 'legal_request_id' => $legalRequestId, 'reason_category' => $reason->value, 'actor_type' => $actorType, 'actor_id' => $actorId, @@ -74,26 +92,42 @@ class EvidenceArchive $zip = $this->buildZip($manifest, $files); $hash = hash('sha256', $zip); $path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString()); + $temp = $path.'.tmp'; - if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) { + if (! $this->disk()->put($temp, $this->encrypter()->encrypt($zip, false))) { throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.'); } - return ArchiveEntry::query()->create([ - 'subject_type' => $subject->getMorphClass(), - 'subject_id' => $subject->getKey(), - 'press_release_id' => $pressReleaseId, - 'event' => $event, - 'actor_type' => $actorType, - 'actor_id' => $actorId, - 'reason_category' => $reason, - 'summary' => $summary ?: null, - 'payload_path' => $path, - 'payload_hash' => $hash, - 'payload_bytes' => strlen($zip), - 'retain_until' => ArchiveEntry::retentionEndFor(now()), - 'created_at' => now(), - ]); + try { + return DB::transaction(function () use ($subject, $pressReleaseId, $legalRequestId, $event, $actorType, $actorId, $reason, $summary, $path, $temp, $hash, $zip): ArchiveEntry { + $entry = ArchiveEntry::query()->create([ + 'subject_type' => $subject->getMorphClass(), + 'subject_id' => $subject->getKey(), + 'press_release_id' => $pressReleaseId, + 'legal_request_id' => $legalRequestId, + 'event' => $event, + 'actor_type' => $actorType, + 'actor_id' => $actorId, + 'reason_category' => $reason, + 'summary' => $summary ?: null, + 'payload_path' => $path, + 'payload_hash' => $hash, + 'payload_bytes' => strlen($zip), + 'retain_until' => ArchiveEntry::retentionEndFor(now()), + 'created_at' => now(), + ]); + + if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) { + throw new RuntimeException('Beweisarchiv: Payload konnte nicht abgelegt werden.'); + } + + return $entry; + }); + } catch (Throwable $exception) { + $this->disk()->delete($temp); + + throw $exception; + } } /** @@ -148,6 +182,7 @@ class EvidenceArchive 'event' => $entry->event->value, 'subject' => $entry->subject_type.'#'.$entry->subject_id, 'press_release_id' => $entry->press_release_id, + 'legal_request_id' => $entry->legal_request_id, 'reason_category' => $entry->reason_category->value, 'created_at' => $entry->created_at?->toIso8601String(), 'retain_until' => $entry->retain_until->toDateString(), @@ -161,14 +196,30 @@ class EvidenceArchive ], 'metadata.json'); } + /** + * Hold setzen unter Zeilensperre: ein gleichzeitig laufender Purge sieht + * den Hold bei seiner erneuten Prüfung, oder der Hold wartet, bis der + * Purge fertig ist — dann ist der Eintrag Tombstone und der Hold wird + * abgelehnt (N02). + */ public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void { - $entry->forceFill([ - 'legal_hold' => true, - 'legal_hold_note' => Str::limit($note, 990, ''), - 'legal_hold_set_by' => $admin->getKey(), - 'legal_hold_set_at' => now(), - ])->save(); + DB::transaction(function () use ($entry, $admin, $note): void { + $locked = $this->lockedEntry($entry); + + if ($locked->isTombstoned()) { + throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone), ein Legal Hold ist nicht mehr möglich.'); + } + + $locked->forceFill([ + 'legal_hold' => true, + 'legal_hold_note' => Str::limit($note, 990, ''), + 'legal_hold_set_by' => $admin->getKey(), + 'legal_hold_set_at' => now(), + ])->save(); + }); + + $entry->refresh(); } /** @@ -176,39 +227,64 @@ class EvidenceArchive */ public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void { - $entry->forceFill([ - 'legal_hold' => false, - 'legal_hold_note' => Str::limit($note, 990, ''), - 'legal_hold_set_by' => $admin->getKey(), - 'legal_hold_set_at' => now(), - 'retain_until' => ArchiveEntry::retentionEndFor(now()), - ])->save(); + DB::transaction(function () use ($entry, $admin, $note): void { + $locked = $this->lockedEntry($entry); + + if ($locked->isTombstoned()) { + throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone).'); + } + + $locked->forceFill([ + 'legal_hold' => false, + 'legal_hold_note' => Str::limit($note, 990, ''), + 'legal_hold_set_by' => $admin->getKey(), + 'legal_hold_set_at' => now(), + 'retain_until' => ArchiveEntry::retentionEndFor(now()), + ])->save(); + }); + + $entry->refresh(); } /** * Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt. + * Jeder Eintrag wird unter Zeilensperre erneut geprüft; Fehlschläge + * werden gezählt und beim nächsten Lauf wiederholt. + * + * @return array{due: int, purged: int, failed: int} */ - public function purgeDue(bool $dryRun = false): int + public function purgeDue(bool $dryRun = false): array { - $count = 0; + $result = ['due' => 0, 'purged' => 0, 'failed' => 0]; - ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void { + ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$result, $dryRun): void { foreach ($entries as $entry) { - $count++; + $result['due']++; if ($dryRun) { continue; } - $this->tombstone($entry); + try { + if ($this->tombstone($entry)) { + $result['purged']++; + } + } catch (Throwable $exception) { + $result['failed']++; + Log::error('Beweisarchiv: Löschung nach Fristablauf fehlgeschlagen, wird wiederholt.', [ + 'entry_id' => $entry->getKey(), + 'error' => $exception->getMessage(), + ]); + } } }); - return $count; + return $result; } /** - * Tombstone-Stand exportieren, damit er einen Restore überlebt. + * Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird + * atomar ersetzt; ein Fehlschlag wirft, statt eine Anzahl zu melden. */ public function exportTombstones(): int { @@ -222,10 +298,22 @@ class EvidenceArchive ->values() ->all(); - $this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([ + $path = (string) config('archive.tombstone_export_path'); + $temp = $path.'.tmp'; + $json = (string) json_encode([ 'exported_at' => now()->toIso8601String(), 'entries' => $rows, - ], JSON_PRETTY_PRINT)); + ], JSON_PRETTY_PRINT); + + if (! $this->disk()->put($temp, $json)) { + throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.'); + } + + if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) { + $this->disk()->delete($temp); + + throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.'); + } return count($rows); } @@ -233,26 +321,25 @@ class EvidenceArchive /** * Nach dem Einspielen einer Sicherung: alles, was im exportierten * Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile). + * Zusätzlich jede Tombstone-Zeile der Datenbank, deren Payload wieder + * auf der Disk liegt — deckt den Fall „Disk alt, Datenbank aktuell" + * ab (5.1 c). * - * @return array{re_tombstoned: int, files_removed: int} + * @return array{re_tombstoned: int, files_removed: int, failed: int} */ public function reconcileAfterRestore(): array { - $result = ['re_tombstoned' => 0, 'files_removed' => 0]; + $result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0]; $raw = $this->disk()->get((string) config('archive.tombstone_export_path')); - - if (! is_string($raw) || $raw === '') { - return $result; - } - - $rows = (array) (json_decode($raw, true)['entries'] ?? []); + $rows = is_string($raw) && $raw !== '' ? (array) (json_decode($raw, true)['entries'] ?? []) : []; + $handled = []; foreach ($rows as $row) { $path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null; - if ($path !== null && $this->disk()->exists($path)) { - $this->disk()->delete($path); - $result['files_removed']++; + if ($path !== null && ! isset($handled[$path]) && $this->disk()->exists($path)) { + $handled[$path] = true; + FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++; } $entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0)); @@ -263,22 +350,127 @@ class EvidenceArchive } } + ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')->orderBy('id') + ->chunkById(500, function ($entries) use (&$result, &$handled): void { + foreach ($entries as $entry) { + $path = (string) $entry->payload_path; + + if (isset($handled[$path]) || ! $this->disk()->exists($path)) { + continue; + } + + $handled[$path] = true; + FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++; + } + }); + + if ($result['failed'] > 0) { + Log::error('Beweisarchiv: Restore-Abgleich konnte Payloads nicht entfernen.', ['failed' => $result['failed']]); + } + return $result; } - private function tombstone(ArchiveEntry $entry): void + /** + * Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback, + * liegengebliebene .tmp) nach Karenzzeit entfernen (N11). + * + * @return array{scanned: int, orphans: int, removed: int} + */ + public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array { - DB::transaction(function () use ($entry): void { - if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) { - $this->disk()->delete($entry->payload_path); + $result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0]; + $threshold = now()->subHours($olderThanHours)->getTimestamp(); + + foreach (array_chunk($this->disk()->allFiles('entries'), 500) as $chunk) { + $result['scanned'] += count($chunk); + $known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all(); + + foreach (array_diff($chunk, $known) as $file) { + if ($this->disk()->lastModified($file) > $threshold) { + continue; + } + + $result['orphans']++; + + if (! $dryRun && FileDeletion::delete($this->disk(), $file)) { + $result['removed']++; + } } + } + + return $result; + } + + /** + * Unter Zeilensperre erneut prüfen, dann Datei bestätigt löschen, dann + * Tombstone. Liefert false, wenn der Eintrag inzwischen nicht mehr + * fällig ist (Hold gesetzt, Frist verlängert, schon Tombstone). + */ + private function tombstone(ArchiveEntry $entry): bool + { + $done = DB::transaction(function () use ($entry): bool { + $locked = $this->lockedEntry($entry); + + if ($locked->isTombstoned() || $locked->legal_hold || $locked->retain_until->gte(today())) { + return false; + } + + if ($locked->payload_path !== null) { + FileDeletion::deleteOrThrow($this->disk(), $locked->payload_path, 'Beweisarchiv'); + } + + $this->purgeApplicationCopies($locked); // Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei // wiederfindet; der Inhalt ist weg. - $entry->forceFill(['deleted_at' => now()])->save(); + $locked->forceFill(['deleted_at' => now()])->save(); + + return true; }); - Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]); + if ($done) { + Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]); + } + + return $done; + } + + /** + * Mit dem Archiv-Tombstone eines entfernten Bildes verschwinden auch die + * Kopien in der Anwendung: Bestätigungen, Korrekturen und die weich + * gelöschte Bildzeile (5.1 a). Das Archiv hielt die vollständige Kopie. + */ + private function purgeApplicationCopies(ArchiveEntry $entry): void + { + if (! in_array($entry->event, [ArchiveEvent::ImageRemoved, ArchiveEvent::ImageReplaced], true) + || $entry->subject_type !== (new PressReleaseImage)->getMorphClass()) { + return; + } + + $image = PressReleaseImage::withTrashed()->find($entry->subject_id); + + if ($image !== null && ! $image->trashed()) { + return; + } + + ImageRightsConfirmation::query() + ->where('subject_type', $entry->subject_type) + ->where('subject_id', $entry->subject_id) + ->get() + ->each(fn (ImageRightsConfirmation $confirmation) => $confirmation->purgeAfterArchive()); + + ImageDeclarationCorrection::query() + ->where('press_release_image_id', $entry->subject_id) + ->get() + ->each(fn (ImageDeclarationCorrection $correction) => $correction->purgeAfterArchive()); + + $image?->forceDelete(); + } + + private function lockedEntry(ArchiveEntry $entry): ArchiveEntry + { + return ArchiveEntry::query()->whereKey($entry->getKey())->lockForUpdate()->firstOrFail(); } private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void diff --git a/app/Services/Archive/PendingArchiveRetry.php b/app/Services/Archive/PendingArchiveRetry.php new file mode 100644 index 00000000..1a5c42c8 --- /dev/null +++ b/app/Services/Archive/PendingArchiveRetry.php @@ -0,0 +1,176 @@ + 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 + */ + 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()); + } +} diff --git a/app/Services/Archive/PressReleaseArchiver.php b/app/Services/Archive/PressReleaseArchiver.php index 33189916..5f34171d 100644 --- a/app/Services/Archive/PressReleaseArchiver.php +++ b/app/Services/Archive/PressReleaseArchiver.php @@ -18,7 +18,8 @@ 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. + * 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 { @@ -28,7 +29,7 @@ class PressReleaseArchiver * 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): ?ArchiveEntry + 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')); @@ -61,15 +62,18 @@ class PressReleaseArchiver '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', + '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)], + ['image_id' => $image->getKey(), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()], + $legalRequest?->getKey(), ); } @@ -130,12 +134,13 @@ class PressReleaseArchiver /** * DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf - * den Antrag. + * 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): ?ArchiveEntry + 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, @@ -148,16 +153,13 @@ class PressReleaseArchiver 'press_release' => $this->pressReleaseHeader($pressRelease), 'before' => $before, 'after' => $after, - 'legal_request' => $legalRequest ? [ - 'id' => $legalRequest->getKey(), - 'reference' => $legalRequest->reference(), - 'type' => $legalRequest->type->value, - 'requester_email' => $legalRequest->requester_email, - ] : null, + 'legal_request' => $this->legalRequestBlock($legalRequest), + 'redaction_snapshot_id' => $redactionSnapshotId, 'note' => $note, ], [], - ['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey()], + ['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey(), 'redaction_snapshot_id' => $redactionSnapshotId], + $legalRequest?->getKey(), ); } @@ -182,6 +184,23 @@ class PressReleaseArchiver ); } + /** + * @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 */ diff --git a/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php b/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php index c6358fef..f1407e96 100644 --- a/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php +++ b/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php @@ -88,6 +88,8 @@ class OpenAiImagePrecheckDriver implements ImagePrecheckDriver throw new RuntimeException('OpenAI-Bildsichtung lieferte ein unvollständiges Schema.'); } + $this->assertSchema($decoded); + return ImagePrecheckResult::ok($decoded, 'openai', $model, null, [ 'prompt_version' => (string) config('images.precheck.prompt_version'), 'sent_size' => [$prepared['width'], $prepared['height']], @@ -95,6 +97,41 @@ class OpenAiImagePrecheckDriver implements ImagePrecheckDriver ]); } + /** + * Strikte Prüfung der Wertstruktur (Audit N20): Ein Schlüssel allein + * reicht nicht — alle Kategorien auf `null`, leere Objekte, String- + * Booleans („false" ist in PHP wahr) oder Konfidenzen außerhalb 0…1 + * ergäben sonst ein scheinbar belastbares `ok` ohne Treffer. Formal + * unbrauchbare Antworten gehen als Ausnahme in den bestehenden + * `error`-Pfad (Block K.1: durchlassen mit Vermerk, nie blockieren). + * + * @param array $decoded + */ + private function assertSchema(array $decoded): void + { + foreach (ImagePrecheckCategory::cases() as $category) { + $entry = $decoded[$category->value]; + + if (! is_array($entry)) { + throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value} ist kein Objekt."); + } + + if (! array_key_exists('hit', $entry) || ! is_bool($entry['hit'])) { + throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.hit ist kein Boolean."); + } + + $confidence = $entry['confidence'] ?? null; + + if (! (is_int($confidence) || is_float($confidence)) || ! is_finite($confidence) || $confidence < 0 || $confidence > 1) { + throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.confidence liegt nicht in 0…1."); + } + + if (array_key_exists('note', $entry) && $entry['note'] !== null && ! is_string($entry['note'])) { + throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.note ist kein Text."); + } + } + } + private function systemPrompt(): string { $keys = implode(', ', array_map(fn (ImagePrecheckCategory $c): string => $c->value, ImagePrecheckCategory::cases())); diff --git a/app/Services/Image/Precheck/ImagePrecheckResult.php b/app/Services/Image/Precheck/ImagePrecheckResult.php index ce917e87..1f3aa914 100644 --- a/app/Services/Image/Precheck/ImagePrecheckResult.php +++ b/app/Services/Image/Precheck/ImagePrecheckResult.php @@ -124,7 +124,9 @@ final readonly class ImagePrecheckResult } $confidence = isset($entry['confidence']) && is_numeric($entry['confidence']) ? (float) $entry['confidence'] : 0.0; - $hit = (bool) ($entry['hit'] ?? false); + // Nur ein echtes `true` zählt (Audit N20): der String "false" wäre + // per Cast wahr. Die strikte Schemaprüfung sitzt im Treiber. + $hit = ($entry['hit'] ?? false) === true; $note = isset($entry['note']) && is_string($entry['note']) && trim($entry['note']) !== '' ? mb_substr(trim($entry['note']), 0, 200) : null; $normalized[$category->value] = [ diff --git a/app/Services/PressRelease/AnonymizationService.php b/app/Services/PressRelease/AnonymizationService.php index b8e23ffa..08dd5e78 100644 --- a/app/Services/PressRelease/AnonymizationService.php +++ b/app/Services/PressRelease/AnonymizationService.php @@ -3,16 +3,23 @@ namespace App\Services\PressRelease; use App\Enums\ActivityType; +use App\Enums\ArchiveEvent; use App\Enums\ArchiveReasonCategory; +use App\Models\ArchiveEntry; use App\Models\LegalRequest; use App\Models\PressRelease; +use App\Models\PressReleaseImage; use App\Models\PressReleaseRedactionSnapshot; use App\Services\ActivityLog; use App\Services\Archive\PressReleaseArchiver; +use App\Services\Image\ImageService; +use App\Services\PressRelease\Images\PressReleaseImageRemover; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use Throwable; /** * Schwärzung personenbezogener Daten in einer Pressemitteilung @@ -23,7 +30,9 @@ use Illuminate\Support\Facades\Storage; * und sichert die Vorher-Werte verschlüsselt (Reversibilität, nie Klartext * in Logs). Bewusst OHNE KI-Gate und OHNE Credit-Bezug — der DSGVO-Pfad * ist Compliance-Pflicht (Art. 12 Abs. 5) und darf an keinem Freigabelauf - * oder Bezahlschritt hängen. + * oder Bezahlschritt hängen. Deshalb blockiert auch das Beweisarchiv + * nicht: Ein fehlgeschlagener Eintrag lässt `archive_pending_at` stehen + * und wird nachgeholt (N11). */ class AnonymizationService { @@ -32,7 +41,12 @@ class AnonymizationService public const DEFAULT_REPLACEMENT = '[entfernt]'; - public function __construct(private readonly ActivityLog $activityLog) {} + public function __construct( + private readonly ActivityLog $activityLog, + private readonly PressReleaseImageRemover $imageRemover, + private readonly PressReleaseArchiver $archiver, + private readonly ImageService $imageService, + ) {} /** * Pflicht-Vorschau: Was würde ersetzt? Verändert die PM nicht. @@ -65,7 +79,8 @@ class AnonymizationService /** * Führt die Schwärzung aus: Snapshot → Text-Replace → Kontakte lösen → - * Bilder/Anhänge entfernen → Re-Sync-Sperre → Cache-Invalidierung → + * Anhänge entfernen → Re-Sync-Sperre → Commit → Bilder über den + * Remover (Beweisarchiv, N12) → Archiveintrag → Cache-Invalidierung → * Verlaufs-Eintrag ohne Klartext-Altwerte. * * @param list $terms @@ -82,10 +97,11 @@ class AnonymizationService ?LegalRequest $legalRequest = null, ): PressReleaseRedactionSnapshot { $totalHits = 0; + $images = collect(); $snapshot = DB::transaction(function () use ( $pressRelease, $terms, $replacement, $detachContacts, - $removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, + $removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, &$images, ): PressReleaseRedactionSnapshot { $contactIds = $detachContacts ? $pressRelease->contacts()->pluck('contacts.id')->all() @@ -103,8 +119,6 @@ class AnonymizationService 'replacement' => $replacement, ], $legalRequest); - $changedFields = []; - foreach (self::REDACTABLE_FIELDS as $field) { $value = (string) ($pressRelease->getAttribute($field) ?? ''); @@ -116,30 +130,18 @@ class AnonymizationService if ($hits > 0) { $pressRelease->setAttribute($field, $after); - $changedFields[] = $field; $totalHits += $hits; } } - if ($changedFields !== []) { - $pressRelease->save(); - } + // Marker „Archivierung steht aus" in derselben Transaktion (N11). + $pressRelease->setAttribute('archive_pending_at', now()); + $pressRelease->save(); if ($contactIds !== []) { $pressRelease->contacts()->detach($contactIds); } - // Klarname in Pressebild/PDF: Zeile soft-löschen (raus aus der - // Auslieferung); auf der public-Disk zusätzlich die Dateien - // entfernen, weil sie sonst per Direkt-URL erreichbar blieben. - foreach ($images as $image) { - $this->deletePublicFiles($image->disk, array_filter([ - $image->path, - ...array_values((array) ($image->variants ?? [])), - ])); - $image->delete(); - } - foreach ($attachments as $attachment) { $this->deletePublicFiles($attachment->disk, array_filter([$attachment->path])); $attachment->delete(); @@ -152,20 +154,24 @@ class AnonymizationService return $snapshot; }); + // Klarname in Pressebild/PDF: über den gemeinsamen Lebenszyklus + // (Beweisarchiv bei veröffentlichten Bildern, Sofortlöschung sonst). + $archiveFailed = false; + + foreach ($images as $image) { + $archiveFailed = ! $this->removeImage($image, $legalRequest) || $archiveFailed; + } + $this->forgetSitemapCaches(); // Beweisarchiv (Block F.4): Diff alt/neu mit Antrag — dort dürfen die // Altwerte liegen (verschlüsselt, zugriffsprotokolliert), im - // Aktivitätsverlauf nicht. - app(PressReleaseArchiver::class)->gdprChange( - $pressRelease, - (array) ($snapshot->encrypted_before['fields'] ?? []), - $pressRelease->fresh()?->only(self::REDACTABLE_FIELDS) ?? [], - $legalRequest, - Auth::user(), - ArchiveReasonCategory::Gdpr, - 'Anonymisierung: '.count($terms).' Begriff(e), '.count($removeImageIds).' Bild(er), '.count($contactIdsForLog = $snapshot->encrypted_before['contact_ids'] ?? []).' Kontakt(e)', - ); + // Aktivitätsverlauf nicht. Vorher-Stand aus dem Snapshot (N11). + $entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $terms, $removeImageIds); + + if ($entry !== null && ! $archiveFailed) { + $pressRelease->forceFill(['archive_pending_at' => null])->save(); + } // Bewusst ohne Alt-Werte und ohne Schwärzbegriffe — sonst leben die // personenbezogenen Daten im Verlauf weiter. @@ -181,12 +187,67 @@ class AnonymizationService ), user: Auth::user(), subject: $pressRelease, - meta: ['legal_request_id' => $legalRequest?->getKey(), 'snapshot_id' => $snapshot->getKey()], + meta: ['legal_request_id' => $legalRequest?->getKey(), 'snapshot_id' => $snapshot->getKey(), 'archive_entry_id' => $entry?->getKey()], ); return $snapshot; } + /** + * Bild über den Remover entfernen (N12). Scheitert das Archiv, muss der + * Klarname trotzdem sofort aus der Auslieferung: Derivate weg, Soft + * Delete; der Nachholer archiviert das weich gelöschte Bild später. + */ + private function removeImage(PressReleaseImage $image, ?LegalRequest $legalRequest): bool + { + try { + $this->imageRemover->remove($image, Auth::user(), ArchiveEvent::ImageRemoved, ArchiveReasonCategory::Gdpr, $legalRequest); + + return true; + } catch (Throwable $exception) { + Log::error('Beweisarchiv: Bild aus Anonymisierung konnte nicht überführt werden, wird nachgeholt.', [ + 'press_release_image_id' => $image->getKey(), + 'error' => $exception->getMessage(), + ]); + + $this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); + + if (! $image->trashed()) { + $image->delete(); + } + + return false; + } + } + + /** + * @param list $terms + * @param list $removeImageIds + */ + private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, ?LegalRequest $legalRequest, array $terms, array $removeImageIds): ?ArchiveEntry + { + try { + return $this->archiver->gdprChange( + $pressRelease, + (array) ($snapshot->encrypted_before['fields'] ?? []), + $pressRelease->fresh()?->only(self::REDACTABLE_FIELDS) ?? [], + $legalRequest, + Auth::user(), + ArchiveReasonCategory::Gdpr, + 'Anonymisierung: '.count($terms).' Begriff(e), '.count($removeImageIds).' Bild(er), '.count($snapshot->encrypted_before['contact_ids'] ?? []).' Kontakt(e)', + $snapshot->getKey(), + ); + } catch (Throwable $exception) { + Log::error('Beweisarchiv: Anonymisierung konnte nicht archiviert werden, wird nachgeholt.', [ + 'press_release_id' => $pressRelease->getKey(), + 'snapshot_id' => $snapshot->getKey(), + 'error' => $exception->getMessage(), + ]); + + return null; + } + } + /** * Ersetzung über Wortgrenzen: Lookarounds auf Unicode-Buchstaben/Ziffern * statt \b, damit Umlaute korrekt begrenzen und weder Teiltreffer diff --git a/app/Services/PressRelease/CanonicalAssigner.php b/app/Services/PressRelease/CanonicalAssigner.php index dba37830..519a8a20 100644 --- a/app/Services/PressRelease/CanonicalAssigner.php +++ b/app/Services/PressRelease/CanonicalAssigner.php @@ -64,7 +64,9 @@ class CanonicalAssigner } if ($pressRelease->canonical_press_release_id !== null) { - $canonical = PressRelease::query()->withoutGlobalScopes()->find($pressRelease->canonical_press_release_id); + // Nur der PortalScope entfällt — eine soft-gelöschte Erstquelle + // liefert 404 und darf kein Canonical-Ziel sein. + $canonical = PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressRelease->canonical_press_release_id); if ($canonical !== null && $canonical->isVisibleInArchive()) { return $this->publicUrl($canonical, $canonical->portal === Portal::Both ? ($this->preferredPortal($canonical) ?? $viewedOn) : $canonical->portal); @@ -131,6 +133,31 @@ class CanonicalAssigner } } + /** + * Gegenstücke einer Erstquelle neu bewerten, nachdem diese aus der + * öffentlichen Ansicht verschwunden ist (Depublikation, Gone, Löschung, + * Statuswechsel). Jede Kopie verliert ihren Verweis und wird — falls ein + * anderes veröffentlichtes Gegenstück existiert — neu zugeordnet; sonst + * ist sie fortan selbst Erstquelle und gehört wieder in die Sitemap. + */ + public function reassignCopiesOf(PressRelease $former): void + { + if (! $this->enabled()) { + return; + } + + $copies = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->where('canonical_press_release_id', $former->getKey()) + ->get(); + + foreach ($copies as $copy) { + $this->write($copy->getKey(), null); + $copy->canonical_press_release_id = null; + $this->assignFor($copy); + } + } + /** * Alle Paare im Bestand zuordnen. * diff --git a/app/Services/PressRelease/CorrectionService.php b/app/Services/PressRelease/CorrectionService.php index 4b8749c6..f3c8e697 100644 --- a/app/Services/PressRelease/CorrectionService.php +++ b/app/Services/PressRelease/CorrectionService.php @@ -7,6 +7,7 @@ use App\Enums\ArchiveReasonCategory; use App\Enums\LegalRequestStatus; use App\Enums\LegalRequestType; use App\Enums\LegalRequestVerificationMethod; +use App\Models\ArchiveEntry; use App\Models\LegalRequest; use App\Models\PressRelease; use App\Models\PressReleaseRedactionSnapshot; @@ -18,6 +19,7 @@ use App\Services\PressRelease\Classification\ClassificationResult; use App\Support\WebCaches; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Throwable; /** * Kleine Korrektur (Meldeverfahren v2, Spur A — Phase 4): Selbstbedienungs- @@ -60,7 +62,7 @@ class CorrectionService try { return $this->classification->driver()->classify($proposed); - } catch (\Throwable $e) { + } catch (Throwable $e) { Log::channel('ki')->warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [ 'press_release_id' => $pressRelease->id, 'error' => $e->getMessage(), @@ -150,9 +152,7 @@ class CorrectionService $this->assertNotRateLimited($pressRelease); - $before = $pressRelease->only(self::CORRECTABLE_FIELDS); - - $legalRequest = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): LegalRequest { + [$legalRequest, $snapshot] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): array { $legalRequest = LegalRequest::query()->create([ 'press_release_id' => $pressRelease->id, 'portal' => $pressRelease->portal?->value, @@ -170,27 +170,20 @@ class CorrectionService 'executed_at' => now(), ]); - PressReleaseRedactionSnapshot::capture($pressRelease, [ + $snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [ 'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS), ], $legalRequest); $this->writeChangedFields($pressRelease, $classification['fields'], $newValues); + $pressRelease->forceFill(['archive_pending_at' => now()])->save(); $pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference()); - return $legalRequest; + return [$legalRequest, $snapshot]; }); WebCaches::forgetSitemaps(); - app(PressReleaseArchiver::class)->gdprChange( - $pressRelease, - $before, - $pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [], - $legalRequest, - $actor, - ArchiveReasonCategory::Correction, - 'Kleine Korrektur (Selbstbedienung)', - ); + $entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)'); $changedFields = collect($classification['fields']) ->filter(fn (array $meta): bool => $meta['changed']) @@ -207,7 +200,7 @@ class CorrectionService ), user: $actor, subject: $pressRelease, - meta: ['legal_request_id' => $legalRequest->id], + meta: ['legal_request_id' => $legalRequest->id, 'archive_entry_id' => $entry?->getKey()], ); // Re-Check ohne Status-Wirkung — konsistent mit dem bestehenden @@ -245,9 +238,7 @@ class CorrectionService return false; } - $before = $pressRelease->only(self::CORRECTABLE_FIELDS); - - DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): void { + $snapshot = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): PressReleaseRedactionSnapshot { $legalRequest->forceFill([ 'status' => LegalRequestStatus::Resolved->value, 'payload' => array_merge($legalRequest->payload ?? [], ['fields' => $classification['fields']]), @@ -255,25 +246,20 @@ class CorrectionService 'executed_at' => now(), ])->save(); - PressReleaseRedactionSnapshot::capture($pressRelease, [ + $snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [ 'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS), ], $legalRequest); $this->writeChangedFields($pressRelease, $classification['fields'], $newValues); + $pressRelease->forceFill(['archive_pending_at' => now()])->save(); $pressRelease->lockSync('Kostenpflichtige Änderung '.$legalRequest->reference()); + + return $snapshot; }); WebCaches::forgetSitemaps(); - app(PressReleaseArchiver::class)->gdprChange( - $pressRelease, - $before, - $pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [], - $legalRequest, - $actor, - ArchiveReasonCategory::Correction, - 'Kostenpflichtige Änderung', - ); + $entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung'); $changedFields = collect($classification['fields']) ->filter(fn (array $meta): bool => $meta['changed']) @@ -290,7 +276,7 @@ class CorrectionService ), user: $actor, subject: $pressRelease, - meta: ['legal_request_id' => $legalRequest->id], + meta: ['legal_request_id' => $legalRequest->id, 'archive_entry_id' => $entry?->getKey()], ); $this->pressReleaseService->reprocessAfterContentChange($pressRelease); @@ -304,6 +290,41 @@ class CorrectionService * @param array $classifiedFields * @param array $newValues */ + /** + * Beweisarchiv (F.4): Vorher-Stand aus dem Snapshot, Nachher-Stand aus + * der Meldung. Scheitert die Ablage, bleibt `archive_pending_at` + * stehen und `archive:retry-pending` holt den Eintrag nach (N11). + */ + private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ?ArchiveEntry + { + try { + $entry = app(PressReleaseArchiver::class)->gdprChange( + $pressRelease, + (array) ($snapshot->encrypted_before['fields'] ?? []), + $pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [], + $legalRequest, + $actor, + ArchiveReasonCategory::Correction, + $note, + $snapshot->getKey(), + ); + } catch (Throwable $exception) { + Log::error('Beweisarchiv: Korrektur konnte nicht archiviert werden, wird nachgeholt.', [ + 'press_release_id' => $pressRelease->getKey(), + 'legal_request_id' => $legalRequest->getKey(), + 'error' => $exception->getMessage(), + ]); + + return null; + } + + if ($entry !== null) { + $pressRelease->forceFill(['archive_pending_at' => null])->save(); + } + + return $entry; + } + private function writeChangedFields(PressRelease $pressRelease, array $classifiedFields, array $newValues): void { foreach ($classifiedFields as $field => $meta) { diff --git a/app/Services/PressRelease/FeedFetchReport.php b/app/Services/PressRelease/FeedFetchReport.php index e1935a54..6f6be9ff 100644 --- a/app/Services/PressRelease/FeedFetchReport.php +++ b/app/Services/PressRelease/FeedFetchReport.php @@ -31,11 +31,13 @@ class FeedFetchReport // letzter Abruf); die Verdichtung je Reader passiert in PHP — das // bleibt auf MySQL und SQLite gleich. Abonnenten: je Feed das Maximum, // dann summiert — ein Dienst holt einen Feed viele Male, die Zahl darf - // nicht mitwachsen. + // nicht mitwachsen. Ein Feed ist Art + Schlüssel + Portal + Sprache, + // wie in topFeeds() und fetchesFor(): der Portalfeed von presseecho + // und der von businessportal24 sind zwei Feeds mit zwei Abonnentenzahlen. $rows = FeedFetch::query() ->where('created_at', '>=', now()->subDays($days)) - ->selectRaw('reader, feed, feed_key, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at') - ->groupBy('reader', 'feed', 'feed_key') + ->selectRaw('reader, feed, feed_key, portal, language, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at') + ->groupBy('reader', 'feed', 'feed_key', 'portal', 'language') ->toBase() ->get() ->groupBy(fn (object $row): string => (string) $row->reader); diff --git a/app/Services/PressRelease/Images/ImageCorrectionService.php b/app/Services/PressRelease/Images/ImageCorrectionService.php index a99fb74b..a3ab5d8d 100644 --- a/app/Services/PressRelease/Images/ImageCorrectionService.php +++ b/app/Services/PressRelease/Images/ImageCorrectionService.php @@ -104,13 +104,16 @@ class ImageCorrectionService $this->refreshOgVariant($image->fresh()); } + // Nie veröffentlicht: kein Archiveintrag (DU 4.1, Umsetzungsaudit + // 5.1 b) — die Korrekturzeile dokumentiert den Vorgang; wird das Bild + // später veröffentlicht und entfernt, wandern alle Korrekturen mit + // dem image_removed-Eintrag ins Archiv. if ($image->wasEverPublished()) { $this->snapshots->capture($image->fresh()); WebCaches::forgetPublicPages(); + $this->archiver->labelCorrected($image->fresh(), $correction, $admin); } - $this->archiver->labelCorrected($image->fresh(), $correction, $admin); - return $correction; } } diff --git a/app/Services/PressRelease/Images/PressReleaseImageRemover.php b/app/Services/PressRelease/Images/PressReleaseImageRemover.php index b6d05406..37de782c 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageRemover.php +++ b/app/Services/PressRelease/Images/PressReleaseImageRemover.php @@ -4,11 +4,15 @@ namespace App\Services\PressRelease\Images; use App\Enums\ArchiveEvent; use App\Enums\ArchiveReasonCategory; +use App\Models\ArchiveEntry; +use App\Models\LegalRequest; use App\Models\PressReleaseImage; use App\Models\User; use App\Services\Archive\PressReleaseArchiver; use App\Services\Image\ImageService; +use App\Support\FileDeletion; use App\Support\WebCaches; +use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -16,10 +20,11 @@ use Illuminate\Support\Facades\Storage; * Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4): * * - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort - * und vollständig weg, die Zeile wird hart gelöscht; - * - veröffentlicht: Derivate weg, Zeile weich gelöscht; Original, Snapshot - * und Bestätigung bleiben für das Beweisarchiv (Block F übernimmt die - * Überführung und die Frist). + * und vollständig weg, die Zeile wird hart gelöscht — schlägt eine + * Dateilöschung fehl, bleibt die Zeile für den nächsten Versuch (5.1 e); + * - veröffentlicht: Original, Snapshot und Bestätigung ins Beweisarchiv + * (Block F), danach die lokalen Beweisdateien weg und die Pfade an der + * Zeile geleert; Derivate weg, Zeile weich gelöscht. * * Bestand ohne `rules_version` hat weder Original noch Protokoll und wird * wie bisher behandelt (Derivate weg, Soft Delete). @@ -31,21 +36,16 @@ class PressReleaseImageRemover private readonly PressReleaseArchiver $archiver, ) {} - public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest): void + public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null): void { - $this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); - if ($image->wasEverPublished() || $image->isLegacyRules()) { - // Veröffentlicht: Original, Snapshot und Bestätigungen ins - // Beweisarchiv (Block F), danach die lokalen Beweisdateien weg. + // Veröffentlicht: erst der Archiveintrag, dann verschwinden die + // Derivate — ein Archivfehler lässt das Bild unangetastet. if ($image->wasEverPublished() && ! $image->isLegacyRules()) { - $entry = $this->archiver->imageRemoved($image, $actor, $event, $reason); - - if ($entry !== null) { - $this->deleteEvidenceFiles($image); - } + $this->archiveTrashed($image, $actor, $event, $reason, $legalRequest); } + $this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); $image->delete(); WebCaches::forgetPublicPages(); @@ -57,32 +57,79 @@ class PressReleaseImageRemover $confirmation->purgeForUnpublished(); } - if ($image->original_path !== null) { - Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->original_path); - } + $disk = $this->evidenceDisk($image); - if ($image->published_snapshot_path !== null) { - Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->published_snapshot_path); + foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) { + FileDeletion::deleteOrThrow($disk, $path, 'Sofortlöschung Bild #'.$image->getKey()); } $image->forceDelete(); }); + + $this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); } + /** + * Archiveintrag für ein veröffentlichtes v2-Bild anlegen und danach die + * lokalen Beweisdateien entfernen. Auch für den Nachholer + * (archive:retry-pending), wenn ein Bild bereits weich gelöscht ist, + * aber kein Eintrag existiert. + */ + public function archiveTrashed(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?string $note = null): ?ArchiveEntry + { + $entry = $this->archiver->imageRemoved($image, $actor, $event, $reason, $legalRequest, $note); + + if ($entry !== null) { + $this->deleteEvidenceFiles($image); + } + + return $entry; + } + + /** + * Lokale Beweisdateien nach erfolgreicher Überführung entfernen. Ein + * Fehlschlag betrifft nur die Datenminimierung — er wird protokolliert, + * die Pfade bleiben stehen, der Systemstatus zählt sie (5.1 e). + */ private function deleteEvidenceFiles(PressReleaseImage $image): void { - $disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk')); - - foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) { - $disk->delete($path); - } + $disk = $this->evidenceDisk($image); + $paths = array_values(array_filter([$image->original_path, $image->published_snapshot_path])); if ($image->published_snapshot_path !== null) { $sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path); if (is_string($sidecar)) { - $disk->delete($sidecar); + $paths[] = $sidecar; } } + + $remaining = FileDeletion::deleteOrLog($disk, $paths, 'Beweisdateien Bild #'.$image->getKey()); + + // Pfade an der Zeile leeren (nur die gelöschten) — über die Query, + // damit die übergebene Instanz für den Aufrufer unverändert bleibt. + PressReleaseImage::withTrashed()->whereKey($image->getKey())->update([ + 'original_path' => in_array($image->original_path, $remaining, true) ? $image->original_path : null, + 'published_snapshot_path' => in_array($image->published_snapshot_path, $remaining, true) ? $image->published_snapshot_path : null, + ]); + } + + /** + * Entfernte v2-Bilder, deren lokale Beweisdateien noch nicht bereinigt + * sind (Überführung ausstehend oder Löschung fehlgeschlagen) — für den + * Admin-Systemstatus (5.1 e). Die Pfade bleiben hier gekapselt (E.4). + */ + public static function countLocalEvidenceLeftovers(): int + { + return PressReleaseImage::query()->onlyTrashed() + ->whereNotNull('rules_version') + ->whereNotNull('published_ever_at') + ->where(fn ($q) => $q->whereNotNull('original_path')->orWhereNotNull('published_snapshot_path')) + ->count(); + } + + private function evidenceDisk(PressReleaseImage $image): Filesystem + { + return Storage::disk($image->original_disk ?? (string) config('images.originals_disk')); } } diff --git a/app/Services/PressRelease/Images/PressReleaseImageUploader.php b/app/Services/PressRelease/Images/PressReleaseImageUploader.php index e93ace4e..d01e2e45 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageUploader.php +++ b/app/Services/PressRelease/Images/PressReleaseImageUploader.php @@ -6,6 +6,7 @@ use App\Enums\AiDeclarationSource; use App\Enums\AiImageDeclaration; use App\Enums\CreditSource; use App\Enums\ImagePrecheckOutcome; +use App\Enums\PressReleaseStatus; use App\Models\ImageRightsConfirmation; use App\Models\LegalText; use App\Models\PressRelease; @@ -38,9 +39,11 @@ use Throwable; * 4. Derivate über ImageService; bei Fehler alles zurückrollen. * 5. Effektive Kennzeichnung bestimmen: Kundenangabe und geprüfter Befund * getrennt; in Phase 1 belegt ein ungesicherter Befund nur vor. - * 6. Transaktion mit Sperre auf der PM: ein Titelbild, Bildzeile, - * Bestätigung (außer Admin-Upload), Regelversion. - * 7. DB-Fehler → Dateien wieder löschen. + * 6. Transaktion mit Sperre auf der PM: erlaubter Status (Audit N07), + * ein Titelbild, Bildzeile, Bestätigung (außer Admin-Upload), + * Regelversion. + * 7. DB-Fehler → Dateien wieder löschen. Nachgelagerte Schritte (Cache) + * laufen erst nach dem Commit und außerhalb der Bereinigung (Audit N08). * * Für Newsroom-Bilder (Block J) wird das Subjekt verallgemeinert; Phase 1 * kennt nur PressRelease. @@ -52,6 +55,7 @@ class PressReleaseImageUploader private readonly ImageProvenanceInspector $inspector, private readonly ImageRightsTextRenderer $textRenderer, private readonly ImagePrecheckService $precheck, + private readonly PublishedImageSnapshot $snapshot, ) {} /** @@ -85,6 +89,15 @@ class PressReleaseImageUploader return $result; } + /** + * `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/ + * Rejected, Admin: alles außer Archived). Sie wird unter der Sperre + * erneut geprüft, damit ein Statuswechsel während Vorab-Check und + * Variantenerzeugung den Upload nicht überholt (Audit N07). Null = keine + * Regel (nur für Aufrufer, die den Status selbst sperren). + * + * @param list|null $allowedStatuses + */ public function store( UploadedFile $upload, PressRelease $pressRelease, @@ -93,6 +106,7 @@ class PressReleaseImageUploader ?string $expectedSha256 = null, ?string $agbUrl = null, ?ImagePrecheckResult $precheck = null, + ?array $allowedStatuses = null, ): PressReleaseImage { $this->assertWithinLimits($upload); @@ -150,13 +164,17 @@ class PressReleaseImageUploader ogCaption: ImageCredit::compose($submission->copyright, $effective['declaration']?->publicLabel()), ); - $image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation): PressReleaseImage { + $image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation, $allowedStatuses): PressReleaseImage { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) ->lockForUpdate() ->firstOrFail(); + if ($allowedStatuses !== null && ! in_array($locked->status, $allowedStatuses, true)) { + throw new ImageUploadException(__('Der Status der Meldung hat sich während des Uploads geändert. Bitte laden Sie die Seite neu.')); + } + if ($locked->images()->exists()) { throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.')); } @@ -210,17 +228,11 @@ class PressReleaseImageUploader return $image; }); - - // Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon - // öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar. - if ($pressRelease->published_at !== null) { - WebCaches::forgetPublicPages(); - } - - return $image; } catch (Throwable $exception) { // Kein Rollback für Dateien: Was geschrieben wurde, wird - // ausdrücklich wieder entfernt (Feedback F09). + // ausdrücklich wieder entfernt (Feedback F09). Nur Fehler VOR + // bzw. IM Commit landen hier — danach gehören die Dateien zur + // gespeicherten Bildzeile (Audit N08). if ($stored !== null) { $this->imageService->deletePressReleaseImage($stored['disk'], $stored['path'], $stored['variants']); } @@ -229,19 +241,44 @@ class PressReleaseImageUploader throw $exception; } + + // Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon + // öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar. + if ($pressRelease->published_at !== null) { + $this->forgetPublicPagesQuietly(); + } + + return $image; } /** * Nachträgliche Bestätigung eines bestehenden Bildes durch den * Kontoinhaber (Admin-Upload, Decision-Update E10) — dieselbe Prüfung * der Textkennung, dasselbe Protokoll, gebunden an den aktuellen Hash. + * Zusätzlich zur Policy prüft der Service selbst, dass der Bestätiger + * der Kontoinhaber ist und eine Bestätigung aussteht (Audit N05). */ public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null): ImageRightsConfirmation { - if (! $actor->confirmsImmediately()) { + $pressRelease = $image->pressRelease; + + if ( + ! $actor->confirmsImmediately() + || $pressRelease === null + || $actor->user === null + || (int) $pressRelease->user_id !== (int) $actor->user->getKey() + ) { throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed'); } + // Frisch prüfen, nicht aus einer eventuell veralteten Relation: + // keine Doppelbestätigung, kein Bestätigen eines ersetzten Bildes. + $image->unsetRelation('latestRightsConfirmation'); + + if (! $image->awaitsCustomerConfirmation()) { + throw new ImageUploadException(__('Für dieses Bild steht keine Bestätigung aus.'), 'rights_confirmed'); + } + $legalText = $this->resolveLegalText( new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey), $actor, @@ -251,20 +288,47 @@ class PressReleaseImageUploader throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed'); } - $confirmation = DB::transaction(function () use ($image, $actor, $legalText, $agbUrl): ImageRightsConfirmation { - $confirmation = $this->recordConfirmation($image, $image->pressRelease, $actor, $legalText, $agbUrl); + $confirmation = DB::transaction(function () use ($image, $pressRelease, $actor, $legalText, $agbUrl): ImageRightsConfirmation { + $confirmation = $this->recordConfirmation($image, $pressRelease, $actor, $legalText, $agbUrl); $image->forceFill(['rights_confirmed_at' => now()])->save(); return $confirmation; }); - if ($image->pressRelease?->published_at !== null) { - WebCaches::forgetPublicPages(); + // Admin-Bildtausch an einer veröffentlichten PM: Mit der Bestätigung + // wird das Bild öffentlich — Veröffentlichungssnapshot (E.2) und + // Seiten-Caches folgen hier, nicht beim Upload. + if ($pressRelease->published_at !== null) { + $this->captureSnapshotQuietly($image, $pressRelease); + $this->forgetPublicPagesQuietly(); } return $confirmation; } + /** + * Nachgelagerte Schritte nach erfolgreichem Commit dürfen die gespeicherte + * Bildzeile nicht mehr gefährden: Fehler werden gemeldet, nicht geworfen + * (Audit N08). Der Cache verfällt spätestens mit seiner TTL. + */ + private function forgetPublicPagesQuietly(): void + { + try { + WebCaches::forgetPublicPages(); + } catch (Throwable $exception) { + report($exception); + } + } + + private function captureSnapshotQuietly(PressReleaseImage $image, PressRelease $pressRelease): void + { + try { + $this->snapshot->capture($image, $pressRelease); + } catch (Throwable $exception) { + report($exception); + } + } + /** * Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt * die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen. diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index dc26f24d..8df122e1 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -32,6 +32,7 @@ use App\Support\WebCaches; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; /** @@ -202,8 +203,15 @@ class PressReleaseService * * Greift nur, solange die PM noch im Status `review` steht; manuelle * Admin-Eingriffe in der Zwischenzeit haben damit Vorrang. + * + * `$expectedHash` ist die Prüfsumme des klassifizierten Inhalts (Audit + * N04): Rot/Hold werden nur für genau diese Fassung wirksam; hat ein + * Editor inzwischen korrigiert, greift unter der Sperre die gleiche + * Regel wie beim Publish (StaleClassificationException, Re-Check). + * + * @throws StaleClassificationException */ - public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void + public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null, ?string $expectedHash = null): void { if ($pressRelease->status !== PressReleaseStatus::Review) { return; @@ -221,12 +229,12 @@ class PressReleaseService $pressRelease->auto_check_attempts, $max, $reason ?: 'Rot ohne Begründung.', - ), automationExhausted: true); + ), automationExhausted: true, expectedHash: $expectedHash); return; } - $this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki'); + $this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki', $expectedHash); return; } @@ -234,7 +242,7 @@ class PressReleaseService // Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für // die manuelle Queue ist bewusst eingeschaltet. if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) { - $this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false); + $this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false, expectedHash: $expectedHash); return; } @@ -247,11 +255,45 @@ class PressReleaseService * wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch. Der Autor * bekommt eine kurze Mail „ein Mensch prüft jetzt" — ohne sie sähe er * nach dem letzten Rot nur Stillstand statt einer Ablehnung. + * + * Unter Zeilensperre (Audit N04): Nur wenn die PM noch in `review` steht + * und – bei übergebener Prüfsumme – noch den geprüften Inhalt trägt. + * + * @throws StaleClassificationException */ - private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted): void + private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted, ?string $expectedHash = null): void { - $pressRelease->update(['manual_review_required_at' => now()]); - $this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki'); + /** @var array{stale?: string, skipped?: true}|null $outcome */ + $outcome = DB::transaction(function () use ($pressRelease, $reason, $expectedHash): ?array { + $locked = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->whereKey($pressRelease->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + if ($locked->status !== PressReleaseStatus::Review) { + return ['skipped' => true]; + } + + if ($this->classificationIsStale($locked, $expectedHash)) { + return ['stale' => (string) $locked->content_hash]; + } + + $locked->update(['manual_review_required_at' => now()]); + $this->logStatusChange($locked, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki'); + + return null; + }); + + $pressRelease->refresh(); + + if (isset($outcome['stale'])) { + $this->rescheduleForCurrentContent($pressRelease, $expectedHash, $outcome['stale']); + } + + if ($outcome !== null) { + return; + } $this->activityLog->record( ActivityType::PressReleaseStatus, @@ -360,9 +402,7 @@ class PressReleaseService } if (isset($outcome['stale'])) { - ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $outcome['stale']['current'])->onQueue('classification'); - - throw new StaleClassificationException($pressRelease->id, $outcome['stale']['analyzed'], $outcome['stale']['current']); + $this->rescheduleForCurrentContent($pressRelease, $outcome['stale']['analyzed'], $outcome['stale']['current']); } if (isset($outcome['blacklist'])) { @@ -374,16 +414,37 @@ class PressReleaseService ); } - // Doppelveröffentlichung (Anhang 2, 1c.8): liegt derselbe Text der - // Firma schon auf dem anderen Portal, bestimmt jetzt das Portal die - // Erstquelle. Nebensache — darf die Veröffentlichung nie brechen. + $this->assignCanonical($pressRelease); + + $this->notifyAuthor($pressRelease, 'published'); + } + + /** + * Doppelveröffentlichung (Anhang 2, 1c.8): liegt derselbe Text der Firma + * schon auf dem anderen Portal, bestimmt jetzt das Portal die Erstquelle. + * Nebensache — darf die Veröffentlichung nie brechen. + */ + private function assignCanonical(PressRelease $pressRelease): void + { try { app(CanonicalAssigner::class)->assignFor($pressRelease); } catch (\Throwable $e) { report($e); } + } - $this->notifyAuthor($pressRelease, 'published'); + /** + * Gegenstück zu assignCanonical(): Verschwindet eine Erstquelle aus der + * öffentlichen Ansicht, bekommen ihre Kopien den Verweis nicht als Leiche + * mit — sie werden neu zugeordnet oder selbst Erstquelle (Sitemap). + */ + private function releaseCanonicalCopies(PressRelease $pressRelease): void + { + try { + app(CanonicalAssigner::class)->reassignCopiesOf($pressRelease); + } catch (\Throwable $e) { + report($e); + } } /** @@ -412,6 +473,21 @@ class PressReleaseService && $analyzedHash !== $pressRelease->content_hash; } + /** + * Gemeinsamer Ausgang von publish(), reject() und Hold, wenn die + * Klassifikation unter der Sperre nicht mehr zum Inhalt passt (U02, N04): + * Prüfung mit Routing für den aktuellen Stand einplanen und dem Aufrufer + * die Abweichung melden. Nichts an der PM geändert. + * + * @throws StaleClassificationException + */ + private function rescheduleForCurrentContent(PressRelease $pressRelease, ?string $analyzedHash, string $currentHash): never + { + ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $currentHash)->onQueue('classification'); + + throw new StaleClassificationException($pressRelease->id, $analyzedHash, $currentHash); + } + /** * Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers * (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte @@ -516,9 +592,20 @@ class PressReleaseService return $base; } - public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void + /** + * @param string|null $expectedHash Prüfsumme des Inhalts, für den die + * Ablehnung gilt (KI-Rot, Audit N04). + * Trägt die gesperrte Zeile eine + * andere, wird nicht abgelehnt, + * sondern neu geprüft. Admin-Ablehnung + * übergibt nichts. + * + * @throws StaleClassificationException + */ + public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin', ?string $expectedHash = null): void { - DB::transaction(function () use ($pressRelease, $reason, $source): void { + /** @var string|null $staleCurrentHash */ + $staleCurrentHash = DB::transaction(function () use ($pressRelease, $reason, $source, $expectedHash): ?string { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) @@ -528,11 +615,22 @@ class PressReleaseService $this->assertStatus($locked, [PressReleaseStatus::Review]); $previous = $locked->status; + if ($this->classificationIsStale($locked, $expectedHash)) { + return (string) $locked->content_hash; + } + $locked->update(['status' => PressReleaseStatus::Rejected->value]); $this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, $source); + + return null; }); $pressRelease->refresh(); + + if ($staleCurrentHash !== null) { + $this->rescheduleForCurrentContent($pressRelease, $expectedHash, $staleCurrentHash); + } + $this->notifyAuthor($pressRelease, 'rejected', $reason); } @@ -552,7 +650,7 @@ class PressReleaseService $previous = $pressRelease->status; - $pressRelease->update(['status' => PressReleaseStatus::Archived->value]); + $pressRelease->update(['status' => PressReleaseStatus::Archived->value, 'archive_pending_at' => now()]); // Sichtbarkeitsregel (Roadmap 1b.1/1b.5): Ein Newsroom ist öffentlich // nur mit mindestens einer veröffentlichten Meldung. Fällt die letzte // weg, muss auch seine Seite sofort weg — nicht erst nach Ablauf des @@ -585,8 +683,10 @@ class PressReleaseService $pressRelease->update([ 'status' => PressReleaseStatus::Archived->value, 'depublished_at' => now(), + 'archive_pending_at' => now(), ]); $pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders'); + $this->releaseCanonicalCopies($pressRelease); WebCaches::forgetPublicPages(); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication'); $this->archiveDepublished( @@ -621,6 +721,10 @@ class PressReleaseService 'published_ever_at' => $status === PressReleaseStatus::Published ? ($locked->published_ever_at ?? now()) : $locked->published_ever_at, + // Archivierung steht aus, bis der Eintrag geschrieben ist (N11). + 'archive_pending_at' => $previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published + ? now() + : $locked->archive_pending_at, ]); if ($status === PressReleaseStatus::Published) { @@ -640,7 +744,12 @@ class PressReleaseService if ($status === PressReleaseStatus::Published) { $this->capturePublishedImages($pressRelease); + + if ($previous !== PressReleaseStatus::Published) { + $this->assignCanonical($pressRelease); + } } elseif ($previous === PressReleaseStatus::Published) { + $this->releaseCanonicalCopies($pressRelease); $this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, $reason); } } @@ -649,7 +758,8 @@ class PressReleaseService { if ($pressRelease->status === PressReleaseStatus::Published) { // Letzter veröffentlichter Stand VOR dem Überschreiben ins Archiv. - $this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, 'Entfernung durch den Admin'); + // Bewusst blockierend: ohne Archiveintrag kein Überschreiben. + app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, Auth::user(), 'Entfernung durch den Admin'); $pressRelease->update([ 'status' => PressReleaseStatus::Archived->value, @@ -661,6 +771,7 @@ class PressReleaseService 'backlink_url' => null, 'no_export' => true, ]); + $this->releaseCanonicalCopies($pressRelease); WebCaches::forgetPublicPages(); return; @@ -679,11 +790,29 @@ class PressReleaseService /** * Beweisarchiv (Block F.4): letzter veröffentlichter Stand, sobald eine - * Meldung das öffentliche Angebot verlässt. + * Meldung das öffentliche Angebot verlässt. Der Marker + * `archive_pending_at` wurde in der fachlichen Transaktion gesetzt und + * fällt erst mit dem Eintrag; scheitert die Ablage, bleibt er stehen + * und `archive:retry-pending` holt den Eintrag nach (N11). Nur + * deleteFromAdmin() archiviert vor dem Überschreiben und bleibt + * blockierend. */ private function archiveDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?string $note = null): void { - app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, $reason, Auth::user(), $note); + try { + $entry = app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, $reason, Auth::user(), $note); + } catch (\Throwable $exception) { + Log::error('Beweisarchiv: Depublikation konnte nicht archiviert werden, wird nachgeholt.', [ + 'press_release_id' => $pressRelease->getKey(), + 'error' => $exception->getMessage(), + ]); + + return; + } + + if ($entry !== null && $pressRelease->archive_pending_at !== null) { + $pressRelease->forceFill(['archive_pending_at' => null])->save(); + } } /** @@ -757,6 +886,7 @@ class PressReleaseService ]); $pressRelease->lockSync($reason ?? 'Entfernung mit 410 (Tombstone)'); + $this->releaseCanonicalCopies($pressRelease); if ($flushPublicPages) { WebCaches::forgetPublicPages(); diff --git a/app/Services/PressRelease/ViewDistributionReport.php b/app/Services/PressRelease/ViewDistributionReport.php index 366c31fd..a0446669 100644 --- a/app/Services/PressRelease/ViewDistributionReport.php +++ b/app/Services/PressRelease/ViewDistributionReport.php @@ -130,14 +130,20 @@ class ViewDistributionReport // Älter heißt: früher veröffentlicht. Die Altersgrenzen drehen sich // deshalb zu Datumsgrenzen um — und bleiben im Fenster from..to. + // Alter [lower, upper) Tage: die Untergrenze des Datums ist + // exklusiv, damit die Kohorten lückenlos aneinanderschließen und + // keine Meldung in zwei fällt. Klemmt das Fenster (`$from`) oder + // ist es die älteste Kohorte, bleibt der Fensterbeginn inklusiv. $newest = $reference->copy()->subDays($lowerDays)->min($to); - $oldest = ($upperDays === null ? $from : $reference->copy()->subDays($upperDays - 1))->max($from); + $bound = $upperDays === null ? null : $reference->copy()->subDays($upperDays); + $lowerExclusive = $bound !== null && $bound->greaterThanOrEqualTo($from); + $oldest = $bound === null ? $from->copy() : $bound->max($from); if ($oldest->greaterThan($newest)) { continue; } - $hits = $this->hits($portal, $oldest, $newest, true); + $hits = $this->hits($portal, $oldest, $newest, true, $lowerExclusive); $count = count($hits); $result[] = [ @@ -157,16 +163,18 @@ class ViewDistributionReport * Eine Meldung mit `portal = both` erscheint auf beiden Portalen und zählt * deshalb bei beiden mit — der Zähler ist einer, die Sichtbarkeit doppelt. * + * @param bool $fromExclusive Untergrenze ausschließen (Kohortengrenzen), sonst inklusiv * @return list */ - private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew): array + private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew, bool $fromExclusive = false): array { return PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', [$portal->value, Portal::Both->value]) ->where('status', PressReleaseStatus::Published->value) ->whereNull('gone_at') - ->whereBetween('published_at', [$from, $to]) + ->where('published_at', $fromExclusive ? '>' : '>=', $from) + ->where('published_at', '<=', $to) ->when($onlyNew === true, fn (Builder $query) => $query->whereNull('legacy_portal')) ->when($onlyNew === false, fn (Builder $query) => $query->whereNotNull('legacy_portal')) ->orderBy('hits') diff --git a/app/Services/PressRelease/VisibilityStats.php b/app/Services/PressRelease/VisibilityStats.php index 0fdc4fcd..2fa039e9 100644 --- a/app/Services/PressRelease/VisibilityStats.php +++ b/app/Services/PressRelease/VisibilityStats.php @@ -136,7 +136,13 @@ class VisibilityStats /** * Einzelwerte einer Meldung für das Panel „Auslieferung". * - * @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list, crawlers_since: Carbon|null, feeds: list} + * Sitemap (1c.8): Nur die Erstquelle steht in der Sitemap eines Portals — + * `sitemap_role` sagt, ob diese Meldung dort Erstquelle (`source`), + * Kopie mit rel=canonical auf die andere Fassung (`copy`) oder gar nicht + * öffentlich (`none`) ist; `sitemap_portals` nennt die Portale, deren + * Sitemap sie tatsächlich führt. + * + * @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array, shares_total: int, in_sitemap_since: Carbon|null, sitemap_role: string, sitemap_portals: list, crawlers: list, crawlers_since: Carbon|null, feeds: list} */ public function releaseSummary(PressRelease $pressRelease): array { @@ -158,16 +164,26 @@ class VisibilityStats $links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : []; + // Dieselbe Regel wie SitemapController/NewsSitemapController: je + // Portal nur die Erstquelle (CanonicalAssigner::isCanonicalOn). + $assigner = app(CanonicalAssigner::class); + $sitemapPortals = array_values(array_map( + static fn (array $link): string => $link['label'], + array_filter($links, static fn (array $link): bool => $assigner->isCanonicalOn($pressRelease, $link['portal'])), + )); + return [ 'hits' => (int) $pressRelease->hits, 'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null, 'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null, 'shares' => $shares->all(), 'shares_total' => (int) $shares->sum(), - // Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem + // Die Sitemap listet jede im Archiv sichtbare Erstquelle — ab dem // Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht // über Google. - 'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null, + 'in_sitemap_since' => $sitemapPortals !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null, + 'sitemap_role' => $links === [] ? 'none' : ($sitemapPortals === [] ? 'copy' : 'source'), + 'sitemap_portals' => $sitemapPortals, 'crawlers' => $this->crawlerFetches($pressRelease), 'crawlers_since' => $this->crawlerHitsSince(), 'feeds' => $this->feedLinks($pressRelease, $links), diff --git a/app/Services/SearchConsole/IndexStatusReport.php b/app/Services/SearchConsole/IndexStatusReport.php index 10056bba..ea1ede58 100644 --- a/app/Services/SearchConsole/IndexStatusReport.php +++ b/app/Services/SearchConsole/IndexStatusReport.php @@ -70,10 +70,7 @@ class IndexStatusReport // lehnt den korrelierten Ausdruck direkt im GROUP BY ab. $visible = DB::query() ->fromSub( - $this->apiJoin(PressRelease::query() - ->withoutGlobalScope(PortalScope::class) - ->visibleInArchive() - ->forPortalValues([$portal->value, Portal::Both->value])) + $this->apiJoin($this->visibleOn($portal)) ->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'), 'r' ) @@ -81,11 +78,15 @@ class IndexStatusReport ->groupBy('origin', 'role') ->get(); + // Dieselbe Grundgesamtheit wie `total`: nur Checks von Meldungen, + // die auf diesem Portal gerade sichtbar sind — sonst zählt eine + // inzwischen depublizierte oder entfernte Meldung als „indexed", + // ohne im Nenner zu stehen. $checked = DB::query() ->fromSub( $this->apiJoin($this->latestChecks($portal) ->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id') - ->whereNull('press_releases.deleted_at')) + ->whereIn('press_releases.id', $this->visibleOn($portal)->select('press_releases.id'))) ->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'), 'c' ) @@ -208,21 +209,36 @@ class IndexStatusReport } /** - * Jeweils jüngste Prüfung je Meldung auf einem Portal. + * Auf dem Portal gerade sichtbare Meldungen — die Grundgesamtheit von + * `total` und der Zustandszählung. + * + * @return Builder + */ + private function visibleOn(Portal $portal): Builder + { + return PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->visibleInArchive() + ->forPortalValues([$portal->value, Portal::Both->value]); + } + + /** + * Jeweils jüngste Prüfung je Meldung auf einem Portal. Bestimmt über die + * höchste ID, nicht über `max(checked_at)`: zwei Prüfungen in derselben + * Sekunde hätten sonst beide gezählt. * * @return Builder */ private function latestChecks(Portal $portal): Builder { $latest = PressReleaseIndexCheck::query() - ->select('press_release_id', DB::raw('max(checked_at) as last_checked_at')) + ->select('press_release_id', DB::raw('max(id) as last_id')) ->where('portal', $portal->value) ->groupBy('press_release_id'); return PressReleaseIndexCheck::query() ->joinSub($latest, 'latest', fn ($join) => $join - ->on('latest.press_release_id', '=', 'press_release_index_checks.press_release_id') - ->on('latest.last_checked_at', '=', 'press_release_index_checks.checked_at')) + ->on('latest.last_id', '=', 'press_release_index_checks.id')) ->where('press_release_index_checks.portal', $portal->value); } } diff --git a/app/Support/FileDeletion.php b/app/Support/FileDeletion.php new file mode 100644 index 00000000..34236969 --- /dev/null +++ b/app/Support/FileDeletion.php @@ -0,0 +1,68 @@ + false`; ein stilles + * `false` darf nirgends als Erfolg durchgehen, wo danach ein Tombstone + * gesetzt oder eine Zeile hart gelöscht wird. + */ +class FileDeletion +{ + /** + * Löscht und bestätigt, dass die Datei danach nicht mehr existiert. + * Eine bereits fehlende Datei gilt als gelöscht. + */ + public static function delete(Filesystem $disk, string $path): bool + { + // Eine fehlende Datei meldet der lokale Treiber als gelöscht (true); + // ein stilles false (throw => false) heißt: Datei ist noch da. + return $disk->delete($path) && self::isGone($disk, $path); + } + + private static function isGone(Filesystem $disk, string $path): bool + { + return ! $disk->exists($path); + } + + /** + * Wie delete(), wirft aber bei Misserfolg — für Pfade, auf denen die + * Löschung Voraussetzung für den nächsten Schritt ist. + */ + public static function deleteOrThrow(Filesystem $disk, string $path, string $context): void + { + if (! self::delete($disk, $path)) { + throw new RuntimeException($context.': Datei konnte nicht gelöscht werden ('.$path.').'); + } + } + + /** + * Löscht mehrere Pfade, protokolliert Fehlschläge und liefert die + * verbliebenen Pfade — für Pfade, deren Verbleib nur Datenminimierung + * betrifft. + * + * @param list $paths + * @return list + */ + public static function deleteOrLog(Filesystem $disk, array $paths, string $context): array + { + $remaining = []; + + foreach ($paths as $path) { + if (! self::delete($disk, $path)) { + $remaining[] = $path; + } + } + + if ($remaining !== []) { + Log::error($context.': Datei(en) konnten nicht gelöscht werden.', ['paths' => $remaining]); + } + + return $remaining; + } +} diff --git a/database/migrations/2026_09_25_153354_add_legal_request_id_to_archive_entries_table.php b/database/migrations/2026_09_25_153354_add_legal_request_id_to_archive_entries_table.php new file mode 100644 index 00000000..5f8b354c --- /dev/null +++ b/database/migrations/2026_09_25_153354_add_legal_request_id_to_archive_entries_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('legal_request_id')->nullable()->after('press_release_id'); + $table->index(['legal_request_id'], 'archive_entries_legal_request_idx'); + }); + } + + public function down(): void + { + Schema::table('archive_entries', function (Blueprint $table) { + $table->dropIndex('archive_entries_legal_request_idx'); + $table->dropColumn('legal_request_id'); + }); + } +}; diff --git a/database/migrations/2026_09_25_153355_add_archive_pending_at_to_press_releases_table.php b/database/migrations/2026_09_25_153355_add_archive_pending_at_to_press_releases_table.php new file mode 100644 index 00000000..da41b4de --- /dev/null +++ b/database/migrations/2026_09_25_153355_add_archive_pending_at_to_press_releases_table.php @@ -0,0 +1,33 @@ +timestamp('archive_pending_at')->nullable()->after('published_ever_at'); + $table->index(['archive_pending_at'], 'press_releases_archive_pending_idx'); + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropIndex('press_releases_archive_pending_idx'); + $table->dropColumn('archive_pending_at'); + }); + } +}; diff --git a/dev/2026-09-01/basiswerte-block0.md b/dev/2026-09-01/basiswerte-block0.md index 1f852cbe..fab126fb 100644 --- a/dev/2026-09-01/basiswerte-block0.md +++ b/dev/2026-09-01/basiswerte-block0.md @@ -141,6 +141,18 @@ Die ganze Kurve am Stichtag 21.09.2026: | **28–55 Tage** | **703 / 122** | **727 / 314** | | 56–90 Tage (= Go-Live-Wochen 20.07.–27.07.) | 184 / 230 | 194 / 346 | +> **Fußnote (25.09.2026, Umsetzungsaudit N15):** Die Kohorten dieser +> Erhebung waren je einen Tag schmaler als beschriftet — der Report bildete +> die Untergrenze mit `Stichtag − (Obergrenze − 1)` und ließ an jeder +> Kohortengrenze 24 Stunden aus. „28–55 Tage" umfasst hier also 27 statt +> 28 Tage, die Kohortensumme (1.616 / 1.651) liegt deshalb unter „seit +> Relaunch" (1.804 / 1.847). Der Report ist seit dem 25.09.2026 korrigiert +> (halboffene Intervalle, lückenlos). Die Werte 122 / 314 bleiben als +> Basiswert stehen — eine Neuerhebung heute träfe eine andere +> Meldungsmenge, nicht denselben Stichtag. Die nächste Erhebung läuft mit +> dem korrigierten Report; der Unterschied von einem Tag Breite ist für den +> Vergleich der Mediane unerheblich. + Lesart: - **Businessportal24 sammelt stetig** bis etwa Woche 5 und liegt dann bei diff --git a/dev/betrieb/deploy-runbook.md b/dev/betrieb/deploy-runbook.md index 6edb67bd..0f7a349d 100644 --- a/dev/betrieb/deploy-runbook.md +++ b/dev/betrieb/deploy-runbook.md @@ -292,7 +292,20 @@ Newsroom-Übersicht, eine alte PM-Detailseite (lädt Bild + Anhang?), Firmenlogo Das Beweisarchiv (Bildkennzeichnung Block F) löscht Einträge nach Frist; eine eingespielte Sicherung würde gelöschte Zeilen und Payloads zurückbringen. Der Abgleich liest den täglich exportierten Tombstone-Stand - (`storage/app/archive/tombstones/latest.json`) und entfernt sie erneut. + (`storage/app/archive/tombstones/latest.json`) **und** die Tombstone-Zeilen der + Datenbank und entfernt wiederhergestellte Payloads erneut. Exit-Code ≠ 0 heißt: + mindestens eine Datei ließ sich nicht löschen — Log prüfen, Lauf wiederholen. +- **Achtung bei Restore der Archiv-Disk:** Wird `storage/app/archive` selbst auf + einen älteren Stand zurückgesetzt, ist auch `tombstones/latest.json` alt. Dann + **vor** dem Abgleich den jüngsten Tombstone-Export von außerhalb des Hosts + einspielen (Kopie aus dem letzten Backup-Lauf *nach* dem letzten `archive:purge`) + oder sicherstellen, dass die Datenbank den aktuellen Stand hat — der Abgleich + nimmt dann die DB-Tombstones. Ein automatisches externes Exportziel ist nicht + gebaut (Betreiberentscheidung offen, Umsetzungsaudit 25.09.2026, 5.1 c). +- **Nach dem Restore ebenfalls:** `php artisan archive:retry-pending` (holt + Archiveinträge nach, deren Ablage beim Vorgang fehlgeschlagen ist; Zähler im + Admin-Systemstatus) und `php artisan archive:sweep-orphans --dry-run` + (Payload-Dateien ohne Zeile). - `ARCHIVE_KEY` (Beweisarchiv) und `CONFIRMATION_IP_KEY` liegen nur in der Ploi-Umgebung. `ARCHIVE_KEY` getrennt vom DB-Backup sichern — ohne ihn ist das Archiv unlesbar; erzeugen mit `php artisan archive:key`. diff --git a/resources/views/livewire/admin/archive/index.blade.php b/resources/views/livewire/admin/archive/index.blade.php index 3ae2ffdf..342177ef 100644 --- a/resources/views/livewire/admin/archive/index.blade.php +++ b/resources/views/livewire/admin/archive/index.blade.php @@ -27,6 +27,10 @@ new #[Layout('components.layouts.app'), Title('Beweisarchiv')] class extends Com #[Url] public string $subject = ''; + /** Vorgang im Meldeverfahren (legal_requests.id, 5.1 d). */ + #[Url] + public string $legalRequest = ''; + public ?int $holdEntryId = null; public string $holdNote = ''; @@ -47,14 +51,22 @@ new #[Layout('components.layouts.app'), Title('Beweisarchiv')] class extends Com ['holdNote.required' => __('Bitte begründen Sie das Setzen bzw. Aufheben (Pflichtvermerk).')], ); - $entry->legal_hold - ? $archive->releaseLegalHold($entry, auth()->user(), $this->holdNote) - : $archive->setLegalHold($entry, auth()->user(), $this->holdNote); + try { + $entry->legal_hold + ? $archive->releaseLegalHold($entry, auth()->user(), $this->holdNote) + : $archive->setLegalHold($entry, auth()->user(), $this->holdNote); + } catch (\RuntimeException $exception) { + // Eintrag wurde zwischenzeitlich nach Fristablauf gelöscht (N02). + $this->holdEntryId = null; + Flux::toast(text: $exception->getMessage(), variant: 'danger'); + + return; + } $this->holdEntryId = null; $this->holdNote = ''; - Flux::toast(text: $entry->fresh()->legal_hold ? __('Legal Hold gesetzt.') : __('Legal Hold aufgehoben, Frist läuft neu.'), variant: 'success'); + Flux::toast(text: $entry->legal_hold ? __('Legal Hold gesetzt.') : __('Legal Hold aufgehoben, Frist läuft neu.'), variant: 'success'); } public function with(): array @@ -71,6 +83,10 @@ new #[Layout('components.layouts.app'), Title('Beweisarchiv')] class extends Com }); } + if ($this->legalRequest !== '') { + $query->where('legal_request_id', (int) $this->legalRequest); + } + return [ 'entries' => $query->paginate(50), 'events' => ArchiveEvent::cases(), @@ -94,6 +110,7 @@ new #[Layout('components.layouts.app'), Title('Beweisarchiv')] class extends Com @endforeach + @@ -127,6 +144,9 @@ new #[Layout('components.layouts.app'), Title('Beweisarchiv')] class extends Com @if ($entry->press_release_id) · PM #{{ $entry->press_release_id }} @endif + @if ($entry->legal_request_id) + · + @endif {{ $entry->reason_category->label() }} {{ $entry->created_at?->format('d.m.Y H:i') }} diff --git a/resources/views/livewire/admin/categories/edit.blade.php b/resources/views/livewire/admin/categories/edit.blade.php index f9eca2e2..c914a703 100644 --- a/resources/views/livewire/admin/categories/edit.blade.php +++ b/resources/views/livewire/admin/categories/edit.blade.php @@ -3,6 +3,7 @@ use App\Enums\Portal; use App\Models\Category; use App\Models\CategoryTranslation; +use App\Support\WebCaches; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; @@ -80,8 +81,11 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext return; } - DB::transaction(function (): void { + $publicChanged = false; + + DB::transaction(function () use (&$publicChanged): void { $category = Category::query()->findOrFail($this->id); + $previousSlugs = $category->translations()->pluck('slug', 'locale')->all(); $category->update([ 'parent_id' => $this->parentId ?: null, @@ -92,6 +96,13 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext $slugDe = CategoryTranslation::uniqueSlug($this->slugDe, 'de', $category->id); $slugEn = CategoryTranslation::uniqueSlug($this->slugEn, 'en', $category->id); + // Sichtbarkeit, Portal oder Adresse geändert: Branchenseite, + // Navigation und Kategorienfeed liegen bis zu einer Stunde im + // Seiten-Cache — den alten Stand nicht nachhängen lassen. + $publicChanged = $category->wasChanged(['is_active', 'portal', 'parent_id']) + || ($previousSlugs['de'] ?? null) !== $slugDe + || ($previousSlugs['en'] ?? null) !== $slugEn; + $category->translations()->updateOrCreate( ['locale' => 'de'], [ @@ -114,6 +125,10 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext $this->slugEn = $slugEn; }); + if ($publicChanged) { + WebCaches::forgetPublicPages(); + } + session()->flash('success', __('Kategorie wurde aktualisiert.')); } diff --git a/resources/views/livewire/components/press-release-images-manager-v2.blade.php b/resources/views/livewire/components/press-release-images-manager-v2.blade.php index cddf749c..b0c86be5 100644 --- a/resources/views/livewire/components/press-release-images-manager-v2.blade.php +++ b/resources/views/livewire/components/press-release-images-manager-v2.blade.php @@ -85,9 +85,18 @@ new class extends Component { #[Locked] public ?array $precheck = null; - public function mount(int $pressReleaseId): void + /** + * Kennung des Bestätigungstexts, den die Seite gerade anzeigt (Audit N06): + * beim Speichern wird sie gegen die aktuelle Fassung geprüft, damit kein + * Kunde einen Wortlaut bestätigt, der inzwischen abgelöst wurde. + */ + #[Locked] + public ?string $legalTextKey = null; + + public function mount(int $pressReleaseId, ImageRightsTextRenderer $texts): void { $this->pressReleaseId = $pressReleaseId; + $this->legalTextKey = $texts->current()?->key; } public function openUploadForm(): void @@ -228,6 +237,17 @@ new class extends Component { ); $legalText = $texts->current(); + + // Versionsbindung (Audit N06): Der Kunde bestätigt nur den Wortlaut, + // den er gesehen hat. Beim Admin-Upload wird nichts bestätigt. + if (!$isAdmin && $legalText?->key !== $this->legalTextKey) { + $this->legalTextKey = $legalText?->key; + $this->newRightsConfirmed = false; + $this->addError('newRightsConfirmed', __('Der Bestätigungstext wurde aktualisiert. Bitte lesen Sie die neue Fassung und bestätigen Sie erneut.')); + + return; + } + [$focusX, $focusY] = array_map('intval', explode(',', $this->focus)); $submission = new ImageSubmission( @@ -258,6 +278,7 @@ new class extends Component { expectedSha256: $this->provenanceSha, agbUrl: $this->agbUrl(), precheck: $this->precheck !== null ? ImagePrecheckResult::fromArray($this->precheck) : null, + allowedStatuses: $this->allowedStatuses(), ); } catch (ImageUploadException $exception) { $this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage()); @@ -332,6 +353,7 @@ new class extends Component { $titleImage = $this->titleImageFor($pressRelease); $legalText = $texts->current(); $agbUrl = $this->agbUrl(); + $this->legalTextKey = $legalText?->key; return [ 'titleImage' => $titleImage, @@ -342,7 +364,6 @@ new class extends Component { 'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease), 'isAdminActor' => $this->actsAsAdmin(), 'companyName' => $this->companyName(), - 'legalTextKey' => $legalText?->key, 'legalLong' => $legalText ? $texts->renderLong($legalText, $agbUrl) : null, 'legalShort' => $legalText ? $texts->renderShort($legalText, $agbUrl) : null, 'provenanceState' => $this->provenanceState(), @@ -450,13 +471,25 @@ new class extends Component { return PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($this->pressReleaseId); } - private function canChangeImages(PressRelease $pressRelease): bool + /** + * Status-Regel des handelnden Nutzers: Kunden ändern das Titelbild nur bei + * Entwürfen und abgelehnten PMs, Admins bei allem außer Archiv. Der + * Uploader prüft dieselbe Liste unter der Sperre erneut (Audit N07). + * + * @return list + */ + private function allowedStatuses(): array { if (auth()->user()?->canAccessAdmin()) { - return !in_array($pressRelease->status, [PressReleaseStatus::Archived], true); + return [PressReleaseStatus::Draft, PressReleaseStatus::Review, PressReleaseStatus::Published, PressReleaseStatus::Rejected]; } - return in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true); + return [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]; + } + + private function canChangeImages(PressRelease $pressRelease): bool + { + return in_array($pressRelease->status, $this->allowedStatuses(), true); } private function titleImageFor(PressRelease $pressRelease): ?PressReleaseImage diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 5794369e..9e8d63c9 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -114,10 +114,19 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends */ public bool $imageRightsConfirmed = false; + /** + * Kennung des angezeigten Bestätigungstexts (Audit N06): Bestätigt wird + * nur der Wortlaut, den die Seite gerade zeigt. + */ + #[Locked] + public ?string $imageLegalTextKey = null; + public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void { $pr = $this->getMyPR(); - $this->authorize('update', $pr); + // Eigene Berechtigung statt `update` (Audit N05): nur der Kontoinhaber, + // auch bei veröffentlichter PM (Admin-Bildtausch). + $this->authorize('confirmImageRights', $pr); $image = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(); @@ -130,11 +139,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends ['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')], ); + $legalText = $texts->current(); + + if ($legalText?->key !== $this->imageLegalTextKey) { + $this->imageLegalTextKey = $legalText?->key; + $this->imageRightsConfirmed = false; + $this->addError('imageRightsConfirmed', __('Der Bestätigungstext wurde aktualisiert. Bitte lesen Sie die neue Fassung und bestätigen Sie erneut.')); + + return; + } + try { $confirmation->confirm( $image, auth()->user(), - $texts->current()?->key, + $legalText?->key, request()->ip(), request()->userAgent(), route('agb', ['edition' => 'de']), @@ -831,6 +850,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $cover = app(PressReleaseCoverImage::class); $user = auth()->user(); + // Angezeigte Fassung des Bestätigungstexts merken (Audit N06). + $imageLegalText = app(ImageRightsTextRenderer::class)->current(); + $this->imageLegalTextKey = $imageLegalText?->key; + $company = $pr->company; $boostService = app(BoostService::class); @@ -869,8 +892,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends 'coverUrl' => $cover->coverUrl($pr, 'cover'), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), 'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(), - 'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('update', $pr), - 'imageLegalShort' => ($imageLegalText = app(ImageRightsTextRenderer::class)->current()) + 'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('confirmImageRights', $pr), + 'imageLegalShort' => $imageLegalText ? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de'])) : null, 'imageLegalLong' => $imageLegalText @@ -1643,8 +1666,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Sitemap für Suchmaschinen') }}
- @if ($visibility['in_sitemap_since']) - {{ __('Gelistet seit :date — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }} + @if (($visibility['sitemap_role'] ?? null) === 'copy') + {{-- Kopie bei Doppelveröffentlichung (Anhang 2, 1c.8): Leser sehen beide Fassungen, die Sitemap nennt nur die Erstquelle. --}} + {{ __('Nicht in der Sitemap — für Suchmaschinen ist die Erstfassung auf dem anderen Portal die maßgebliche Adresse (rel=canonical). Leser finden die Meldung weiterhin hier.') }} + @elseif ($visibility['in_sitemap_since']) + {{ __('Gelistet seit :date (:portals) — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y'), 'portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }} @else {{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }} @endif diff --git a/routes/console.php b/routes/console.php index 385ac440..8f09307a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,8 @@ use App\Console\Commands\AggregatePressReleaseViews; use App\Console\Commands\ArchiveExportTombstones; use App\Console\Commands\ArchivePurge; +use App\Console\Commands\ArchiveRetryPending; +use App\Console\Commands\ArchiveSweepOrphans; use App\Console\Commands\CheckIndexStatus; use App\Console\Commands\CheckMailDomains; use App\Console\Commands\ExpireGrandfathering; @@ -87,6 +89,20 @@ Schedule::command(ArchiveExportTombstones::class) ->runInBackground() ->onFailure($alertOnFailure('archive:export-tombstones')); +// Fehlgeschlagene Archiveinträge nachholen (03:35) und Payloads ohne Zeile +// nach 24 h Karenz entfernen (03:40) — Umsetzungsaudit 25.09.2026, N11. +Schedule::command(ArchiveRetryPending::class) + ->dailyAt('03:35') + ->withoutOverlapping() + ->runInBackground() + ->onFailure($alertOnFailure('archive:retry-pending')); + +Schedule::command(ArchiveSweepOrphans::class) + ->dailyAt('03:40') + ->withoutOverlapping() + ->runInBackground() + ->onFailure($alertOnFailure('archive:sweep-orphans')); + // PM-Entwürfe: Zombie-Drafts nach 180 Tagen Inaktivität archivieren (wöchentlich) Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180']) ->weekly() diff --git a/tests/Feature/Admin/AdminCategoryManagementTest.php b/tests/Feature/Admin/AdminCategoryManagementTest.php index 745d7261..46854264 100644 --- a/tests/Feature/Admin/AdminCategoryManagementTest.php +++ b/tests/Feature/Admin/AdminCategoryManagementTest.php @@ -154,3 +154,25 @@ test('category can be deleted when empty', function () { expect(Category::query()->find($category->id))->toBeNull(); }); + +test('deactivating a category clears the cached public pages and its feed at once', function () { + /** @var TestCase $this */ + $category = Category::factory()->create(['portal' => Portal::Presseecho->value, 'is_active' => true]); + $category->translations()->createMany([ + ['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel'], + ['locale' => 'en', 'name' => 'Trade', 'slug' => 'trade'], + ]); + PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category) + ->create(['language' => 'de', 'published_at' => now()->subHour()]); + + // Feed warm im Seiten-Cache. + $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful(); + + LivewireVolt::test('admin.categories.edit', ['id' => $category->id]) + ->set('isActive', false) + ->call('save') + ->assertHasNoErrors(); + + // Nicht erst nach Ablauf der Stale-Grenze: sofort weg. + $this->get('https://presseecho.test/de/category/handel/feed')->assertNotFound(); +}); diff --git a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php index eaccb432..b4b71ee0 100644 --- a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php @@ -6,11 +6,14 @@ use App\Models\ImageRightsConfirmation; use App\Models\PressRelease; use App\Models\PressReleaseImage; use App\Models\User; +use App\Services\Image\ImageProvenanceInspector; use Database\Seeders\LegalDocumentSeeder; use Database\Seeders\LegalTextSeeder; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; +use Mockery\MockInterface; use Tests\TestCase; /** @@ -181,3 +184,37 @@ test('the api docs page lists the rights fields with the current text version', ->assertSeeText('confirm_text_version') ->assertSeeText('img-confirm-v2'); }); + +test('a status change during the upload answers 422 and stores nothing', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease] = rightsV2ApiDraft(); + $real = app(ImageProvenanceInspector::class); + + // Die PM wird während der Herkunftsprüfung eingereicht und veröffentlicht + // (Audit N07): der Uploader prüft die Statusregel unter der Sperre erneut. + $this->instance(ImageProvenanceInspector::class, Mockery::mock(ImageProvenanceInspector::class, function (MockInterface $mock) use ($real, $pressRelease): void { + $mock->shouldReceive('inspect')->andReturnUsing(function (string $path) use ($real, $pressRelease) { + DB::table('press_releases')->where('id', $pressRelease->id)->update([ + 'status' => PressReleaseStatus::Published->value, + 'published_at' => now(), + ]); + + return $real->inspect($path); + }); + })); + + $response = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ + 'image' => rightsV2ApiFixture('plain.png'), + 'ai_declaration' => 'original', + 'copyright' => 'Musterfirma', + 'rights_confirmed' => true, + 'confirm_text_version' => 'img-confirm-v2', + ]); + + $response->assertStatus(422); + + expect($response->json('message'))->toContain('Status der Meldung') + ->and(PressReleaseImage::query()->count())->toBe(0) + ->and(ImageRightsConfirmation::query()->count())->toBe(0) + ->and(Storage::disk('local')->allFiles())->toBe([]); +}); diff --git a/tests/Feature/Archive/EvidenceArchiveTest.php b/tests/Feature/Archive/EvidenceArchiveTest.php index 931154df..8bfb1a7c 100644 --- a/tests/Feature/Archive/EvidenceArchiveTest.php +++ b/tests/Feature/Archive/EvidenceArchiveTest.php @@ -1,26 +1,35 @@ assignRole('customer'); $this->actingAs($customer)->get('https://pressekonto.test/admin/archive')->assertForbidden(); }); + +// --------------------------------------------------------------------------- +// Umsetzungsaudit 25.09.2026: N02, N10, N11, 5.1 a/c/d +// --------------------------------------------------------------------------- + +test('a legal hold set after the purge selected the entry still protects the payload', function () { + /** @var TestCase $this */ + $admin = archiveAdmin(); + $pr = PressRelease::factory()->create(); + $archive = app(EvidenceArchive::class); + + $first = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]); + $second = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 2]); + + $this->travelTo(now()->endOfYear()->addYears(3)->addDay()); + + // Beide Einträge sind im selben Chunk geladen; während der erste gelöscht + // wird, setzt ein Admin den Hold auf den zweiten (alte Instanz im Purge). + FailingDisk::install('archive', [ + 'exists' => function (string $path, callable $real) use ($second): bool { + DB::table('archive_entries')->where('id', $second->id)->update(['legal_hold' => true]); + + return $real(); + }, + ]); + + $result = $archive->purgeDue(); + + expect($result)->toBe(['due' => 2, 'purged' => 1, 'failed' => 0]) + ->and($first->fresh()->isTombstoned())->toBeTrue() + ->and($second->fresh()->isTombstoned())->toBeFalse() + ->and(Storage::disk('archive')->exists($second->payload_path))->toBeTrue(); + + FailingDisk::restore('archive'); + + // Hold auf einem Tombstone wird abgelehnt statt still gespeichert. + expect(fn () => $archive->setLegalHold($first->fresh(), $admin, 'zu spät'))->toThrow(RuntimeException::class); + expect($first->fresh()->legal_hold)->toBeFalse(); +}); + +test('a failed payload deletion leaves the entry active, fails the command and is retried on the next run', function () { + /** @var TestCase $this */ + $pr = PressRelease::factory()->create(); + $archive = app(EvidenceArchive::class); + $due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]); + + $this->travelTo(now()->endOfYear()->addYears(3)->addDay()); + FailingDisk::install('archive', ['delete' => fn () => false]); + + $this->artisan('archive:purge')->expectsOutputToContain('fehlgeschlagen: 1')->assertFailed(); + + expect($due->fresh()->isTombstoned())->toBeFalse() + ->and(Storage::disk('archive')->exists($due->payload_path))->toBeTrue(); + + FailingDisk::restore('archive'); + + $this->artisan('archive:purge')->expectsOutputToContain('Gelöscht: 1')->assertSuccessful(); + expect($due->fresh()->isTombstoned())->toBeTrue() + ->and(Storage::disk('archive')->exists($due->payload_path))->toBeFalse(); +}); + +test('a failed tombstone export or reconcile deletion is reported as failure', function () { + /** @var TestCase $this */ + $pr = PressRelease::factory()->create(); + $archive = app(EvidenceArchive::class); + $due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]); + $this->travelTo(now()->endOfYear()->addYears(3)->addDay()); + $this->artisan('archive:purge')->assertSuccessful(); + + FailingDisk::install('archive', ['put' => fn () => false]); + $this->artisan('archive:export-tombstones')->assertFailed(); + expect(Storage::disk('archive')->exists(config('archive.tombstone_export_path')))->toBeFalse(); + FailingDisk::restore('archive'); + + $this->artisan('archive:export-tombstones')->assertSuccessful(); + expect(Storage::disk('archive')->exists(config('archive.tombstone_export_path')))->toBeTrue() + ->and(Storage::disk('archive')->exists(config('archive.tombstone_export_path').'.tmp'))->toBeFalse(); + + // „Restore": Payload ist wieder da, Löschung schlägt fehl. + Storage::disk('archive')->put($due->payload_path, 'restored-cipher'); + FailingDisk::install('archive', ['delete' => fn () => false]); + $this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 fehlgeschlagen')->assertFailed(); + FailingDisk::restore('archive'); +}); + +test('record leaves neither a row nor a temp file behind when the disk fails, and sweep removes stale orphans', function () { + /** @var TestCase $this */ + $pr = PressRelease::factory()->create(); + $archive = app(EvidenceArchive::class); + + FailingDisk::install('archive', ['put' => fn () => false]); + expect(fn () => $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []))->toThrow(RuntimeException::class); + FailingDisk::restore('archive'); + + FailingDisk::install('archive', ['move' => fn () => false]); + expect(fn () => $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []))->toThrow(RuntimeException::class); + FailingDisk::restore('archive'); + + expect(ArchiveEntry::query()->count())->toBe(0) + ->and(Storage::disk('archive')->allFiles('entries'))->toBe([]); + + // Verwaiste Dateien: eine alte (weg), eine frische (bleibt), eine mit Zeile (bleibt). + $kept = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []); + Storage::disk('archive')->put('entries/2026/stale.bin', 'x'); + Storage::disk('archive')->put('entries/2026/stale.bin.tmp', 'x'); + Storage::disk('archive')->put('entries/2026/fresh.bin', 'x'); + touch(Storage::disk('archive')->path('entries/2026/stale.bin'), time() - 90000); + touch(Storage::disk('archive')->path('entries/2026/stale.bin.tmp'), time() - 90000); + touch(Storage::disk('archive')->path($kept->payload_path), time() - 90000); + + $this->artisan('archive:sweep-orphans', ['--dry-run' => true])->expectsOutputToContain('2 verwaist, 0 entfernt')->assertSuccessful(); + $this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 2 entfernt')->assertSuccessful(); + + expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeFalse() + ->and(Storage::disk('archive')->exists('entries/2026/stale.bin.tmp'))->toBeFalse() + ->and(Storage::disk('archive')->exists('entries/2026/fresh.bin'))->toBeTrue() + ->and(Storage::disk('archive')->exists($kept->payload_path))->toBeTrue(); +}); + +test('a failed depublication archive leaves a pending marker that the retry command resolves', function () { + /** @var TestCase $this */ + ['pr' => $pr] = archivePublishedWithImage(); + $this->actingAs(archiveAdmin()); + + FailingDisk::install('archive', ['put' => fn () => false]); + app(PressReleaseService::class)->depublish($pr); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Archived) + ->and($pr->fresh()->archive_pending_at)->not->toBeNull() + ->and(ArchiveEntry::query()->count())->toBe(0); + + $health = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); + expect($health->get('archive_pending')['status'])->toBe('warn'); + + $this->artisan('archive:retry-pending')->assertFailed(); + FailingDisk::restore('archive'); + + $this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful(); + + $entry = ArchiveEntry::query()->where('event', 'pm_depublished')->sole(); + expect($entry->reason_category)->toBe(ArchiveReasonCategory::CustomerRequest) + ->and($pr->fresh()->archive_pending_at)->toBeNull() + ->and(app(EvidenceArchive::class)->manifest($entry, auth()->user(), 'Test')['note'])->toContain('Nachgeholt'); + + $this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 0')->assertSuccessful(); + expect(ArchiveEntry::query()->count())->toBe(1); +}); + +test('a failed anonymisation archive still removes the image and is completed by the retry with the snapshot as before state', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'image' => $image] = archivePublishedWithImage(); + $pr->forceFill(['text' => '

Max Mustermann war dabei.

'])->save(); + $legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]); + $this->actingAs(archiveAdmin()); + + FailingDisk::install('archive', ['put' => fn () => false]); + app(AnonymizationService::class)->execute($pr, ['Max Mustermann'], removeImageIds: [$image->id], legalRequest: $legalRequest); + FailingDisk::restore('archive'); + + expect($pr->fresh()->text)->not->toContain('Max Mustermann') + ->and($pr->fresh()->archive_pending_at)->not->toBeNull() + ->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue() + ->and(Storage::disk('local')->exists($image->path))->toBeFalse() + ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() + ->and(ArchiveEntry::query()->count())->toBe(0); + + // Nachträgliche Änderung darf den Vorher-Stand des Nachweises nicht verfälschen. + $pr->forceFill(['text' => '

Ganz neuer Text.

'])->save(); + + $this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 2')->assertSuccessful(); + + $change = ArchiveEntry::query()->where('event', 'gdpr_change')->sole(); + $removed = ArchiveEntry::query()->where('event', 'image_removed')->sole(); + $manifest = app(EvidenceArchive::class)->manifest($change, auth()->user(), 'Test'); + + expect($change->legal_request_id)->toBe($legalRequest->id) + ->and($removed->legal_request_id)->toBe($legalRequest->id) + ->and($removed->reason_category)->toBe(ArchiveReasonCategory::Gdpr) + ->and($manifest['before']['text'])->toContain('Max Mustermann') + ->and($manifest['after']['text'])->toContain('Ganz neuer Text') + ->and($manifest['redaction_snapshot_id'])->toBe(PressReleaseRedactionSnapshot::query()->sole()->id) + ->and(Storage::disk('local')->exists($image->original_path))->toBeFalse() + ->and($pr->fresh()->archive_pending_at)->toBeNull(); +}); + +test('an anonymisation removes images through the archive lifecycle and links entry and activity to the request', function () { + ['pr' => $pr, 'image' => $image] = archivePublishedWithImage(); + $legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]); + $admin = archiveAdmin(); + $this->actingAs($admin); + + app(AnonymizationService::class)->execute($pr, [], removeImageIds: [$image->id], legalRequest: $legalRequest); + + $removed = ArchiveEntry::query()->where('event', 'image_removed')->sole(); + $change = ArchiveEntry::query()->where('event', 'gdpr_change')->sole(); + + expect($removed->reason_category)->toBe(ArchiveReasonCategory::Gdpr) + ->and($removed->legal_request_id)->toBe($legalRequest->id) + ->and($removed->actor_type)->toBe('admin') + ->and(app(EvidenceArchive::class)->manifest($removed, $admin, 'Test')['legal_request']['id'])->toBe($legalRequest->id) + ->and(Storage::disk('local')->exists($image->original_path))->toBeFalse() + ->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue() + ->and($pr->fresh()->archive_pending_at)->toBeNull() + ->and(ArchiveEntry::query()->where('legal_request_id', $legalRequest->id)->count())->toBe(2); + + $activity = ActivityEvent::query()->where('type', ActivityType::Anonymization->value)->latest('id')->first(); + expect($activity->meta['archive_entry_id'])->toBe($change->id); + + $this->get('https://pressekonto.test/admin/archive?legalRequest='.$legalRequest->id) + ->assertSuccessful() + ->assertSeeText('Vorgang #'.$legalRequest->id); +}); + +test('the tombstone of a removed image also purges confirmation, correction and image row in the application', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'image' => $image] = archivePublishedWithImage(); + $admin = archiveAdmin(); + + app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer Nachweis', 'Einwand'); + app(PressReleaseImageRemover::class)->remove($image->fresh(), $admin); + + expect(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(1) + ->and(ImageDeclarationCorrection::query()->where('press_release_image_id', $image->id)->count())->toBe(1); + + $this->travelTo(now()->endOfYear()->addYears(3)->addDay()); + $this->artisan('archive:purge')->assertSuccessful(); + + expect(ArchiveEntry::query()->where('event', 'image_removed')->sole()->isTombstoned())->toBeTrue() + ->and(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(0) + ->and(ImageDeclarationCorrection::query()->where('press_release_image_id', $image->id)->count())->toBe(0) + ->and(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse() + ->and(PressRelease::query()->whereKey($pr->id)->exists())->toBeTrue(); +}); + +test('reconcile also uses database tombstones when the export on the restored disk is stale', function () { + /** @var TestCase $this */ + $pr = PressRelease::factory()->create(); + $archive = app(EvidenceArchive::class); + $due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]); + $this->travelTo(now()->endOfYear()->addYears(3)->addDay()); + $this->artisan('archive:purge')->assertSuccessful(); + + // Disk auf alten Stand: Payload wieder da, kein Export vorhanden. + Storage::disk('archive')->put($due->payload_path, 'restored-cipher'); + Storage::disk('archive')->delete(config('archive.tombstone_export_path')); + + $this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 Dateien entfernt, 0 fehlgeschlagen')->assertSuccessful(); + expect(Storage::disk('archive')->exists($due->payload_path))->toBeFalse(); +}); diff --git a/tests/Feature/Billing/ExpireGrandfatheringTest.php b/tests/Feature/Billing/ExpireGrandfatheringTest.php index 83f30dd2..b7b2e1eb 100644 --- a/tests/Feature/Billing/ExpireGrandfatheringTest.php +++ b/tests/Feature/Billing/ExpireGrandfatheringTest.php @@ -12,6 +12,7 @@ use App\Models\User; use App\Models\UserPaymentOption; use App\Services\Mail\EmailSuppressionService; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Tests\TestCase; @@ -217,3 +218,73 @@ test('dry-run lists pending notifications and changes nothing', function () { Carbon::setTestNow(); }); + +test('two runs racing for the same open notification queue exactly one mail (N14)', function () { + /** @var TestCase $this */ + Mail::fake(); + Carbon::setTestNow('2026-09-18 04:15:00'); + + $pending = grandfatheredAgreement('2026-09-01', [ + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'cancelled_at' => now()->subDay(), + 'grandfathering_expired_at' => now()->subDay(), + 'grandfathering_notified_at' => null, + ]); + + // Der „andere Lauf" setzt den Marker genau zwischen der Auswahl der + // offenen Posten und dem gesperrten Nachlesen. + $raced = false; + DB::beforeExecuting(function (string $query) use (&$raced, $pending): void { + if ($raced || ! str_starts_with($query, 'select') || ! str_contains($query, 'user_payment_options') || ! str_contains($query, ' in (')) { + return; + } + + $raced = true; + DB::table('user_payment_options')->where('id', $pending->id)->update(['grandfathering_notified_at' => now()->subSecond()]); + }); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + expect($raced)->toBeTrue() + ->and($pending->fresh()->grandfathering_notified_at->toDateTimeString())->toBe('2026-09-18 04:14:59'); + + Mail::assertNothingQueued(); + + Carbon::setTestNow(); +}); + +test('the notification marker and the queued job stand or fall together (N14)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-09-18 04:15:00'); + config()->set('queue.default', 'database'); + + $expired = grandfatheredAgreement('2026-09-17'); + + // Der Job-Insert scheitert einmal: Marker und Auftrag rollen gemeinsam + // zurück — sonst bliebe entweder eine Mail aus oder käme doppelt. + $failOnce = true; + DB::beforeExecuting(function (string $query) use (&$failOnce): void { + if ($failOnce && str_starts_with($query, 'insert') && str_contains($query, 'jobs')) { + $failOnce = false; + + throw new RuntimeException('Queue weg'); + } + }); + + $this->artisan(ExpireGrandfathering::class)->assertFailed(); + + expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled) + ->and($expired->fresh()->grandfathering_notified_at)->toBeNull() + ->and(DB::table('jobs')->count())->toBe(0); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + expect($expired->fresh()->grandfathering_notified_at?->toDateTimeString())->toBe('2026-09-18 04:15:00') + ->and(DB::table('jobs')->count())->toBe(1); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + expect(DB::table('jobs')->count())->toBe(1); + + Carbon::setTestNow(); +}); diff --git a/tests/Feature/CanonicalAssignmentTest.php b/tests/Feature/CanonicalAssignmentTest.php index 80350268..5b8f79bc 100644 --- a/tests/Feature/CanonicalAssignmentTest.php +++ b/tests/Feature/CanonicalAssignmentTest.php @@ -225,3 +225,65 @@ test('portal and auth layouts carry noindex and the help page explains the singl $this->get('https://presseecho.test/de/gute-pressemitteilung')->assertSuccessful() ->assertSee('verweist bei uns die zweite Fassung auf die erste'); }); + +test('copies are re-evaluated when their source is depublished, republished, gone or deleted', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + [$pe, $b24] = duplicatePair('it'); + $assigner = app(CanonicalAssigner::class); + $service = app(PressReleaseService::class); + $assigner->assignFor($pe); + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id); + + // Erstquelle depubliziert: die Kopie wird selbst Erstquelle — Self-Canonical UND Sitemap. + $service->depublish($b24->fresh()); + + expect($pe->fresh()->canonical_press_release_id)->toBeNull() + ->and($assigner->isCanonicalOn($pe->fresh(), Portal::Presseecho))->toBeTrue(); + $this->get('https://presseecho.test/de/press-release/doppel-standort')->assertSuccessful() + ->assertSee('', false); + $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful() + ->assertSee('https://presseecho.test/de/press-release/doppel-standort', false); + + // Erstquelle per Admin-Statuswechsel wieder veröffentlicht: Paar erneut zugeordnet. + $service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Published); + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id); + + // Admin nimmt die Erstquelle zurück: Kopie wieder frei. + $service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Draft, 'Zurückgezogen'); + expect($pe->fresh()->canonical_press_release_id)->toBeNull(); + + $service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Published); + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id); + + // 410 Gone. + $service->markGone($b24->fresh()); + expect($pe->fresh()->canonical_press_release_id)->toBeNull(); + + $service->restoreFromGone($b24->fresh()); + $service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Published); + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id); + + // Entfernung durch den Admin. + $service->deleteFromAdmin($b24->fresh()); + expect($pe->fresh()->canonical_press_release_id)->toBeNull(); +}); + +test('a soft-deleted source is no canonical target even while the copy still points at it', function () { + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + [$pe, $b24] = duplicatePair('it'); + app(CanonicalAssigner::class)->assignFor($pe); + + // Direkt gelöscht, ohne Service-Hook — der Verweis bleibt als Leiche stehen. + $b24->delete(); + $pe = $pe->fresh(); + + expect($pe->canonical_press_release_id)->toBe($b24->id) + ->and(app(CanonicalAssigner::class)->canonicalUrlFor($pe, Portal::Presseecho)) + ->toBe('https://presseecho.test/de/press-release/doppel-standort'); +}); diff --git a/tests/Feature/ConversionBaselineReportsTest.php b/tests/Feature/ConversionBaselineReportsTest.php index c5ae69d1..df2e87a1 100644 --- a/tests/Feature/ConversionBaselineReportsTest.php +++ b/tests/Feature/ConversionBaselineReportsTest.php @@ -7,6 +7,7 @@ use App\Models\PressReleaseStatusLog; use App\Models\User; use App\Services\PressRelease\RejectionRateReport; use App\Services\PressRelease\ViewDistributionReport; +use Illuminate\Support\Carbon; use Tests\TestCase; /** @@ -256,3 +257,35 @@ test('the rejection rate command reports an empty window plainly', function () { ->expectsOutputToContain('Keine Einreichungen im Zeitraum.') ->assertSuccessful(); }); + +test('the age cohorts join seamlessly and every release belongs to exactly one', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-09-21 12:00:00'); + + // Alter in Stunden: unmittelbar vor, genau auf und mitten in jeder Grenze. + // Alter [0,7) → „0–6", [7,14) → „7–13" usw.; genau 7 Tage alt gehört zu „7–13". + $ages = [167, 168, 335, 336, 671, 672, 1343, 1344, 2183, 2184]; + + foreach ($ages as $hours) { + baselineRelease(Portal::Presseecho, $hours, ['published_at' => now()->subHours($hours)]); + } + + $data = app(ViewDistributionReport::class)->build(now()->subDays(120)); + $portal = collect($data['portals'])->firstWhere('portal', 'presseecho'); + $cohorts = collect($portal['cohorts'])->keyBy('alter'); + $sinceRelaunch = collect($portal['segments'])->firstWhere('segment', 'seit Relaunch')['stats']['anzahl']; + + expect($cohorts->pluck('anzahl', 'alter')->all())->toBe([ + '0–6 Tage' => 1, + '7–13 Tage' => 2, + '14–20 Tage' => 1, + '21–27 Tage' => 1, + '28–55 Tage' => 2, + '56–90 Tage' => 2, + '91+ Tage' => 1, + ]) + ->and($cohorts['7–13 Tage']['median'])->toBe(168) + ->and($cohorts['28–55 Tage']['median'])->toBe(672) + ->and($cohorts->sum('anzahl'))->toBe($sinceRelaunch) + ->and($sinceRelaunch)->toBe(count($ages)); +}); diff --git a/tests/Feature/Images/ImageLifecycleTest.php b/tests/Feature/Images/ImageLifecycleTest.php index d0c5cc05..49be57a3 100644 --- a/tests/Feature/Images/ImageLifecycleTest.php +++ b/tests/Feature/Images/ImageLifecycleTest.php @@ -7,6 +7,7 @@ use App\Enums\ConfirmationChannel; use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\ActivityEvent; +use App\Models\ArchiveEntry; use App\Models\Category; use App\Models\Company; use App\Models\ImageDeclarationCorrection; @@ -18,12 +19,14 @@ use App\Models\User; use App\Services\Admin\AdminSystemHealth; use App\Services\PressRelease\ImageRightsConfirmationRequiredException; use App\Services\PressRelease\Images\ImageCorrectionService; +use App\Services\PressRelease\Images\PressReleaseImageRemover; use App\Services\PressRelease\PressReleaseService; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Livewire\Volt\Volt as LivewireVolt; +use Tests\Support\FailingDisk; use Tests\TestCase; /** @@ -375,3 +378,119 @@ test('the system health warns about failed provenance checks of the last seven d expect($checks->get('image_provenance')['status'])->toBe('warn') ->and($checks->get('image_provenance')['detail'])->toContain('1 Bild-Upload'); }); + +test('the media route applies the shared delivery rule: legacy stays public, withheld and unconfirmed v2 images do not', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'author' => $author] = lifecycleRelease('published'); + $pr->forceFill(['published_at' => now()->subDay(), 'published_ever_at' => now()->subDay()])->save(); + $admin = lifecycleAdmin(); + + $legacy = PressReleaseImage::factory()->for($pr)->legacy()->create(['disk' => 'local', 'path' => 'presseecho/press/legacy.jpg']); + Storage::disk('local')->put($legacy->path, 'legacy-bytes'); + $withheld = lifecycleImage($pr, attributes: ['withheld_at' => now()]); + $unconfirmed = lifecycleImage($pr, confirmed: false, attributes: ['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]); + $confirmed = lifecycleImage($pr); + + $url = fn (PressReleaseImage $image): string => route('media.press-release-images.show', ['image' => $image->id]); + + expect($legacy->isPubliclyDeliverable())->toBeTrue() + ->and($withheld->isPubliclyDeliverable())->toBeFalse() + ->and($unconfirmed->isPubliclyDeliverable())->toBeFalse() + ->and($confirmed->isPubliclyDeliverable())->toBeTrue() + ->and(PressReleaseImage::query()->publiclyVisible()->pluck('id')->all())->toEqualCanonicalizing([$legacy->id, $confirmed->id]); + + $this->get($url($legacy))->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); + $this->get($url($confirmed))->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); + $this->get($url($withheld))->assertNotFound(); + $this->get($url($unconfirmed))->assertNotFound(); + + // Berechtigte interne Ansicht bleibt, aber ohne öffentlichen Cache. + $this->actingAs($author)->get($url($withheld))->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); + $this->actingAs($admin)->get($url($unconfirmed))->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); + + app(ImageCorrectionService::class)->release($withheld, $admin, 'Einwand geprüft'); + auth()->logout(); + + $this->get($url($withheld))->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); +}); + +// --------------------------------------------------------------------------- +// Umsetzungsaudit 25.09.2026: N13, 5.1 b, 5.1 e +// --------------------------------------------------------------------------- + +test('the draft purge also cleans media of stale rejected submissions that were never published and keeps their status', function () { + /** @var TestCase $this */ + ['pr' => $stale] = lifecycleRelease('rejected'); + $staleImage = lifecycleImage($stale); + DB::table('press_releases')->where('id', $stale->id)->update(['updated_at' => now()->subDays(200)]); + + ['pr' => $fresh] = lifecycleRelease('rejected'); + $freshImage = lifecycleImage($fresh); + + // Altbestand: im Altsystem „offline genommen", war einmal veröffentlicht. + ['pr' => $formerlyPublished] = lifecycleRelease('rejected'); + $formerlyPublished->forceFill(['published_ever_at' => now()->subYear()])->save(); + $keptImage = lifecycleImage($formerlyPublished, true, ['published_ever_at' => now()->subYear()]); + DB::table('press_releases')->where('id', $formerlyPublished->id)->update(['updated_at' => now()->subDays(400)]); + + $this->artisan('press-releases:purge-drafts', ['--days' => 180, '--dry-run' => true]) + ->expectsOutputToContain('1 abgelehnte Einreichungen würden bereinigt') + ->assertSuccessful(); + expect(PressReleaseImage::query()->whereKey($staleImage->id)->exists())->toBeTrue(); + + $this->artisan('press-releases:purge-drafts', ['--days' => 180]) + ->expectsOutputToContain('abgelehnte Einreichungen bereinigt: 1, Bilder gelöscht: 1') + ->assertSuccessful(); + + expect(PressReleaseImage::withTrashed()->whereKey($staleImage->id)->exists())->toBeFalse() + ->and(Storage::disk('local')->exists($staleImage->original_path))->toBeFalse() + ->and(ImageRightsConfirmation::query()->where('subject_id', $staleImage->id)->count())->toBe(0) + ->and($stale->fresh()->status)->toBe(PressReleaseStatus::Rejected) + ->and(PressReleaseImage::query()->whereKey($freshImage->id)->exists())->toBeTrue() + ->and(PressReleaseImage::query()->whereKey($keptImage->id)->exists())->toBeTrue() + ->and(Storage::disk('local')->exists($keptImage->original_path))->toBeTrue(); + + // Wiederholungsfest: ohne Bilder zählt die abgelehnte Meldung nicht mehr. + $this->artisan('press-releases:purge-drafts', ['--days' => 180]) + ->expectsOutputToContain('abgelehnte Einreichungen bereinigt: 0, Bilder gelöscht: 0') + ->assertSuccessful(); +}); + +test('a correction of a never published image is logged but not archived', function () { + Storage::fake('archive'); + config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32))); + ['pr' => $pr] = lifecycleRelease('draft'); + $image = lifecycleImage($pr); + + app(ImageCorrectionService::class)->correctCredit($image, lifecycleAdmin(), 'Neuer Nachweis', 'Tippfehler'); + + expect(ImageDeclarationCorrection::query()->where('press_release_image_id', $image->id)->count())->toBe(1) + ->and(ArchiveEntry::query()->count())->toBe(0); +}); + +test('the immediate deletion keeps the image row when a local file cannot be deleted, and the health check counts leftovers', function () { + ['pr' => $pr] = lifecycleRelease('draft'); + $image = lifecycleImage($pr); + + FailingDisk::install('local', ['delete' => fn () => false]); + + expect(fn () => app(PressReleaseImageRemover::class)->remove($image)) + ->toThrow(RuntimeException::class); + + FailingDisk::restore('local'); + + expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue() + ->and(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(1) + ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue(); + + // Entferntes v2-Bild mit noch vorhandenem Original: Systemstatus warnt. + $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); + expect($checks->get('image_evidence_leftovers')['status'])->toBe('ok'); + + $image->forceFill(['published_ever_at' => now()])->save(); + $image->delete(); + + $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); + expect($checks->get('image_evidence_leftovers')['status'])->toBe('warn') + ->and($checks->get('image_evidence_leftovers')['detail'])->toContain('1 entfernte(s) Bild(er)'); +}); diff --git a/tests/Feature/Images/ImagePrecheckTest.php b/tests/Feature/Images/ImagePrecheckTest.php index 0431b65c..b6191a47 100644 --- a/tests/Feature/Images/ImagePrecheckTest.php +++ b/tests/Feature/Images/ImagePrecheckTest.php @@ -494,3 +494,50 @@ test('the api answers 422 with the reason on rejection and flags queued images', ->assertCreated() ->assertJsonPath('data.warnings', fn (array $w): bool => in_array('image_under_review', $w, true)); }); + +test('a formally unusable vision answer becomes an error result, never a pass (N20)', function (array $patch) { + $categories = json_decode((string) precheckResponseBody()['choices'][0]['message']['content'], true); + + if (array_key_exists('*', $patch)) { + $categories = array_fill_keys(array_keys($categories), $patch['*']); + } else { + $categories = array_replace($categories, $patch); + } + + Http::fake(['*' => Http::response(['choices' => [['message' => ['content' => json_encode($categories)]]]], 200)]); + $upload = precheckUpload(); + + $service = app(ImagePrecheckService::class); + $result = $service->run($upload->getRealPath(), 'sha'); + + expect($result)->not->toBeNull() + ->and($result->isError())->toBeTrue() + ->and($result->errorMessage)->toContain('Schema') + ->and($result->categories)->toBe([]) + ->and($service->evaluate($result, null)->outcome)->toBe(ImagePrecheckOutcome::Error); +})->with([ + 'alle Kategorien null' => [['*' => null]], + 'alle Kategorien leere Objekte' => [['*' => []]], + 'hit als String "false"' => [['stock_watermark' => ['hit' => 'false', 'confidence' => 0.95]]], + 'hit fehlt' => [['nudity_or_violence' => ['confidence' => 0.1]]], + 'Konfidenz als Text' => [['document_or_id' => ['hit' => true, 'confidence' => 'hoch']]], + 'Konfidenz über 1' => [['stock_watermark' => ['hit' => true, 'confidence' => 1.7]]], + 'Konfidenz unter 0' => [['persons_visible' => ['hit' => false, 'confidence' => -0.1]]], + 'Notiz als Objekt' => [['technical_defect' => ['hit' => true, 'confidence' => 0.9, 'note' => ['x']]]], +]); + +test('a stored precheck result counts only a real true as a hit (N20)', function () { + $result = ImagePrecheckResult::fromArray([ + 'status' => 'ok', + 'provider' => 'openai', + 'checked_at' => now()->toIso8601String(), + 'categories' => [ + 'stock_watermark' => ['hit' => 'false', 'confidence' => 0.95], + 'persons_visible' => ['hit' => true, 'confidence' => 0.8], + ], + ]); + + expect($result)->not->toBeNull() + ->and($result->confidence(ImagePrecheckCategory::StockWatermark))->toBe(0.0) + ->and($result->confidence(ImagePrecheckCategory::PersonsVisible))->toBe(0.8); +}); diff --git a/tests/Feature/Images/PressReleaseImageRightsV2Test.php b/tests/Feature/Images/PressReleaseImageRightsV2Test.php index 6b86460b..8d308f18 100644 --- a/tests/Feature/Images/PressReleaseImageRightsV2Test.php +++ b/tests/Feature/Images/PressReleaseImageRightsV2Test.php @@ -2,13 +2,19 @@ use App\Enums\AiImageDeclaration; use App\Enums\ConfirmationChannel; +use App\Enums\Portal; use App\Mail\PressReleaseImageConfirmationRequested; +use App\Models\ArchiveEntry; use App\Models\Category; use App\Models\Company; use App\Models\ImageRightsConfirmation; +use App\Models\LegalDocument; +use App\Models\LegalText; use App\Models\PressRelease; use App\Models\PressReleaseImage; use App\Models\User; +use App\Services\PressRelease\Images\CustomerImageConfirmation; +use App\Services\PressRelease\Images\ImageUploadException; use Database\Seeders\LegalDocumentSeeder; use Database\Seeders\LegalTextSeeder; use Database\Seeders\RolesAndPermissionsSeeder; @@ -59,6 +65,56 @@ function rightsV2Fixture(string $name): UploadedFile return UploadedFile::fake()->createWithContent($name, (string) file_get_contents(base_path('tests/Fixtures/images/'.$name))); } +/** + * Veröffentlichte PM mit öffentlicher Detailseite auf presseecho. + * + * @return array{owner: User, pr: PressRelease, company: Company} + */ +function makeRightsV2Published(): array +{ + $owner = User::factory()->create(['is_active' => true]); + $owner->assignRole('customer'); + + $category = Category::factory()->create(['portal' => Portal::Presseecho]); + $category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]); + $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]); + $owner->companies()->attach($company->id, ['role' => 'owner']); + + $pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([ + 'user_id' => $owner->id, + 'slug' => 'rights-v2-'.fake()->unique()->numberBetween(1, 99999), + 'published_at' => now()->subDay(), + 'published_ever_at' => now()->subDay(), + ]); + + return compact('owner', 'pr', 'company'); +} + +/** + * Simuliert Deploy 3: neue AGB-Fassung ab heute samt daran gebundenem + * Bestätigungstext. LegalText::currentFor wählt den an die aktuelle + * AGB-Fassung gebundenen Text, bisher gilt img-confirm-v2 (AGB 0.6). + */ +function activateRightsTextV4(): void +{ + LegalDocument::factory()->agb()->create(['version' => '0.9-test', 'effective_from' => today()]); + + $body = 'Neue Fassung der Bestätigung der Bildrechte (AGB {agb_version}).'; + + LegalText::query()->create([ + 'key' => 'img-confirm-v4', + 'type' => LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, + 'locale' => 'de', + 'title' => 'Bestätigung der Bildrechte', + 'body' => $body, + 'short_body' => 'Ich bestätige die Rechte am Bild (AGB {agb_version}).', + 'bound_agb_version' => '0.9-test', + 'body_sha256' => hash('sha256', $body), + 'effective_from' => today(), + 'note' => 'Testfassung', + ]); +} + test('the edit page embeds the v2 manager only while the flag is on', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); @@ -270,8 +326,10 @@ test('removing a never published image deletes original, derivatives and confirm ->and(Storage::disk('local')->allFiles())->toBe([]); }); -test('removing a once published image keeps the original and the confirmation and only soft deletes', function () { +test('removing a once published image moves original and confirmation into the evidence archive and only soft deletes', function () { /** @var TestCase $this */ + Storage::fake('archive'); + config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32))); ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); @@ -291,7 +349,166 @@ test('removing a once published image keeps the original and the confirmation an LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('remove', $image->id); - expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue() - ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() + $trashed = PressReleaseImage::withTrashed()->whereKey($image->id)->sole(); + + // Neue Semantik (Umsetzungsaudit 25.09.2026): Original und Bestätigung + // liegen im Beweisarchiv, die lokale Beweisdatei ist weg, der Pfad an + // der Zeile geleert (Bestätigungszeilen bleiben bis zum Fristende — + // siehe EvidenceArchiveTest). + expect($trashed->trashed())->toBeTrue() + ->and(ArchiveEntry::query()->where('event', 'image_removed')->where('subject_id', $image->id)->exists())->toBeTrue() + ->and(Storage::disk('local')->exists($image->original_path))->toBeFalse() + ->and($trashed->original_path)->toBeNull() ->and(Storage::disk('local')->exists($image->path))->toBeFalse(); }); + +test('an admin upload on a published press release stays hidden on page, og and media route until the owner confirms it', function () { + /** @var TestCase $this */ + Mail::fake(); + ['pr' => $pr, 'owner' => $owner] = makeRightsV2Published(); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $this->actingAs($admin); + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newCopyright', 'Beispiel AG') + ->set('newAiDeclaration', AiImageDeclaration::Original->value) + ->call('saveImage') + ->assertHasNoErrors(); + + $image = $pr->images()->sole(); + $mediaUrl = route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'cover']); + $pageUrl = 'https://presseecho.test/de/press-release/'.$pr->slug; + + expect($image->isPubliclyDeliverable())->toBeFalse() + ->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse(); + + // Berechtigte interne Ansicht bleibt, aber ohne öffentlichen Cache. + $this->get($mediaUrl)->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); + + auth()->logout(); + $this->get($pageUrl)->assertSuccessful()->assertDontSee('media/press-release-images/'.$image->id, false); + $this->get($mediaUrl)->assertNotFound(); + + $this->actingAs($owner); + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Titelbild bestätigen') + ->set('imageRightsConfirmed', true) + ->call('confirmImageRights') + ->assertHasNoErrors(); + + $image = $image->fresh(); + + expect($image->rights_confirmed_at)->not->toBeNull() + ->and($image->isPubliclyDeliverable())->toBeTrue() + ->and($image->published_snapshot_path)->not->toBeNull() + ->and($image->rightsConfirmations()->sole()->channel)->toBe(ConfirmationChannel::MagicLink); + + auth()->logout(); + $this->get($pageUrl) + ->assertSuccessful() + ->assertSee('', false); + $this->get($mediaUrl)->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); +}); + +test('only the account owner may confirm an admin upload: team member, super admin and a repeat are refused', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'owner' => $owner, 'company' => $company] = makeRightsV2Draft(); + $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ + 'rights_confirmed_at' => null, + 'customer_confirmation_requested_at' => now(), + ]); + + $member = User::factory()->create(['is_active' => true]); + $member->assignRole('customer'); + $member->companies()->attach($company->id, ['role' => 'member']); + $superAdmin = User::factory()->superAdmin()->create(['is_active' => true]); + + expect($member->can('update', $pr))->toBeTrue() + ->and($member->can('confirmImageRights', $pr))->toBeFalse() + ->and($superAdmin->can('update', $pr))->toBeTrue() + ->and($superAdmin->can('confirmImageRights', $pr))->toBeFalse() + ->and($owner->can('confirmImageRights', $pr))->toBeTrue(); + + $this->actingAs($member); + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertDontSee('Titelbild bestätigen') + ->set('imageRightsConfirmed', true) + ->call('confirmImageRights') + ->assertForbidden(); + + $confirm = app(CustomerImageConfirmation::class); + + // Der Service prüft die Identität unabhängig von der Policy. + expect(fn () => $confirm->confirm($image, $member, 'img-confirm-v2', null, null)) + ->toThrow(ImageUploadException::class, 'Kontoinhaber'); + + expect(ImageRightsConfirmation::query()->count())->toBe(0); + + $confirm->confirm($image->fresh(), $owner, 'img-confirm-v2', null, null); + + expect(fn () => $confirm->confirm($image->fresh(), $owner, 'img-confirm-v2', null, null)) + ->toThrow(ImageUploadException::class, 'keine Bestätigung aus'); + + expect(ImageRightsConfirmation::query()->count())->toBe(1); +}); + +test('a confirmation text replaced between render and save must be read and confirmed again in the manager', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); + $this->actingAs($owner); + + $component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->assertSet('legalTextKey', 'img-confirm-v2') + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newCopyright', 'Beispiel AG') + ->set('newAiDeclaration', AiImageDeclaration::Original->value) + ->set('newRightsConfirmed', true); + + activateRightsTextV4(); + + $component->call('saveImage') + ->assertHasErrors(['newRightsConfirmed']) + ->assertSet('newRightsConfirmed', false) + ->assertSet('legalTextKey', 'img-confirm-v4'); + + expect($pr->images()->count())->toBe(0); + + $component->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasNoErrors(); + + expect($pr->images()->sole()->rightsConfirmations()->sole()->legal_text_key)->toBe('img-confirm-v4'); +}); + +test('the confirmation on the detail page is bound to the text version shown', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); + $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ + 'rights_confirmed_at' => null, + 'customer_confirmation_requested_at' => now(), + ]); + $this->actingAs($owner); + + $component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSet('imageLegalTextKey', 'img-confirm-v2') + ->set('imageRightsConfirmed', true); + + activateRightsTextV4(); + + $component->call('confirmImageRights') + ->assertHasErrors(['imageRightsConfirmed']) + ->assertSet('imageRightsConfirmed', false) + ->assertSet('imageLegalTextKey', 'img-confirm-v4'); + + expect(ImageRightsConfirmation::query()->count())->toBe(0); + + $component->set('imageRightsConfirmed', true) + ->call('confirmImageRights') + ->assertHasNoErrors(); + + expect($image->rightsConfirmations()->sole()->legal_text_key)->toBe('img-confirm-v4'); +}); diff --git a/tests/Feature/Images/PressReleaseImageUploaderTest.php b/tests/Feature/Images/PressReleaseImageUploaderTest.php index 5287334b..46e5a41b 100644 --- a/tests/Feature/Images/PressReleaseImageUploaderTest.php +++ b/tests/Feature/Images/PressReleaseImageUploaderTest.php @@ -3,9 +3,11 @@ use App\Enums\AiDeclarationSource; use App\Enums\AiImageDeclaration; use App\Enums\ImageProvenanceStatus; +use App\Enums\PressReleaseStatus; use App\Models\ImageRightsConfirmation; use App\Models\PressRelease; use App\Models\User; +use App\Services\Image\ImageProvenanceInspector; use App\Services\PressRelease\Images\ImageSubmission; use App\Services\PressRelease\Images\ImageUploadActor; use App\Services\PressRelease\Images\ImageUploadException; @@ -13,8 +15,11 @@ use App\Services\PressRelease\Images\PressReleaseImageUploader; use Database\Seeders\LegalDocumentSeeder; use Database\Seeders\LegalTextSeeder; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Storage; +use Mockery\MockInterface; use Tests\TestCase; beforeEach(function (): void { @@ -255,3 +260,59 @@ test('a failed derivative generation never leaves the source file public', funct expect(Storage::disk('local')->allFiles())->toBe([]); }); + +test('a status change during the image check is caught under the lock and leaves no files behind', function () { + /** @var TestCase $this */ + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Draft->value]); + $real = app(ImageProvenanceInspector::class); + + // Die PM wird eingereicht und veröffentlicht, während die Herkunftsprüfung läuft. + $this->instance(ImageProvenanceInspector::class, Mockery::mock(ImageProvenanceInspector::class, function (MockInterface $mock) use ($real, $pressRelease): void { + $mock->shouldReceive('inspect')->andReturnUsing(function (string $path) use ($real, $pressRelease) { + DB::table('press_releases')->where('id', $pressRelease->id)->update([ + 'status' => PressReleaseStatus::Published->value, + 'published_at' => now(), + ]); + + return $real->inspect($path); + }); + })); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], + ))->toThrow(ImageUploadException::class, 'Status der Meldung'); + + expect($pressRelease->images()->count())->toBe(0) + ->and(Storage::disk('local')->allFiles())->toBe([]) + ->and(ImageRightsConfirmation::query()->count())->toBe(0); +}); + +test('a cache failure after the commit is reported but keeps the stored image and its files', function () { + /** @var TestCase $this */ + Exceptions::fake(); + $pressRelease = PressRelease::factory()->published()->create(); + + // Proxy auf den echten CacheManager: nur die Generations-Erhöhung des + // Seiten-Caches (WebPageCache::flush) schlägt fehl, alles andere läuft. + $cache = Mockery::mock(Cache::getFacadeRoot())->makePartial(); + $cache->shouldReceive('increment')->andThrow(new RuntimeException('cache down')); + Cache::swap($cache); + + $image = app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + ); + + Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'cache down'); + + expect($pressRelease->images()->count())->toBe(1) + ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() + ->and(Storage::disk('local')->exists($image->path))->toBeTrue() + ->and(ImageRightsConfirmation::query()->count())->toBe(1); +}); diff --git a/tests/Feature/IndexStatusCheckTest.php b/tests/Feature/IndexStatusCheckTest.php index 65dbc979..64e1c893 100644 --- a/tests/Feature/IndexStatusCheckTest.php +++ b/tests/Feature/IndexStatusCheckTest.php @@ -206,3 +206,27 @@ test('the admin report splits the distribution by origin and lists the latest ch ->assertSeeText('Abrufe der RSS-Feeds') ->assertSeeText('Kundenansicht'); }); + +test('the distribution counts states over the same population as the total, one check per release', function () { + /** @var TestCase $this */ + $checked = publishedOn(Portal::Presseecho, 10); + $depublished = publishedOn(Portal::Presseecho, 12); + $at = now()->subDay()->startOfSecond(); + + // Zwei Prüfungen in derselben Sekunde: nur die jüngere (höhere ID) zählt. + PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $checked->id, 'portal' => Portal::Presseecho, 'checked_at' => $at]); + PressReleaseIndexCheck::factory()->withState(IndexState::NotCrawled)->create(['press_release_id' => $checked->id, 'portal' => Portal::Presseecho, 'checked_at' => $at]); + + // Geprüft, danach depubliziert: weder im Nenner noch als „indexed". + PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $depublished->id, 'portal' => Portal::Presseecho, 'checked_at' => $at]); + $depublished->update(['status' => 'archived']); + + $bucket = app(IndexStatusReport::class)->distribution()['presseecho']['direct']['source']; + $states = collect(IndexState::cases())->sum(fn (IndexState $state): int => $bucket[$state->value]); + + expect($bucket['total'])->toBe(1) + ->and($bucket['indexed'])->toBe(0) + ->and($bucket['not_crawled'])->toBe(1) + ->and($bucket['unchecked'])->toBe(0) + ->and($states + $bucket['unchecked'])->toBe($bucket['total']); +}); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php index 30e497db..9c69bf2d 100644 --- a/tests/Feature/PressReleaseClassificationJobTest.php +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -546,3 +546,142 @@ test('a green re-check without routing leaves the status untouched (Audit U03)', Mail::assertNothingQueued(); }); + +test('a classification is only stored together with its audit — a failed audit write rolls the result back (Audit N03)', function () { + /** @var TestCase $this */ + Queue::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + // Fällige Termin-PM: genau die Kandidatin, die der Scheduler zieht. + $pressRelease = PressRelease::factory()->create([ + 'title' => 'Sauber', + 'text' => 'Inhalt', + 'status' => PressReleaseStatus::Review->value, + 'scheduled_at' => now()->subMinute(), + ]); + + // Zwischen Ergebnis-UPDATE und Audit-INSERT darf kein Zustand sichtbar + // werden, in dem eine grüne PM ohne Nachweis dasteht — der Scheduler + // behandelte sie sonst wie auditlosen Altbestand. + $auditAttempted = false; + DB::beforeExecuting(function (string $query) use (&$auditAttempted): void { + if (str_starts_with($query, 'insert') && str_contains($query, 'ki_audits')) { + $auditAttempted = true; + + throw new RuntimeException('Audit-Tabelle nicht erreichbar'); + } + }); + + expect(fn () => (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ))->toThrow(RuntimeException::class); + + expect($auditAttempted)->toBeTrue(); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBeNull() + ->and($fresh->classified_at)->toBeNull() + ->and($fresh->status)->toBe(PressReleaseStatus::Review) + ->and(KiAudit::where('press_release_id', $pressRelease->id)->count())->toBe(0); + + $this->artisan('press-releases:publish-scheduled')->assertSuccessful(); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); +}); + +test('a red re-check whose content was corrected before the reject lock does not reject and re-checks the current version (Audit N04)', function () { + Mail::fake(); + Queue::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['gesperrt']); + + $author = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $author->id, + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'classified_at' => now()->subHour(), + 'scheduled_at' => now()->addWeek(), + 'title' => 'Sauber', + 'text' => 'Dieser Inhalt ist jetzt gesperrt.', + ]); + $redHash = $pressRelease->content_hash; + $correctedHash = str_repeat('e', 64); + + // Der Editor korrigiert exakt zwischen dem Status-Refresh des Jobs (erster + // SELECT nach dem Audit) und der Zeilensperre in reject(). + $auditWritten = false; + $raced = false; + DB::listen(function ($query) use (&$auditWritten, &$raced, $pressRelease, $correctedHash): void { + if (str_starts_with($query->sql, 'insert') && str_contains($query->sql, 'ki_audits')) { + $auditWritten = true; + + return; + } + + if ($auditWritten && ! $raced && str_starts_with($query->sql, 'select') && str_contains($query->sql, 'press_releases')) { + $raced = true; + DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Sauberer Inhalt.', 'content_hash' => $correctedHash]); + } + }); + + (new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $redHash))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($raced)->toBeTrue(); + + $fresh = $pressRelease->fresh(); + expect($fresh->status)->toBe(PressReleaseStatus::Review) + ->and($fresh->content_hash)->toBe($correctedHash); + + Mail::assertNothingQueued(); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->route === true + && $job->contentHash === $correctedHash); +}); + +test('a red re-check whose content changed right after the result was stored skips the rejection (Audit N04)', function () { + Mail::fake(); + Queue::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['gesperrt']); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'classified_at' => now()->subHour(), + 'scheduled_at' => now()->addWeek(), + 'title' => 'Sauber', + 'text' => 'Dieser Inhalt ist jetzt gesperrt.', + ]); + $redHash = $pressRelease->content_hash; + $correctedHash = str_repeat('f', 64); + + // Korrektur nach dem bedingten UPDATE, aber vor dem Audit — das Rot gilt + // für die alte Fassung; die Bearbeitung selbst plant den neuen Re-Check. + $raced = false; + DB::beforeExecuting(function (string $query) use (&$raced, $pressRelease, $correctedHash): void { + if ($raced || ! str_starts_with($query, 'insert') || ! str_contains($query, 'ki_audits')) { + return; + } + + $raced = true; + DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Sauberer Inhalt.', 'content_hash' => $correctedHash]); + }); + + (new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $redHash))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($raced)->toBeTrue() + ->and($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); + + Mail::assertNothingQueued(); + Queue::assertNotPushed(ClassifyPressRelease::class); +}); diff --git a/tests/Feature/PurgePressReleaseEventsTest.php b/tests/Feature/PurgePressReleaseEventsTest.php index a6c59cca..6cbcf953 100644 --- a/tests/Feature/PurgePressReleaseEventsTest.php +++ b/tests/Feature/PurgePressReleaseEventsTest.php @@ -3,8 +3,11 @@ use App\Console\Commands\AggregatePressReleaseViews; use App\Enums\PressReleaseEventType; use App\Models\AppSetting; +use App\Models\FeedFetch; use App\Models\PressRelease; +use App\Models\PressReleaseCrawlerHit; use App\Models\PressReleaseEvent; +use Illuminate\Support\Facades\Cache; use Tests\TestCase; /** @@ -78,11 +81,20 @@ test('the purge dry run counts without deleting', function () { AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $event->id); + // Der echte Lauf räumt auch die Abruf-Protokolle — der Probelauf nennt sie mit. + PressReleaseCrawlerHit::factory()->create(['press_release_id' => $release->id, 'created_at' => now()->subDays(400)]); + PressReleaseCrawlerHit::factory()->create(['press_release_id' => $release->id]); + FeedFetch::factory()->create(['created_at' => now()->subDays(400)]); + FeedFetch::factory()->create(); + $this->artisan('press-releases:purge-events', ['--dry-run' => true]) ->expectsOutputToContain('[Dry-Run] 1 Ereignisse') + ->expectsOutputToContain('[Dry-Run] 1 Einträge im Crawler-Abruf-Protokoll und 1 im Feed-Abruf-Protokoll') ->assertSuccessful(); - expect(PressReleaseEvent::query()->find($event->id))->not->toBeNull(); + expect(PressReleaseEvent::query()->find($event->id))->not->toBeNull() + ->and(PressReleaseCrawlerHit::query()->count())->toBe(2) + ->and(FeedFetch::query()->count())->toBe(2); }); test('the retention window is configurable', function () { @@ -96,3 +108,20 @@ test('the retention window is configurable', function () { expect(PressReleaseEvent::query()->find($event->id))->toBeNull(); }); + +test('the purge reads the watermark from the database, not from a stale cache (Audit N21)', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + + $old = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); + + AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $old->id); + // Veralteter Cachewert: mit ihm dürfte nichts gelöscht werden — und er + // darf vor allem nicht wieder in den Cache zurückwandern. + Cache::forever('app-setting.'.AggregatePressReleaseViews::WatermarkKey, 0); + + $this->artisan('press-releases:purge-events')->assertSuccessful(); + + expect(PressReleaseEvent::query()->find($old->id))->toBeNull() + ->and(Cache::get('app-setting.'.AggregatePressReleaseViews::WatermarkKey))->toBe(0); +}); diff --git a/tests/Feature/VisibilityStatsTest.php b/tests/Feature/VisibilityStatsTest.php index de6704f0..eef94922 100644 --- a/tests/Feature/VisibilityStatsTest.php +++ b/tests/Feature/VisibilityStatsTest.php @@ -200,3 +200,37 @@ test('a draft shows no visibility panel', function () { ->assertDontSeeText('Auslieferung') ->assertSeeText('noch nicht veröffentlicht'); }); + +test('the release summary reports the sitemap role: the source is listed, a copy is not', function () { + /** @var TestCase $this */ + [, $company] = claimant(); + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['handel' => 'presseecho']); + + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']); + + $source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay()]); + $copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay(), 'canonical_press_release_id' => $source->id]); + $both = PressRelease::factory()->published()->forPortal(Portal::Both)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay()]); + + $stats = app(VisibilityStats::class); + $sourceSummary = $stats->releaseSummary($source); + $copySummary = $stats->releaseSummary($copy); + $bothSummary = $stats->releaseSummary($both); + + expect($sourceSummary['sitemap_role'])->toBe('source') + ->and($sourceSummary['sitemap_portals'])->toBe([Portal::Businessportal24->label()]) + ->and($sourceSummary['in_sitemap_since'])->not->toBeNull() + // Die Kopie ist öffentlich, steht aber in keiner Sitemap (SitemapController, 1c.8). + ->and($copySummary['sitemap_role'])->toBe('copy') + ->and($copySummary['sitemap_portals'])->toBe([]) + ->and($copySummary['in_sitemap_since'])->toBeNull() + // portal=both: nur die Sitemap des Kategorie-Portals führt sie. + ->and($bothSummary['sitemap_role'])->toBe('source') + ->and($bothSummary['sitemap_portals'])->toBe([Portal::Presseecho->label()]); + + // Ohne Zuordnung ist jede Fassung Erstquelle. + config()->set('canonical.enabled', false); + expect($stats->releaseSummary($copy)['sitemap_role'])->toBe('source'); +}); diff --git a/tests/Feature/Web/FeedFetchLogTest.php b/tests/Feature/Web/FeedFetchLogTest.php index 7871efb1..e799570d 100644 --- a/tests/Feature/Web/FeedFetchLogTest.php +++ b/tests/Feature/Web/FeedFetchLogTest.php @@ -148,3 +148,17 @@ test('the purge command removes feed fetches older than the retention period', f expect(FeedFetch::query()->count())->toBe(1); }); + +test('the reader summary keeps feeds apart by portal and language', function () { + // Gleicher Reader, gleicher Schlüssel (Portalfeed) — auf zwei Portalen und + // in zwei Sprachen sind das drei Feeds mit drei Abonnentenzahlen. + FeedFetch::factory()->reader('feedly', 12)->count(2)->create(); + FeedFetch::factory()->reader('feedly', 20)->create(['portal' => 'businessportal24']); + FeedFetch::factory()->reader('feedly', 3)->create(['language' => 'en']); + + $feedly = collect(app(FeedFetchReport::class)->byReader(30))->firstWhere('reader', 'feedly'); + + expect($feedly['fetches'])->toBe(4) + ->and($feedly['feeds'])->toBe(3) + ->and($feedly['subscribers'])->toBe(35); +}); diff --git a/tests/Feature/Web/PressReleaseTrackingTest.php b/tests/Feature/Web/PressReleaseTrackingTest.php index 956f0f1d..9e649e36 100644 --- a/tests/Feature/Web/PressReleaseTrackingTest.php +++ b/tests/Feature/Web/PressReleaseTrackingTest.php @@ -309,3 +309,31 @@ test('a run whose watermark was moved by another run books nothing twice (Audit ->and(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0) ->and((int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'))->toBe($latest); }); + +test('a stale cached watermark neither stops the aggregation nor counts a view twice (Audit N21)', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 0]); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + $cacheKey = 'app-setting.'.AggregatePressReleaseViews::WatermarkKey; + + $this->get($url)->assertSuccessful(); + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + $booked = (int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1); + + // Ein Leser hat die alte Marke zwischen Cache-Forget und Commit zurück in + // den Cache geschrieben — früher scheiterte ab hier jeder Lauf dauerhaft. + Cache::forever($cacheKey, 0); + + $this->get($url)->assertSuccessful(); + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2) + ->and((int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'))->toBeGreaterThan($booked) + ->and(Cache::get($cacheKey))->toBeNull(); + + // Und der Folgelauf zählt nichts nach. + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2); +}); diff --git a/tests/Support/FailingDisk.php b/tests/Support/FailingDisk.php new file mode 100644 index 00000000..ebd11e79 --- /dev/null +++ b/tests/Support/FailingDisk.php @@ -0,0 +1,78 @@ + fn () => false]); + * Zurücksetzen: FailingDisk::restore('archive'). + */ +class FailingDisk extends FilesystemAdapter +{ + /** @var array */ + private static array $originals = []; + + /** + * @param array $hooks + */ + public function __construct(FilesystemAdapter $inner, private readonly array $hooks) + { + parent::__construct($inner->getDriver(), $inner->getAdapter(), $inner->getConfig()); + } + + /** + * @param array $hooks + */ + public static function install(string $disk, array $hooks): FilesystemAdapter + { + /** @var FilesystemAdapter $original */ + $original = self::$originals[$disk] ?? Storage::disk($disk); + self::$originals[$disk] = $original; + Storage::set($disk, new self($original, $hooks)); + + return $original; + } + + public static function restore(string $disk): void + { + if (isset(self::$originals[$disk])) { + Storage::set($disk, self::$originals[$disk]); + unset(self::$originals[$disk]); + } + } + + public function exists($path) + { + return $this->hook('exists', $path, fn () => parent::exists($path)); + } + + public function put($path, $contents, $options = []) + { + return $this->hook('put', $path, fn () => parent::put($path, $contents, $options)); + } + + public function delete($paths) + { + return $this->hook('delete', $paths, fn () => parent::delete($paths)); + } + + public function move($from, $to) + { + return $this->hook('move', $from, fn () => parent::move($from, $to)); + } + + private function hook(string $method, mixed $path, callable $real): mixed + { + if (isset($this->hooks[$method])) { + return ($this->hooks[$method])($path, $real); + } + + return $real(); + } +}