images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)

- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung)
- deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1)
- press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard)
- Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien
- ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite
- Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:53:49 +00:00
parent 9f44546082
commit da167b713b
24 changed files with 1202 additions and 12 deletions

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Bildkennzeichnung E.1: `published_ever_at` für den Bestand — jede
* Meldung, die heute veröffentlicht ist, ein `published_at` trägt oder
* laut Status-Log jemals auf `published` stand, war öffentlich. Ihre Bilder
* erben den Zeitpunkt. Idempotent, nur NULL-Zeilen werden gesetzt.
*/
class BackfillPublishedEver extends Command
{
protected $signature = 'press-releases:backfill-published-ever {--dry-run : Nur zählen}';
protected $description = 'Setzt published_ever_at für Meldungen und Bilder, die jemals öffentlich waren';
public function handle(): int
{
$candidates = PressRelease::withoutGlobalScopes()
->whereNull('published_ever_at')
->where(function ($query): void {
$query->whereNotNull('published_at')
->orWhere('status', PressReleaseStatus::Published->value)
->orWhereIn('id', PressReleaseStatusLog::query()->where('to_status', PressReleaseStatus::Published->value)->select('press_release_id'));
});
$count = $candidates->count();
$this->info("Meldungen ohne published_ever_at, die öffentlich waren: {$count}");
if ($this->option('dry-run') || $count === 0) {
return self::SUCCESS;
}
$updated = 0;
$candidates->select(['id', 'published_at', 'created_at'])->chunkById(1000, function ($releases) use (&$updated): void {
foreach ($releases as $release) {
$firstLog = PressReleaseStatusLog::query()
->where('press_release_id', $release->id)
->where('to_status', PressReleaseStatus::Published->value)
->min('created_at');
$at = collect([$release->published_at, $firstLog, $release->created_at])->filter()->min();
DB::table('press_releases')->where('id', $release->id)->update(['published_ever_at' => $at]);
DB::table('press_release_images')->where('press_release_id', $release->id)->whereNull('published_ever_at')
->update(['published_ever_at' => DB::raw('CASE WHEN created_at > '.DB::getPdo()->quote((string) $at).' THEN created_at ELSE '.DB::getPdo()->quote((string) $at).' END')]);
$updated++;
}
});
$this->info("Gesetzt: {$updated} Meldungen samt Bildern.");
return self::SUCCESS;
}
}

View file

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Console\Command;
/**
@ -36,9 +37,23 @@ class PurgeExpiredPressReleaseDrafts extends Command
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 = 0;
(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) {
$remover->remove($image);
$imagesRemoved++;
}
}
});
$query->update(['status' => PressReleaseStatus::Archived->value]);
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
$this->info("PM-Entwürfe archiviert: {$count} Einträge, Bilder gelöscht: {$imagesRemoved}.");
return self::SUCCESS;
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Legacy-Bereinigung (Bildkennzeichnung E.5, Entscheidung E-B11): Bilder
* aus dem Alt-Import an Meldungen, die nicht veröffentlicht sind (offline
* genommen, archiviert, Entwurf), werden samt Dateien gelöscht. Sie haben
* weder Nachweis noch Protokoll; eine erneute Einreichung läuft ohne Bild.
* Auf der Dev-Kopie: 10.768 Bilder (Stand 22.09.2026).
*/
class PurgeInactiveLegacyPressReleaseImages extends Command
{
protected $signature = 'press-release-images:purge-inactive-legacy
{--execute : Wirklich löschen (Standard ist ein Trockenlauf)}
{--chunk=500 : Zeilen je Durchlauf}';
protected $description = 'Löscht Legacy-Bilder an nicht veröffentlichten Meldungen samt Dateien (E-B11)';
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$execute = (bool) $this->option('execute');
$inactive = [PressReleaseStatus::Rejected->value, PressReleaseStatus::Archived->value, PressReleaseStatus::Draft->value];
$query = PressReleaseImage::query()
->whereNotNull('press_release_images.legacy_id')
->whereNull('press_release_images.rules_version')
->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScopes()->whereIn('press_releases.status', $inactive));
$byStatus = (clone $query)
->join('press_releases', 'press_releases.id', '=', 'press_release_images.press_release_id')
->selectRaw('press_releases.status as status, count(*) as c')
->groupBy('press_releases.status')
->pluck('c', 'status')
->all();
$total = array_sum($byStatus);
$this->info(($execute ? 'Lösche' : 'Trockenlauf:').' Legacy-Bilder an inaktiven Meldungen: '.$total);
foreach ($byStatus as $status => $count) {
$this->line(sprintf(' %-10s %d', $status, $count));
}
if (! $execute || $total === 0) {
return self::SUCCESS;
}
$deleted = 0;
$bytes = 0;
$query->with('pressRelease')->chunkById((int) $this->option('chunk'), function ($images) use (&$deleted, &$bytes): void {
foreach ($images as $image) {
$bytes += $this->sizeOf($image);
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->forceDelete();
$deleted++;
}
});
$this->info(sprintf('Gelöscht: %d Bilder, etwa %s MB freigegeben.', $deleted, number_format($bytes / 1048576, 1, ',', '.')));
return self::SUCCESS;
}
private function sizeOf(PressReleaseImage $image): int
{
$disk = Storage::disk($image->disk);
$size = 0;
foreach (array_filter([$image->path, ...array_values($image->variants ?? [])]) as $path) {
if (is_string($path) && $disk->exists($path)) {
$size += (int) $disk->size($path);
}
}
return $size;
}
}

