|null $variants * @property string|null $title * @property string|null $description * @property string|null $copyright * @property string|null $author * @property ImageLicenseType|null $license_type * @property string|null $license_detail * @property string|null $license_url * @property string|null $source_url * @property bool $persons_consent * @property string|null $people_rights_status * @property string|null $property_rights_status * @property string|null $rights_notes * @property Carbon|null $rights_confirmed_at * @property bool $is_ai_generated * @property AiImageDeclaration|null $ai_declaration * @property AiImageDeclaration|null $ai_declaration_user * @property AiDeclarationSource|null $ai_declaration_source * @property bool $ai_declaration_locked * @property CreditSource|null $credit_source * @property ImageProvenanceStatus|null $provenance_status * @property array|null $provenance * @property Carbon|null $provenance_checked_at * @property string|null $provenance_tool_version * @property array|null $precheck * @property ImagePrecheckOutcome|null $precheck_outcome * @property Carbon|null $precheck_review_required_at * @property Carbon|null $precheck_reviewed_at * @property int|null $precheck_reviewed_by * @property string|null $precheck_review_note * @property string|null $original_disk * @property string|null $original_path * @property string|null $original_sha256 * @property string|null $published_snapshot_path * @property string|null $published_snapshot_sha256 * @property Carbon|null $published_ever_at * @property Carbon|null $customer_confirmation_requested_at * @property Carbon|null $withheld_at * @property Carbon|null $precheck_withheld_at * @property string|null $rules_version * @property int|null $focus_x * @property int|null $focus_y * @property bool $no_crop * @property bool $is_preview * @property int $sort_order * @property int|null $width * @property int|null $height * @property string|null $mime * @property string|null $legacy_portal * @property int|null $legacy_id * @property-read PressRelease|null $pressRelease * @property-read ImageRightsConfirmation|null $latestRightsConfirmation */ class PressReleaseImage extends Model { /** @use HasFactory */ use HasFactory; use SoftDeletes; protected $fillable = [ 'press_release_id', 'disk', 'path', 'variants', 'title', 'description', 'copyright', 'author', 'license_type', 'license_detail', 'license_url', 'source_url', 'persons_consent', 'people_rights_status', 'property_rights_status', 'rights_notes', 'rights_confirmed_at', 'is_ai_generated', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'ai_declaration_locked', 'credit_source', 'provenance_status', 'provenance', 'provenance_checked_at', 'provenance_tool_version', 'precheck', 'precheck_outcome', 'precheck_review_required_at', 'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'original_disk', 'original_path', 'original_sha256', 'published_snapshot_path', 'published_snapshot_sha256', 'published_ever_at', 'customer_confirmation_requested_at', 'withheld_at', 'precheck_withheld_at', 'rules_version', 'focus_x', 'focus_y', 'no_crop', 'is_preview', 'sort_order', 'width', 'height', 'mime', 'legacy_portal', 'legacy_id', ]; protected function casts(): array { return [ 'variants' => 'array', 'license_type' => ImageLicenseType::class, 'persons_consent' => 'boolean', 'rights_confirmed_at' => 'datetime', 'is_ai_generated' => 'boolean', 'ai_declaration' => AiImageDeclaration::class, 'ai_declaration_user' => AiImageDeclaration::class, 'ai_declaration_source' => AiDeclarationSource::class, 'ai_declaration_locked' => 'boolean', 'credit_source' => CreditSource::class, 'provenance_status' => ImageProvenanceStatus::class, 'provenance' => 'array', 'provenance_checked_at' => 'datetime', 'precheck' => 'array', 'precheck_outcome' => ImagePrecheckOutcome::class, 'precheck_review_required_at' => 'datetime', 'precheck_reviewed_at' => 'datetime', 'published_ever_at' => 'datetime', 'customer_confirmation_requested_at' => 'datetime', 'withheld_at' => 'datetime', 'precheck_withheld_at' => 'datetime', 'focus_x' => 'integer', 'focus_y' => 'integer', 'no_crop' => 'boolean', 'is_preview' => 'boolean', 'sort_order' => 'integer', 'width' => 'integer', 'height' => 'integer', 'deleted_at' => 'datetime', ]; } /** * @return BelongsTo */ public function pressRelease(): BelongsTo { return $this->belongsTo(PressRelease::class); } /** * @return MorphMany */ public function rightsConfirmations(): MorphMany { return $this->morphMany(ImageRightsConfirmation::class, 'subject'); } /** * @return MorphOne */ public function latestRightsConfirmation(): MorphOne { return $this->morphOne(ImageRightsConfirmation::class, 'subject')->latestOfMany('confirmed_at'); } /** * Bestand vor Formular v2: kein Beweisoriginal, kein Protokoll. Nach der * Legacy-Bereinigung (E.5) sind das nur noch Bilder veröffentlichter PMs. */ public function isLegacyRules(): bool { return $this->rules_version === null; } /** * Eine Bestätigung zählt nur, wenn sie zur aktuellen Datei gehört * (Feedback F09): Bildtausch verlangt eine neue Bestätigung. */ public function hasValidRightsConfirmation(): bool { $confirmation = $this->relationLoaded('latestRightsConfirmation') ? $this->latestRightsConfirmation : $this->latestRightsConfirmation()->first(); return $confirmation !== null && $this->original_sha256 !== null && hash_equals($this->original_sha256, (string) $confirmation->file_sha256); } /** * Admin-Upload, dessen Kundenbestätigung per Magic-Link noch aussteht * (Decision-Update E10). Blockiert die Veröffentlichung (E.1). */ public function awaitsCustomerConfirmation(): bool { return $this->customer_confirmation_requested_at !== null && ! $this->hasValidRightsConfirmation(); } public function wasEverPublished(): bool { return $this->published_ever_at !== null; } /** * Wird das Bild an einer veröffentlichten Meldung öffentlich * auslieferbar (Kundenbestätigung, Freigabe der Vorab-Sichtung oder der * Korrektur), gilt es ab diesem Moment als veröffentlicht (DU E5). Die * Marke gehört in die Transaktion der Freigabe, nicht in den * nachgelagerten Snapshot — sonst macht ein Schreibfehler dort aus einem * öffentlich gezeigten Bild ein „nie veröffentlichtes", das beim * Entfernen ohne Archiv sofort gelöscht würde (Umsetzungsaudit * 02.10.2026, O03). */ public function markPublishedIfNowPublic(): void { if ($this->published_ever_at !== null || ! $this->isPubliclyDeliverable()) { return; } $status = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($this->press_release_id) ->value('status'); if (($status instanceof PressReleaseStatus ? $status : PressReleaseStatus::tryFrom((string) $status)) !== PressReleaseStatus::Published) { return; } $this->forceFill(['published_ever_at' => now()])->save(); } /** * Ausgang `queue` des Vorab-Checks (Block K): Bild ist zurückgehalten, * bis ein Admin freigibt oder ablehnt. */ public function awaitsPrecheckReview(): bool { return $this->precheck_review_required_at !== null && $this->precheck_reviewed_at === null; } public function scopeAwaitingPrecheckReview(Builder $query): Builder { return $query->whereNotNull('precheck_review_required_at')->whereNull('precheck_reviewed_at'); } /** * @return BelongsTo */ public function precheckReviewer(): BelongsTo { return $this->belongsTo(User::class, 'precheck_reviewed_by'); } /** * Vorläufig zurückgehalten — aus irgendeinem Grund: Korrekturweg (E.6, * `withheld_at`) oder offene Vorab-Sichtung (K.4, `precheck_withheld_at`). * Öffentlich greift der Platzhalter, im Panel bleibt das Bild sichtbar. */ public function isWithheld(): bool { return $this->withheld_at !== null || $this->precheck_withheld_at !== null; } /** * Rückhalt aus dem Korrekturweg (E.6). Die beiden Gründe sind getrennt * (Umsetzungsaudit 29.09.2026, B-07): Die Freigabe der Vorab-Sichtung * hebt einen Rückhalt wegen einer Beschwerde nicht auf und umgekehrt. */ public function isWithheldByCorrection(): bool { return $this->withheld_at !== null; } public function isWithheldForPrecheck(): bool { return $this->precheck_withheld_at !== null; } /** * Gemeinsamer Freigabezustand für die öffentliche Auslieferung (Audit * 25.09. N01): kein Rückhalt (Korrektur oder Vorab-Sichtung, B-07) 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, Teaser, OG-Auswahl, Nachweis-PDF, Snapshot und * Medienroute wenden dieselbe Regel an; der Scope ist gleichlautend. */ public function isPubliclyDeliverable(): bool { return $this->withheld_at === null && $this->precheck_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') ->whereNull('precheck_withheld_at') ->where(fn (Builder $inner) => $inner ->whereNull('rules_version') ->orWhereNotNull('rights_confirmed_at')); } public function url(bool $signed = false): ?string { if (blank($this->path)) { return null; } return $this->resolveDiskUrl($this->path, signed: $signed); } public function variantUrl(string $key, bool $signed = false): ?string { $variantPath = $this->pathForVariant($key); if (! is_string($variantPath) || blank($variantPath)) { return null; } return $this->resolveDiskUrl($variantPath, $key, $signed); } public function pathForVariant(?string $key = null): ?string { if ($key === null || $key === 'original') { return $this->path; } $variants = $this->getAttribute('variants'); return is_array($variants) && is_string($variants[$key] ?? null) ? $variants[$key] : null; } /** * Löst die URL je nach Disk auf: public → direkt, sonst über die geschützte * Media-Route. `signed=true` erzeugt eine temporär signierte Route, damit * die anonyme Token-Vorschau (unveröffentlichte PM) die Bilder laden kann, * ohne die Media-Route generell zu öffnen. */ private function resolveDiskUrl(string $relativePath, ?string $variant = null, bool $signed = false): string { if ($this->disk === 'public') { return asset('storage/'.ltrim($relativePath, '/')); } $parameters = array_filter([ 'image' => $this->getKey(), 'variant' => $variant, ]); if ($signed) { return URL::temporarySignedRoute('media.press-release-images.show', now()->addHours(6), $parameters); } return route('media.press-release-images.show', $parameters); } }