images: Titelbild-Formular v2, Admin-Upload mit Kundenbestätigung, Entfernen mit Sofortlöschung (Bildkennzeichnung Block B)

- press-release-images-manager-v2 hinter IMAGE_RIGHTS_V2: drei Blöcke, Vorbelegung aus Metadaten, Bestätigung mit archiviertem Wortlaut, Bild-Bindung über Hash, Fokuspunkt und Letterbox, Zustände absent/prefilled/error
- CustomerImageConfirmation: Magic-Link an den Kontoinhaber, Karte auf der Kundenseite, Kanal magic_link
- PressReleaseImageRemover: nie veröffentlicht = alles sofort weg, veröffentlicht = Soft Delete, Original bleibt
- ConversionSource::MailImageConfirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:31:32 +00:00
parent e9bdff3ad3
commit 40953d6c65
11 changed files with 1380 additions and 5 deletions

View file

@ -0,0 +1,83 @@
<?php
namespace App\Services\PressRelease\Images;
use App\Enums\ConfirmationChannel;
use App\Enums\ConversionSource;
use App\Mail\PressReleaseImageConfirmationRequested;
use App\Models\ImageRightsConfirmation;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Illuminate\Support\Facades\Mail;
/**
* Kundenbestätigung für Admin-Uploads (Decision-Update E10, Block B.4):
* Der Kontoinhaber der Meldung bekommt einen Magic-Link „Bild bestätigen",
* der ihn auf die Meldung im Konto führt. Dort gibt er dieselbe Bestätigung
* ab wie beim eigenen Upload; das Protokoll trägt den Kanal `magic_link`.
*/
class CustomerImageConfirmation
{
public const LINK_TTL_MINUTES = 60 * 24 * 3;
public function __construct(
private readonly MagicLinkGenerator $magicLinks,
private readonly PressReleaseImageUploader $uploader,
) {}
/**
* Link erzeugen und Mail an den Kontoinhaber schicken. Erneut aufrufbar;
* ein neuer Link entwertet den alten (MagicLinkGenerator).
*/
public function request(PressReleaseImage $image, ?string $requestedIp = null): bool
{
$pressRelease = $image->pressRelease;
$owner = $pressRelease?->user;
if ($pressRelease === null || $owner === null || blank($owner->email)) {
return false;
}
$link = $this->magicLinks->createLoginLink(
$owner,
$requestedIp,
self::LINK_TTL_MINUTES,
ConversionSource::MailImageConfirmation,
[
'company_id' => $pressRelease->company_id,
'press_release_id' => $pressRelease->id,
'image_confirmation' => $image->id,
],
);
if ($image->customer_confirmation_requested_at === null) {
$image->forceFill(['customer_confirmation_requested_at' => now()])->save();
}
Mail::to($owner->email)->queue(
(new PressReleaseImageConfirmationRequested(
$pressRelease,
$image,
route('magic-links.consume', ['token' => $link['plain_token']]),
$link['expires_at'],
$owner->name,
))->afterCommit(),
);
return true;
}
/**
* Bestätigung des Kontoinhabers im Konto (Kanal magic_link).
*/
public function confirm(PressReleaseImage $image, User $customer, ?string $legalTextKey, ?string $ip, ?string $userAgent, ?string $agbUrl = null): ImageRightsConfirmation
{
return $this->uploader->confirmExisting(
$image,
new ImageUploadActor($customer, ConfirmationChannel::MagicLink, null, $ip, $userAgent),
$legalTextKey,
$agbUrl,
);
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Services\PressRelease\Images;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
*
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
* und vollständig weg, die Zeile wird hart gelöscht;
* - veröffentlicht: Derivate weg, Zeile weich gelöscht; Original, Snapshot
* und Bestätigung bleiben für das Beweisarchiv (Block F übernimmt die
* Überführung und die Frist).
*
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
* wie bisher behandelt (Derivate weg, Soft Delete).
*/
class PressReleaseImageRemover
{
public function __construct(private readonly ImageService $imageService) {}
public function remove(PressReleaseImage $image): void
{
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
if ($image->wasEverPublished() || $image->isLegacyRules()) {
$image->delete();
return;
}
DB::transaction(function () use ($image): void {
foreach ($image->rightsConfirmations()->get() as $confirmation) {
$confirmation->purgeForUnpublished();
}
if ($image->original_path !== null) {
Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->original_path);
}
if ($image->published_snapshot_path !== null) {
Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->published_snapshot_path);
}
$image->forceDelete();
});
}
}

View file

@ -182,6 +182,34 @@ class PressReleaseImageUploader
}
}
/**
* Nachträgliche Bestätigung eines bestehenden Bildes durch den
* Kontoinhaber (Admin-Upload, Decision-Update E10) — dieselbe Prüfung
* der Textkennung, dasselbe Protokoll, gebunden an den aktuellen Hash.
*/
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null): ImageRightsConfirmation
{
if (! $actor->confirmsImmediately()) {
throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed');
}
$legalText = $this->resolveLegalText(
new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey),
$actor,
);
if ($legalText === null || $image->original_sha256 === null) {
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
}
return DB::transaction(function () use ($image, $actor, $legalText, $agbUrl): ImageRightsConfirmation {
$confirmation = $this->recordConfirmation($image, $image->pressRelease, $actor, $legalText, $agbUrl);
$image->forceFill(['rights_confirmed_at' => now()])->save();
return $confirmation;
});
}
/**
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
@ -255,11 +283,11 @@ class PressReleaseImageUploader
return $current;
}
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): void
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): ImageRightsConfirmation
{
$variables = $this->textRenderer->variables($agbUrl);
ImageRightsConfirmation::query()->create([
return ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $pressRelease->getKey(),