View file

@ -31,6 +31,12 @@ enum ActivityType: string
case MailSuppressed = 'mail_suppressed';
case ManualInvoice = 'manual_invoice';
/** Zugriff auf Beweisoriginal oder veröffentlichte Bildfassung (Bildkennzeichnung E.4). */
case EvidenceAccess = 'evidence_access';
/** Korrekturweg Bildnachweis/Kennzeichnung (Bildkennzeichnung E.6). */
case ImageCorrection = 'image_correction';
public function label(): string
{
return match ($this) {
@ -55,6 +61,8 @@ enum ActivityType: string
self::ContentRemoval => 'Entfernung (410)',
self::MailSuppressed => 'Mail-Sperre',
self::ManualInvoice => 'Rechnung (MAN)',
self::EvidenceAccess => 'Beweiszugriff',
self::ImageCorrection => 'Bildkorrektur',
};
}
@ -78,6 +86,8 @@ enum ActivityType: string
self::Anonymization => 'red',
self::Correction => 'blue',
self::ContentRemoval => 'red',
self::EvidenceAccess => 'red',
self::ImageCorrection => 'blue',
self::MailSuppressed => 'orange',
self::ManualInvoice => 'amber',
};

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\ActivityType;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Einziger Zugriffsweg auf Beweisoriginal und veröffentlichte Fassung
* (Bildkennzeichnung E.4): nur Admins, nur mit angegebenem Zweck, jeder
* Abruf im Aktivitätsverlauf. Medienroute, Vorschau-Signaturen und API
* kennen diese Pfade nicht. Block F verlagert das Protokoll in
* `archive_access_log`.
*/
class PressReleaseImageEvidenceController extends Controller
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function original(Request $request, int $id, int $image): StreamedResponse
{
return $this->stream($request, $id, $image, 'original');
}
public function published(Request $request, int $id, int $image): StreamedResponse
{
return $this->stream($request, $id, $image, 'published');
}
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse
{
$purpose = trim((string) $request->query('purpose', ''));
abort_if($purpose === '', 422, 'Zweck des Zugriffs fehlt (purpose).');
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($pressReleaseId);
$image = PressReleaseImage::query()->withTrashed()->where('press_release_id', $pressRelease->getKey())->findOrFail($imageId);
$path = $kind === 'original' ? $image->original_path : $image->published_snapshot_path;
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
abort_unless(is_string($path) && $path !== '' && $disk->exists($path), 404);
$this->activityLog->record(
ActivityType::EvidenceAccess,
sprintf('%s zu Bild #%d (PM #%d) abgerufen: %s', $kind === 'original' ? 'Beweisoriginal' : 'Veröffentlichte Fassung', $image->getKey(), $pressRelease->getKey(), $purpose),
$request->user(),
$image,
['kind' => $kind, 'purpose' => $purpose, 'sha256' => $kind === 'original' ? $image->original_sha256 : $image->published_snapshot_sha256],
);
return $disk->response($path, basename($path), [
'Cache-Control' => 'private, no-store',
'X-Content-Type-Options' => 'nosniff',
]);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Insert-only-Protokoll des Korrekturwegs (Bildkennzeichnung E.6): welcher
* Admin wann welches Feld eines Bildes mit welchem Grund geändert hat.
*/
class ImageDeclarationCorrection extends Model
{
public const UPDATED_AT = null;
public const FIELD_CREDIT = 'copyright';
public const FIELD_AI_DECLARATION = 'ai_declaration';
public const FIELD_WITHHELD = 'withheld';
protected $fillable = [
'press_release_image_id',
'press_release_id',
'admin_id',
'field',
'old_value',
'new_value',
'old_source',
'reason',
'created_at',
];
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
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.'));
}
/**
* @return BelongsTo<PressReleaseImage, $this>
*/
public function image(): BelongsTo
{
return $this->belongsTo(PressReleaseImage::class, 'press_release_image_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
}

View file

@ -104,6 +104,7 @@ class PressRelease extends Model
'no_export',
'published_at',
'depublished_at',
'published_ever_at',
'gone_at',
'no_export_before_gone',
'scheduled_at',
@ -134,6 +135,7 @@ class PressRelease extends Model
'no_export' => 'boolean',
'published_at' => 'datetime',
'depublished_at' => 'datetime',
'published_ever_at' => 'datetime',
'gone_at' => 'datetime',
'no_export_before_gone' => 'boolean',
'sync_locked_at' => 'datetime',

View file

@ -8,6 +8,7 @@ use App\Enums\CreditSource;
use App\Enums\ImageLicenseType;
use App\Enums\ImageProvenanceStatus;
use Database\Factories\PressReleaseImageFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -60,6 +61,7 @@ use Illuminate\Support\Facades\URL;
* @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
@ -116,6 +118,7 @@ class PressReleaseImage extends Model
'published_snapshot_sha256',
'published_ever_at',
'customer_confirmation_requested_at',
'withheld_at',
'rules_version',
'focus_x',
'focus_y',
@ -147,6 +150,7 @@ class PressReleaseImage extends Model
'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',
@ -220,6 +224,26 @@ 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.
*/
public function isWithheld(): bool
{
return $this->withheld_at !== null;
}
/**
* Öffentlich ausspielbare Bilder: nicht zurückgehalten.
*
* @param Builder<PressReleaseImage> $query
* @return Builder<PressReleaseImage>
*/
public function scopePubliclyVisible(Builder $query): Builder
{
return $query->whereNull('withheld_at');
}
public function url(bool $signed = false): ?string
{
if (blank($this->path)) {

View file

@ -192,6 +192,23 @@ class AdminSystemHealth
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
];
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben.
$provenanceFailures = PressReleaseImage::query()
->withTrashed()
->where('provenance_status', 'error')
->where('provenance_checked_at', '>=', now()->subDays(7))
->count();
$checks[] = [
'key' => 'image_provenance',
'label' => 'Herkunftsprüfung Bilder',
'status' => $provenanceFailures === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $provenanceFailures === 0
? 'Keine fehlgeschlagene Herkunftsprüfung in den letzten 7 Tagen.'
: $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).',
];
// Ausstehende Medien-Privatisierung.
$publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count();
$publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count();

View file

@ -0,0 +1,18 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Veröffentlichungsgate (Bildkennzeichnung E.1): Ein Bild aus Formular v2
* ohne gültige Rechtebestätigung zur aktuellen Datei — typisch ein
* Admin-Upload, den der Kunde noch nicht bestätigt hat (E10).
*/
class ImageRightsConfirmationRequiredException extends RuntimeException
{
public function __construct(?string $message = null)
{
parent::__construct($message ?? __('Das Titelbild ist noch nicht vom Kontoinhaber bestätigt. Bis dahin kann die Meldung nicht mit Bild veröffentlicht werden.'));
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Services\PressRelease\Images;
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Models\ImageDeclarationCorrection;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
/**
* Korrekturweg (Bildkennzeichnung E.6, Decision-Update S17): Der Admin
* ändert Bildnachweis oder Portalkennzeichnung mit Pflichtgrund oder hält
* das Bild vorläufig zurück. Kundenangabe, ursprüngliche Bestätigung und
* Befund bleiben unverändert; jede Änderung ist insert-only protokolliert.
*/
class ImageCorrectionService
{
public function __construct(private readonly PublishedImageSnapshot $snapshots) {}
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
{
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_CREDIT, $image->copyright, $newCredit, null, $reason, function () use ($image, $newCredit): void {
$image->forceFill(['copyright' => $newCredit])->save();
});
}
public function correctAiDeclaration(PressReleaseImage $image, User $admin, AiImageDeclaration $new, string $reason): ImageDeclarationCorrection
{
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_AI_DECLARATION, $image->ai_declaration?->value, $new->value, $image->ai_declaration_source?->value, $reason, function () use ($image, $new): void {
$image->forceFill([
'ai_declaration' => $new,
'ai_declaration_source' => AiDeclarationSource::AdminCorrection,
'ai_declaration_locked' => false,
])->save();
});
}
public function withhold(PressReleaseImage $image, User $admin, string $reason): ImageDeclarationCorrection
{
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_WITHHELD, '0', '1', null, $reason, function () use ($image): void {
$image->forceFill(['withheld_at' => now()])->save();
});
}
public function release(PressReleaseImage $image, User $admin, string $reason): ImageDeclarationCorrection
{
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_WITHHELD, '1', '0', null, $reason, function () use ($image): void {
$image->forceFill(['withheld_at' => null])->save();
});
}
private function apply(PressReleaseImage $image, User $admin, string $field, ?string $old, ?string $new, ?string $oldSource, string $reason, \Closure $mutation): ImageDeclarationCorrection
{
$correction = DB::transaction(function () use ($image, $admin, $field, $old, $new, $oldSource, $reason, $mutation): ImageDeclarationCorrection {
$mutation();
return ImageDeclarationCorrection::query()->create([
'press_release_image_id' => $image->getKey(),
'press_release_id' => $image->press_release_id,
'admin_id' => $admin->getKey(),
'field' => $field,
'old_value' => $old,
'new_value' => $new,
'old_source' => $oldSource,
'reason' => $reason,
'created_at' => now(),
]);
});
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
// Seiten-Caches verwerfen (D.5).
if ($image->wasEverPublished()) {
$this->snapshots->capture($image->fresh());
WebCaches::forgetPublicPages();
}
return $correction;
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Services\PressRelease\Images;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Support\ImageCredit;
use Illuminate\Support\Facades\Storage;
/**
* Snapshot der veröffentlichten Bildfassung (Bildkennzeichnung E.2,
* Decision-Update E2/S1): beim Veröffentlichen wird die ausgelieferte
* Cover-Variante zusammen mit dem gerenderten Nachweis und der Kennzeichnung
* auf den privaten Snapshot-Pfad kopiert. So lässt sich später belegen, was
* mit welchem Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
*/
class PublishedImageSnapshot
{
public function captureForPressRelease(PressRelease $pressRelease): void
{
$pressRelease->loadMissing('company');
foreach ($pressRelease->images()->whereNotNull('rules_version')->get() as $image) {
$this->capture($image, $pressRelease);
}
}
public function capture(PressReleaseImage $image, ?PressRelease $pressRelease = null): void
{
$pressRelease ??= $image->pressRelease;
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null) {
return;
}
$sourceDisk = Storage::disk($image->disk);
$coverPath = $image->pathForVariant('cover') ?? $image->path;
if (! $sourceDisk->exists($coverPath)) {
return;
}
$bytes = (string) $sourceDisk->get($coverPath);
$sha = hash('sha256', $bytes);
// Unverändert seit dem letzten Snapshot: nichts zu tun.
if ($image->published_snapshot_sha256 !== null && hash_equals($image->published_snapshot_sha256, $sha) && $image->published_snapshot_path !== null) {
return;
}
$credit = ImageCredit::for($image, $pressRelease->company);
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
$base = sprintf(
'%s/%d/%d-%s',
config('images.published_snapshot_directory'),
$pressRelease->getKey(),
$image->getKey(),
substr($sha, 0, 12),
);
$targetDisk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
$targetDisk->put($base.'.'.$extension, $bytes);
$targetDisk->put($base.'.json', (string) json_encode([
'captured_at' => now()->toIso8601String(),
'press_release_id' => $pressRelease->getKey(),
'image_id' => $image->getKey(),
'variant' => 'cover',
'variant_sha256' => $sha,
'original_sha256' => $image->original_sha256,
'credit_line' => $credit->line(),
'credit' => $credit->credit,
'ai_declaration' => $image->ai_declaration?->value,
'ai_declaration_source' => $image->ai_declaration_source?->value,
'responsible_line' => $credit->responsibleLine,
'confirmation_key' => $credit->confirmation?->legal_text_key,
'confirmation_at' => $credit->confirmation?->confirmed_at?->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$image->forceFill([
'published_snapshot_path' => $base.'.'.$extension,
'published_snapshot_sha256' => $sha,
'published_ever_at' => $image->published_ever_at ?? now(),
])->save();
}
}

View file

@ -24,6 +24,8 @@ use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\Mail\EmailSuppressionService;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PublishedImageSnapshot;
use App\Support\WebCaches;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
@ -330,13 +332,20 @@ class PressReleaseService
return ['blacklist' => ['reason' => $reason, 'word' => $word]];
}
// Veröffentlichungsgate (Bildkennzeichnung E.1): kein Bild aus
// Formular v2 ohne gültige Bestätigung zur aktuellen Datei.
$this->assertImageRightsConfirmed($locked);
$this->consumePublishSlot($locked);
$locked->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($locked, $publishedAtOverride),
'published_ever_at' => $locked->published_ever_at ?? now(),
]);
$this->markImagesPublished($locked);
$this->logStatusChange($locked, $previous, PressReleaseStatus::Published, null, $source);
return null;
@ -344,6 +353,10 @@ class PressReleaseService
$pressRelease->refresh();
if ($outcome === null) {
$this->capturePublishedImages($pressRelease);
}
if (isset($outcome['stale'])) {
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $outcome['stale']['current'])->onQueue('classification');
@ -586,6 +599,7 @@ class PressReleaseService
$previous = $locked->status;
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->assertImageRightsConfirmed($locked);
$this->consumePublishSlot($locked);
}
@ -594,8 +608,15 @@ class PressReleaseService
'published_at' => $status === PressReleaseStatus::Published
? ($locked->published_at ?? now())
: $locked->published_at,
'published_ever_at' => $status === PressReleaseStatus::Published
? ($locked->published_ever_at ?? now())
: $locked->published_ever_at,
]);
if ($status === PressReleaseStatus::Published) {
$this->markImagesPublished($locked);
}
if ($previous !== $status) {
$this->logStatusChange($locked, $previous, $status, $reason, 'admin');
}
@ -606,6 +627,10 @@ class PressReleaseService
});
$pressRelease->refresh();
if ($status === PressReleaseStatus::Published) {
$this->capturePublishedImages($pressRelease);
}
}
public function deleteFromAdmin(PressRelease $pressRelease): void
@ -626,9 +651,48 @@ class PressReleaseService
return;
}
// Nie veröffentlicht: Bilder samt Original und Bestätigung sofort
// weg (Bildkennzeichnung E.3, Decision-Update E4).
$remover = app(PressReleaseImageRemover::class);
foreach ($pressRelease->images()->get() as $image) {
$remover->remove($image);
}
$pressRelease->delete();
}
/**
* Veröffentlichungsgate (E.1): Bilder aus Formular v2 brauchen eine
* Bestätigung zur aktuellen Datei — sonst wartet ein Admin-Upload noch
* auf den Kunden (E10). Bestandsbilder sind nicht betroffen.
*/
private function assertImageRightsConfirmed(PressRelease $pressRelease): void
{
$unconfirmed = $pressRelease->images()
->whereNotNull('rules_version')
->with('latestRightsConfirmation')
->get()
->first(fn (PressReleaseImage $image): bool => ! $image->hasValidRightsConfirmation());
if ($unconfirmed !== null) {
throw new ImageRightsConfirmationRequiredException;
}
}
private function markImagesPublished(PressRelease $pressRelease): void
{
$pressRelease->images()->whereNull('published_ever_at')->update(['published_ever_at' => now()]);
}
/**
* Snapshot der veröffentlichten Bildfassung nach dem Commit (E.2).
*/
private function capturePublishedImages(PressRelease $pressRelease): void
{
app(PublishedImageSnapshot::class)->captureForPressRelease($pressRelease);
}
/**
* Entfernung mit 410 Gone: die Mitteilung verschwindet aus allen
* öffentlichen Listen, ihre Detail-URL liefert aber weiterhin einen

View file

@ -97,7 +97,7 @@ class CategoryPageData
$with = [
'company',
'category.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
];
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche

View file

@ -59,7 +59,7 @@ class HomePageData
$with = [
'company',
'category.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query
'images' => fn ($query) => $query->publiclyVisible()
->orderByDesc('is_preview')
->orderBy('sort_order')
->limit(1),

View file

@ -150,7 +150,7 @@ class NewsroomDetailPageData
$with = [
'company',
'category.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
];
$leadRelease = $base()->with($with)->orderByDesc('published_at')->first();

View file

@ -40,7 +40,7 @@ class ReleaseDetailPageData
'user:id,agency_name',
'category.translations' => fn ($query) => $query->where('locale', $language),
'category.parent.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order'),
'contacts',
])
// Doppelveröffentlichung mit gleichem Slug: dieselbe Wahl wie
@ -107,7 +107,7 @@ class ReleaseDetailPageData
$with = [
'company',
'category.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
];
$companyReleases = $release->company_id