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>
113 lines
3.3 KiB
PHP
113 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Enums\ArchiveEvent;
|
|
use App\Enums\ArchiveReasonCategory;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
use Illuminate\Support\Carbon;
|
|
use LogicException;
|
|
|
|
/**
|
|
* Eintrag im Beweisarchiv (Bildkennzeichnung Block F). Append-only: Nur
|
|
* legal_hold-Felder, retain_until (bei Aufhebung) und der Tombstone dürfen
|
|
* sich ändern; alles andere wirft. Löschen von Zeilen ist ausgeschlossen —
|
|
* der Tombstone bleibt.
|
|
*/
|
|
class ArchiveEntry extends Model
|
|
{
|
|
public const UPDATED_AT = null;
|
|
|
|
/** @var list<string> */
|
|
private const MUTABLE = ['legal_hold', 'legal_hold_note', 'legal_hold_set_by', 'legal_hold_set_at', 'retain_until', 'deleted_at', 'payload_path'];
|
|
|
|
protected $fillable = [
|
|
'subject_type',
|
|
'subject_id',
|
|
'press_release_id',
|
|
'legal_request_id',
|
|
'event',
|
|
'actor_type',
|
|
'actor_id',
|
|
'reason_category',
|
|
'summary',
|
|
'payload_path',
|
|
'payload_hash',
|
|
'payload_bytes',
|
|
'retain_until',
|
|
'legal_hold',
|
|
'legal_hold_note',
|
|
'legal_hold_set_by',
|
|
'legal_hold_set_at',
|
|
'deleted_at',
|
|
'created_at',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'event' => ArchiveEvent::class,
|
|
'reason_category' => ArchiveReasonCategory::class,
|
|
'summary' => 'array',
|
|
'retain_until' => 'date',
|
|
'legal_hold' => 'boolean',
|
|
'legal_hold_set_at' => 'datetime',
|
|
'deleted_at' => 'datetime',
|
|
'created_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::updating(function (self $entry): void {
|
|
$illegal = array_diff(array_keys($entry->getDirty()), self::MUTABLE);
|
|
|
|
if ($illegal !== []) {
|
|
throw new LogicException('archive_entries ist append-only; geändert werden dürfen nur legal_hold, retain_until und der Tombstone (versucht: '.implode(', ', $illegal).').');
|
|
}
|
|
});
|
|
|
|
static::deleting(fn () => throw new LogicException('archive_entries werden nie gelöscht; der Tombstone bleibt.'));
|
|
}
|
|
|
|
public function isTombstoned(): bool
|
|
{
|
|
return $this->deleted_at !== null;
|
|
}
|
|
|
|
/**
|
|
* Frist: 31.12. des Bezugsjahres plus die konfigurierten Jahre.
|
|
*/
|
|
public static function retentionEndFor(\DateTimeInterface $reference): Carbon
|
|
{
|
|
return Carbon::instance($reference)->endOfYear()->addYears((int) config('archive.retention_years', 3))->startOfDay();
|
|
}
|
|
|
|
/**
|
|
* @param Builder<ArchiveEntry> $query
|
|
* @return Builder<ArchiveEntry>
|
|
*/
|
|
public function scopeDueForPurge(Builder $query): Builder
|
|
{
|
|
return $query->whereNull('deleted_at')->where('legal_hold', false)->whereDate('retain_until', '<', today());
|
|
}
|
|
|
|
/**
|
|
* @return MorphTo<Model, $this>
|
|
*/
|
|
public function subject(): MorphTo
|
|
{
|
|
return $this->morphTo();
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<ArchiveAccessLog, $this>
|
|
*/
|
|
public function accessLog(): HasMany
|
|
{
|
|
return $this->hasMany(ArchiveAccessLog::class, 'entry_id');
|
|
}
|
|
}
|