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 214aea8910
commit 3d3ddc2dc8
11 changed files with 1380 additions and 5 deletions

View file

@ -23,6 +23,9 @@ enum ConversionSource: string
case ProfileClaim = 'profile_claim';
/** „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4, E10). */
case MailImageConfirmation = 'mail_image_confirmation';
case Organic = 'organic';
public function label(): string
@ -32,6 +35,7 @@ enum ConversionSource: string
self::MailReject => 'Ablehnungsmail',
self::MailReview => 'Prüfhinweis-Mail',
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
self::MailImageConfirmation => 'Mail „Bild bestätigen"',
self::Organic => 'ohne zugeordnete Herkunft',
};
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
/**
* „Bild bestätigen" (Decision-Update E10): Ein Admin hat im Auftrag des
* Kunden ein Titelbild hochgeladen. Veröffentlicht wird es erst, wenn der
* Kontoinhaber die Rechtebestätigung selbst abgibt — der Magic-Link führt
* direkt auf die Meldung im Konto.
*/
class PressReleaseImageConfirmationRequested extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public readonly PressRelease $pressRelease,
public readonly PressReleaseImage $image,
public readonly string $confirmationUrl,
public readonly Carbon $expiresAt,
public readonly ?string $recipientName,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Bitte bestätigen: Titelbild für Ihre Pressemitteilung',
);
}
public function content(): Content
{
return new Content(view: 'emails.press-release.image-confirmation');
}
}

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(),