|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 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 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', 'original_disk', 'original_path', 'original_sha256', 'published_snapshot_path', 'published_snapshot_sha256', 'published_ever_at', 'customer_confirmation_requested_at', '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', 'published_ever_at' => 'datetime', 'customer_confirmation_requested_at' => 'datetime', '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; } /** * Vorläufig zurückgehalten (Korrekturweg E.6): ö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. * * @param Builder $query * @return Builder */ public function scopePubliclyVisible(Builder $query): Builder { return $query->whereNull('withheld_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); } }