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

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

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

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

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

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

88 lines
3.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
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,
* 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
{
protected $signature = 'press-releases:purge-drafts
{--days=180 : Entwürfe älter als X Tage archivieren}
{--dry-run : Nur zählen, nichts ändern}';
protected $description = 'Archiviert inaktive PM-Entwürfe und bereinigt Medien abgelehnter Einreichungen (älter als X Tage).';
public function handle(): int
{
$days = (int) $this->option('days');
$isDryRun = $this->option('dry-run');
$cutoff = now()->subDays($days);
$drafts = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Draft->value)
->where('updated_at', '<', $cutoff);
// 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] {$draftCount} Entwürfe würden archiviert, {$rejectedCount} abgelehnte Einreichungen würden bereinigt. Kein tatsächlicher Vorgang.");
return self::SUCCESS;
}
// 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 = $this->removeImages((clone $drafts)->whereNull('published_ever_at'), $remover)
+ $this->removeImages(clone $rejected, $remover);
$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<PressRelease> $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);
$removed++;
}
}
});
return $removed;
}
}