legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)

- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:12:17 +00:00
parent b5b70416f2
commit 075d0d931f
27 changed files with 866 additions and 15 deletions

View file

@ -284,3 +284,5 @@ CONFIRMATION_IP_KEY=
# Beweisarchiv: eigener Schlüssel (php artisan archive:key), getrennt sichern — ohne ihn ist das Archiv unlesbar.
ARCHIVE_KEY=
ARCHIVE_RETENTION_YEARS=3
# Zustimmungsgate vor jeder Einreichung (Block G) — erst mit AGB v0.7 einschalten.
LEGAL_ACCEPTANCE_GATE=false

View file

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Zustimmungsgate per API (Block G, O2): einmal je Kontoinhaber und
* Dokumentversion. Ohne gültige Annahme antwortet POST …/submit mit 428
* und den geforderten Fassungen.
*/
class LegalAcceptanceController extends Controller
{
public function __construct(private readonly LegalAcceptanceGate $gate) {}
public function index(Request $request): JsonResponse
{
$required = $this->gate->requiredFor($request->user())->map(fn (LegalDocument $d) => $this->gate->describe($d))->values();
return response()->json([
'gate_enabled' => $this->gate->isEnabled(),
'required' => $required,
'accepted' => LegalDocumentAcceptance::query()
->where('user_id', $request->user()->getKey())
->orderByDesc('id')
->limit(20)
->get(['type', 'version', 'context', 'accepted_at'])
->map(fn (LegalDocumentAcceptance $a) => ['type' => $a->type, 'version' => $a->version, 'context' => $a->context, 'accepted_at' => $a->accepted_at->toIso8601String()])
->values(),
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$validated = $request->validate([
'documents' => ['required', 'array', 'min:1'],
'documents.*.type' => ['required', Rule::in(LegalAcceptanceGate::TYPES)],
'documents.*.version' => ['required', 'string', 'max:10'],
]);
$documents = [];
foreach ($validated['documents'] as $item) {
$current = LegalDocument::current($item['type']);
if ($current === null || $current->version !== $item['version']) {
return response()->json([
'message' => sprintf('Version %s of %s is not the current one (current: %s).', $item['version'], $item['type'], $current !== null ? $current->version : 'none'),
], 422);
}
$documents[] = $current;
}
$this->gate->accept($request->user(), $documents, LegalDocumentAcceptance::CONTEXT_API, $request->ip(), $request->userAgent());
return $this->index($request)->setStatusCode(201);
}
}

View file

@ -14,6 +14,7 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Legal\LegalAcceptanceGate;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
@ -208,6 +209,18 @@ class PressReleaseController extends Controller
], 409);
}
// Zustimmungsgate (Block G, O2): ohne Annahme der aktuellen AGB- und
// Richtlinien-Fassung 428 mit den geforderten Versionen.
$gate = app(LegalAcceptanceGate::class);
$requiredDocuments = $gate->requiredFor($request->user());
if ($requiredDocuments->isNotEmpty()) {
return response()->json([
'message' => 'The current terms and guidelines must be accepted before submitting (POST /api/v1/legal/acceptances).',
'required_documents' => $requiredDocuments->map(fn ($document) => $gate->describe($document))->values(),
], 428);
}
$pressRelease->update([
'ai_text_declaration' => $validated['ai_text_declaration'],
'ai_declaration_confirmed_at' => now(),

View file

@ -3,6 +3,8 @@
namespace App\Livewire\Concerns;
use App\Enums\AiTextDeclaration;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
@ -21,19 +23,51 @@ trait InteractsWithAiDeclaration
public bool $aiDeclarationConfirmed = false;
/**
* Zustimmungsgate (Bildkennzeichnung Block G): aktive Annahme der
* aktuellen AGB- und Richtlinien-Fassung, wenn das Gate an ist und eine
* Fassung noch nicht angenommen wurde.
*/
public bool $legalAcceptanceConfirmed = false;
/**
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
*/
protected function validateAiDeclaration(): void
{
$this->validate([
$rules = [
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
'aiDeclarationConfirmed' => ['accepted'],
], [
];
$messages = [
'aiTextDeclaration.required' => __('Bitte angeben, ob und wie generative KI beim Text eingesetzt wurde.'),
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
]);
];
if (app(LegalAcceptanceGate::class)->requiredFor(auth()->user())->isNotEmpty()) {
$rules['legalAcceptanceConfirmed'] = ['accepted'];
$messages['legalAcceptanceConfirmed.accepted'] = __('Bitte nehmen Sie die aktuelle Fassung der AGB und der Veröffentlichungsrichtlinien an.');
}
$this->validate($rules, $messages);
}
/**
* Annahme protokollieren (nach validateAiDeclaration, vor dem Einreichen).
* Ohne Gate oder ohne offene Fassung passiert nichts.
*/
protected function persistLegalAcceptances(): void
{
$gate = app(LegalAcceptanceGate::class);
$required = $gate->requiredFor(auth()->user());
if ($required->isEmpty() || ! $this->legalAcceptanceConfirmed) {
return;
}
$gate->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent());
$this->legalAcceptanceConfirmed = false;
}
/**

View file

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Aktive Annahme einer Rechtstext-Fassung durch einen Nutzer (Block G).
* Insert-only.
*/
class LegalDocumentAcceptance extends Model
{
public const UPDATED_AT = null;
public const CONTEXT_SUBMIT = 'submit';
public const CONTEXT_API = 'api';
public const CONTEXT_GATE = 'gate';
protected $fillable = [
'user_id',
'legal_document_id',
'type',
'version',
'context',
'ip_hmac',
'user_agent',
'accepted_at',
'created_at',
];
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
'created_at' => 'datetime',
];
}
protected static function booted(): void
{
static::updating(fn () => throw new \LogicException('legal_document_acceptances ist insert-only.'));
static::deleting(fn () => throw new \LogicException('legal_document_acceptances wird nicht gelöscht.'));
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return BelongsTo<LegalDocument, $this>
*/
public function legalDocument(): BelongsTo
{
return $this->belongsTo(LegalDocument::class);
}
}

View file

@ -17,6 +17,7 @@ use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Support\ContentFingerprint;
use App\Support\ImageCredit;
use Closure;
use Database\Factories\PressReleaseFactory;
use Illuminate\Database\Eloquent\Builder;
@ -263,6 +264,17 @@ class PressRelease extends Model
* Fallback Original. Null bei bildlosen PMs (dann greift
* chosenPlaceholderUrl() bzw. der generische Fallback der Komponente).
*/
/**
* Nachweiszeile des Titelbilds für Teaser (Bildkennzeichnung H.2):
* „Foto: …" bzw. „Quelle: …" plus KI-Zusatz, null ohne Bild.
*/
public function cardImageCredit(): ?string
{
$image = $this->relationLoaded('images') ? $this->images->first() : null;
return $image ? ImageCredit::for($image, $this->company)->line() : null;
}
public function cardImageUrl(): ?string
{
$image = $this->images?->first();

View file

@ -75,6 +75,12 @@ class ImageService
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
/**
* Schrift für den eingebrannten Nachweisbalken der OG-Variante
* (Bildkennzeichnung H.1, O4): Inter SemiBold, OFL 1.1, im Repository.
*/
public const OG_CAPTION_FONT = 'fonts/Inter_18pt-SemiBold.ttf';
public function __construct(private readonly string $disk = 'public') {}
/**
@ -144,6 +150,7 @@ class ImageService
string $diskName = 'local',
?array $focus = null,
bool $noCrop = false,
?string $ogCaption = null,
): array {
$this->assertValidPressReleaseImageUpload($upload);
@ -167,6 +174,7 @@ class ImageService
cover: true,
focus: $focus,
noCrop: $noCrop,
ogCaption: $ogCaption,
);
} finally {
// Die Arbeitskopie im Derivat-Ordner wird immer entfernt — auch
@ -284,7 +292,121 @@ class ImageService
* @param array{x: int, y: int}|null $focus
* @return array<string, string>
*/
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false, ?array $focus = null, bool $noCrop = false): array
/**
* OG-Variante neu erzeugen (Bildkennzeichnung H.1): aus der größten
* vorhandenen Variante, mit eingebranntem Nachweisbalken. Nötig nach
* einer Korrektur des Nachweises (E.6) und für den Bestand ohne
* OG-Variante. Liefert den Variantenpfad oder null.
*
* @param array<string, string>|null $variants
* @param array{x: int, y: int}|null $focus
*/
public function regenerateOgVariant(string $diskName, ?array $variants, ?string $fallbackPath, ?array $focus, bool $noCrop, ?string $caption): ?string
{
$disk = $this->disk($diskName);
$source = null;
foreach (['large', 'cover', 'medium'] as $key) {
if (is_string($variants[$key] ?? null) && $disk->exists($variants[$key])) {
$source = $variants[$key];
break;
}
}
$source ??= (is_string($fallbackPath) && $disk->exists($fallbackPath)) ? $fallbackPath : null;
if ($source === null) {
return null;
}
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION) ?: 'jpg');
$sourceImage = $this->createImageResource($disk->path($source), $extension);
if (! $sourceImage) {
return null;
}
try {
$spec = self::PRESS_RELEASE_IMAGE_VARIANTS['og'];
$ogPath = preg_replace('/-(large|cover|medium)\.([a-z0-9]+)$/i', '-og.$2', $source);
if (! is_string($ogPath) || $ogPath === $source) {
$ogPath = $this->variantPath($source, 'og');
}
$disk->makeDirectory(dirname($ogPath));
$resized = $noCrop
? $this->resizeContained($sourceImage, $spec['width'], $spec['height'], opaque: $extension === 'jpg')
: $this->resizeCover($sourceImage, $spec['width'], $spec['height'], $focus);
if ($caption !== null && $caption !== '') {
$this->burnCaption($resized, $caption);
}
$written = $this->writeImage($resized, $disk->path($ogPath), $extension);
imagedestroy($resized);
return $written ? $ogPath : null;
} finally {
imagedestroy($sourceImage);
}
}
/**
* Halbtransparenter Balken am unteren Rand mit dem Nachweis in Inter
* SemiBold; der Text wird auf die Breite gekürzt. Ohne Schrift bleibt
* das Bild unverändert (H.1, O4).
*/
private function burnCaption(\GdImage $image, string $caption): void
{
$font = resource_path(self::OG_CAPTION_FONT);
if (! is_file($font) || ! function_exists('imagettftext')) {
return;
}
$width = imagesx($image);
$height = imagesy($image);
$bar = max(44, (int) round($height * 0.105));
$size = max(14, (int) round($height * 0.034));
$padding = max(16, (int) round($width * 0.023));
imagealphablending($image, true);
$shade = imagecolorallocatealpha($image, 0, 0, 0, 38);
imagefilledrectangle($image, 0, $height - $bar, $width, $height, $shade);
$text = $this->fitCaption($caption, $font, $size, $width - 2 * $padding);
$box = imagettfbbox($size, 0, $font, $text);
$textHeight = abs($box[7] - $box[1]);
$baseline = $height - (int) round(($bar - $textHeight) / 2);
$white = imagecolorallocate($image, 255, 255, 255);
imagettftext($image, $size, 0, $padding, $baseline, $white, $font, $text);
}
private function fitCaption(string $caption, string $font, int $size, int $maxWidth): string
{
$text = trim(preg_replace('/\s+/u', ' ', $caption) ?? $caption);
while ($text !== '') {
$box = imagettfbbox($size, 0, $font, $text);
if (abs($box[2] - $box[0]) <= $maxWidth) {
return $text;
}
$text = rtrim(mb_substr($text, 0, max(0, mb_strlen($text) - 4))).'…';
if (mb_strlen($text) <= 4) {
return $text;
}
}
return $text;
}
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false, ?array $focus = null, bool $noCrop = false, ?string $ogCaption = null): array
{
$absolute = $disk->path($relativePath);
$sourceImage = $this->createImageResource($absolute, $extension);
@ -318,6 +440,11 @@ class ImageService
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight, $focus)
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight, opaque: $cover && $extension === 'jpg');
// OpenGraph-Vorschau trägt den Nachweis eingebrannt (H.1, E7).
if ($key === 'og' && $ogCaption !== null && $ogCaption !== '') {
$this->burnCaption($resized, $ogCaption);
}
$written = $this->writeImage($resized, $variantAbsolute, $extension);
imagedestroy($resized);

View file

@ -0,0 +1,104 @@
<?php
namespace App\Services\Legal;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Zustimmungsgate vor jeder Einreichung (Bildkennzeichnung Block G,
* Decision-Update E12): AGB und Richtlinien müssen in der aktuellen Fassung
* aktiv angenommen sein — kein „Weiternutzung gilt als Zustimmung".
* Bestandskunden werden beim nächsten Einreichen einmalig durchgeschleust.
*
* Hinter LEGAL_ACCEPTANCE_GATE, aktiv erst mit AGB v0.7 (Deploy 3), damit
* niemand zweimal klickt.
*/
class LegalAcceptanceGate
{
/** @var list<string> */
public const TYPES = [LegalDocument::TYPE_AGB, LegalDocument::TYPE_RICHTLINIEN];
public function isEnabled(): bool
{
return (bool) config('legal.acceptance_gate', false);
}
/**
* Fassungen, die der Nutzer noch nicht angenommen hat (leer, wenn das
* Gate aus ist oder alles angenommen wurde).
*
* @return Collection<int, LegalDocument>
*/
public function requiredFor(?User $user): Collection
{
if (! $this->isEnabled() || $user === null) {
return collect();
}
return collect(self::TYPES)
->map(fn (string $type): ?LegalDocument => LegalDocument::current($type))
->filter()
->reject(fn (LegalDocument $document): bool => $this->hasAccepted($user, $document))
->values();
}
public function hasAccepted(User $user, LegalDocument $document): bool
{
return LegalDocumentAcceptance::query()
->where('user_id', $user->getKey())
->where('type', $document->type)
->where('version', $document->version)
->exists();
}
/**
* @param iterable<int, LegalDocument> $documents
* @return list<LegalDocumentAcceptance>
*/
public function accept(User $user, iterable $documents, string $context, ?string $ip = null, ?string $userAgent = null): array
{
$rows = [];
foreach ($documents as $document) {
if ($this->hasAccepted($user, $document)) {
continue;
}
$rows[] = LegalDocumentAcceptance::query()->create([
'user_id' => $user->getKey(),
'legal_document_id' => $document->getKey(),
'type' => $document->type,
'version' => $document->version,
'context' => $context,
'ip_hmac' => ImageRightsConfirmation::hashIp($ip),
'user_agent' => $userAgent !== null ? Str::limit($userAgent, 250, '') : null,
'accepted_at' => now(),
'created_at' => now(),
]);
}
return $rows;
}
/**
* Anzeige-/API-Form einer Fassung.
*
* @return array{type: string, version: string, label: string, url: string}
*/
public function describe(LegalDocument $document): array
{
$route = $document->type === LegalDocument::TYPE_RICHTLINIEN ? 'richtlinien' : 'agb';
return [
'type' => $document->type,
'version' => $document->version,
'label' => $document->type === LegalDocument::TYPE_RICHTLINIEN ? __('Veröffentlichungsrichtlinien') : __('Allgemeine Geschäftsbedingungen'),
'url' => route($route, ['edition' => 'de']),
];
}
}

View file

@ -8,6 +8,8 @@ use App\Models\ImageDeclarationCorrection;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Support\ImageCredit;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
@ -22,6 +24,7 @@ class ImageCorrectionService
public function __construct(
private readonly PublishedImageSnapshot $snapshots,
private readonly PressReleaseArchiver $archiver,
private readonly ImageService $imageService,
) {}
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
@ -56,6 +59,26 @@ class ImageCorrectionService
});
}
/**
* Der eingebrannte Nachweis der OG-Variante muss der Korrektur folgen (H.1).
*/
public function refreshOgVariant(PressReleaseImage $image): void
{
$caption = ImageCredit::for($image, $image->pressRelease?->company)->line();
$path = $this->imageService->regenerateOgVariant(
$image->disk,
$image->variants,
$image->path,
$image->focus_x !== null && $image->focus_y !== null ? ['x' => $image->focus_x, 'y' => $image->focus_y] : null,
(bool) $image->no_crop,
$caption,
);
if ($path !== null) {
$image->forceFill(['variants' => array_merge((array) $image->variants, ['og' => $path])])->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 {
@ -77,6 +100,10 @@ class ImageCorrectionService
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
// Seiten-Caches verwerfen (D.5). Jede Korrektur zusätzlich als
// Archiveintrag label_corrected (Block F.4).
if (in_array($field, [ImageDeclarationCorrection::FIELD_CREDIT, ImageDeclarationCorrection::FIELD_AI_DECLARATION], true)) {
$this->refreshOgVariant($image->fresh());
}
if ($image->wasEverPublished()) {
$this->snapshots->capture($image->fresh());
WebCaches::forgetPublicPages();

View file

@ -14,6 +14,7 @@ use App\Services\Image\ImageProvenanceInspector;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\Image\ImageService;
use App\Services\Image\ProvenanceResult;
use App\Support\ImageCredit;
use App\Support\WebCaches;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
@ -109,14 +110,17 @@ class PressReleaseImageUploader
throw new RuntimeException('Hash von Original und Prüfung stimmen nicht überein.');
}
$effective = $this->effectiveDeclaration($submission->aiDeclaration, $provenance);
$stored = $this->imageService->storePressReleaseImage(
$upload,
$pressRelease->id,
focus: $submission->focus,
noCrop: $submission->noCrop,
ogCaption: ImageCredit::compose($submission->copyright, $effective['declaration']?->publicLabel()),
);
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl): PressReleaseImage {
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective): PressReleaseImage {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
@ -127,8 +131,6 @@ class PressReleaseImageUploader
throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
}
$effective = $this->effectiveDeclaration($submission->aiDeclaration, $provenance);
$image = $locked->images()->create([
'disk' => $stored['disk'],
'path' => $stored['path'],

View file

@ -75,6 +75,26 @@ final readonly class ImageCredit
);
}
/**
* Zeile aus Einzelteilen — für den eingebrannten OG-Balken beim Upload,
* bevor es ein Modell gibt (H.1).
*/
public static function compose(?string $credit, ?string $aiLabel, ?string $label = null): ?string
{
$credit = self::clean($credit);
$parts = [];
if ($credit !== null) {
$parts[] = ($label ?? __('web.detail.photo')).': '.$credit;
}
if ($aiLabel !== null && $aiLabel !== '') {
$parts[] = $aiLabel;
}
return $parts === [] ? null : implode(' · ', $parts);
}
/**
* Sichtbare Zeile unter dem Bild, z. B. „Foto: Musterfirma · KI-generiert".
*/

View file

@ -98,4 +98,18 @@ return [
'corrections_per_week' => (int) env('LEGAL_CORRECTIONS_PER_WEEK', 1),
/*
|--------------------------------------------------------------------------
| Zustimmungsgate (Bildkennzeichnung Block G, Decision-Update E12)
|--------------------------------------------------------------------------
|
| acceptance_gate: vor jeder Einreichung müssen AGB und Richtlinien in
| der aktuellen Fassung aktiv angenommen sein (legal_document_acceptances).
| Erst mit AGB v0.7 einschalten (Deploy 3), damit Bestandskunden nicht
| zweimal klicken.
|
*/
'acceptance_gate' => (bool) env('LEGAL_ACCEPTANCE_GATE', false),
];

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Zustimmungsgate (Bildkennzeichnung Block G, Decision-Update E12/S12):
* Wer welche Fassung von AGB und Richtlinien wann aktiv angenommen hat.
* Insert-only; eine neue Fassung verlangt eine neue Annahme. Die
* bestehenden booking_confirmations bleiben unverändert daneben.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('legal_document_acceptances', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('legal_document_id')->nullable()->constrained()->nullOnDelete();
$table->string('type', 30);
$table->string('version', 10);
$table->string('context', 20);
$table->string('ip_hmac', 80)->nullable();
$table->string('user_agent', 255)->nullable();
$table->timestamp('accepted_at');
$table->timestamp('created_at')->nullable();
$table->index(['user_id', 'type', 'version'], 'lda_user_type_version_idx');
});
}
public function down(): void
{
Schema::dropIfExists('legal_document_acceptances');
}
};

View file

@ -33,6 +33,11 @@
{{-- Blockform, nicht Inline-Form: die Inline-Variante würde bis zum Ende des nächsten Blocks als Rohblock gelesen (BladeCompiler::storePhpBlocks, Guard: BladePhpBlockGuardTest). --}}
@php
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
// Zustimmungsgate (Bildkennzeichnung Block G): offene Fassungen von AGB
// und Richtlinien, die vor dem Einreichen aktiv angenommen werden müssen.
$legalGate = app(\App\Services\Legal\LegalAcceptanceGate::class);
$legalDocumentsToAccept = $legalGate->requiredFor(auth()->user())->map(fn ($document) => $legalGate->describe($document))->values();
$needsLegalAcceptance = $legalDocumentsToAccept->isNotEmpty();
@endphp
<flux:modal :name="$name" class="w-full max-w-xl">
@ -72,7 +77,7 @@
@else
{{-- Ohne eigenes Titelbild startet `images` auf true: die Checkbox ist
ausgeblendet und darf den Bestätigen-Button nicht blockieren. --}}
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false }">
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false, legal: {{ $needsLegalAcceptance ? 'false' : 'true' }} }">
<div>
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung freigeben') }}</flux:heading>
@ -130,6 +135,28 @@
</article>
@endif
@if ($needsLegalAcceptance)
{{-- Zustimmungsgate (Block G): aktiver Klick je Fassung, kein
„Weiternutzung gilt als Zustimmung". Wire-gebunden, weil die
Annahme protokolliert wird (legal_document_acceptances). --}}
<article class="panel overflow-hidden" data-legal-acceptance>
<div class="panel-head" style="padding:10px 16px;">
<span class="section-eyebrow">{{ __('Neue Fassung unserer Bedingungen') }} <span class="text-[color:var(--color-err)]">*</span></span>
</div>
<div class="p-4 space-y-3 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0">{{ __('Vor dem Einreichen nehmen Sie bitte die aktuelle Fassung an:') }}</p>
<ul class="m-0 list-disc space-y-1 ps-5">
@foreach ($legalDocumentsToAccept as $legalDocument)
<li><a href="{{ $legalDocument['url'] }}" target="_blank" rel="noopener" class="underline underline-offset-2">{{ $legalDocument['label'] }}</a> — {{ __('Version :version', ['version' => $legalDocument['version']]) }}</li>
@endforeach
</ul>
<flux:switch align="left" wire:model="legalAcceptanceConfirmed" x-on:change="legal = $event.target.checked"
:label="__('Ich habe die genannten Fassungen gelesen und nehme sie an.')" />
<flux:error name="legalAcceptanceConfirmed" />
</div>
</article>
@endif
{{-- KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
Pflicht-Selbstdeklaration in jedem Veröffentlichungsprozess.
Die Eltern-Komponente stellt die Properties über den Trait
@ -223,7 +250,7 @@
</flux:modal.close>
<flux:button variant="primary" wire:click="{{ $action }}"
wire:loading.attr="disabled"
x-bind:disabled="! (agb && images && contact && waiver && ai)">
x-bind:disabled="! (agb && images && contact && waiver && ai && legal)">
{{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
</flux:button>
</div>

View file

@ -9,6 +9,7 @@
$teaser = $release?->teaserText(180) ?? '';
$company = $release?->company?->name;
$title = $release->title;
$imageCredit = $imageUrl ? $release?->cardImageCredit() : null;
$href = route('release.detail', ['slug' => $release->slug]);
$time = $release?->compactPublishedLabel();
$city = $release?->company?->city ?? null;
@ -18,7 +19,7 @@
<article class="py-6 border-b border-bg-rule grid gap-6 grid-cols-1 md:grid-cols-[240px_1fr] group">
<a href="{{ $href }}" class="relative overflow-hidden bg-feature-grad block cursor-pointer" style="width:100%; max-width:240px; height:160px;">
@if ($imageUrl)
<img src="{{ $imageUrl }}" alt="{{ $title }}" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
<img src="{{ $imageUrl }}" alt="{{ $imageCredit ? $title.' · '.$imageCredit : $title }}" @if ($imageCredit) title="{{ $imageCredit }}" @endif class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@elseif ($placeholderUrl)
<img src="{{ $placeholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@else

View file

@ -18,6 +18,7 @@
$leadHref = route('release.detail', ['slug' => $leadRelease->slug]);
$leadReadTime = $leadRelease->readTimeMinutes();
$leadPublishedAt = $leadRelease->published_at;
$leadImageCredit = $leadImageUrl ? $leadRelease->cardImageCredit() : null;
}
@endphp
@ -37,10 +38,13 @@
<a href="{{ $leadHref }}" class="block group cursor-pointer">
<div class="relative overflow-hidden bg-hero-grad min-h-[320px] sm:min-h-[420px] lg:min-h-[500px] flex flex-col justify-end">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadTitle }}"
<img src="{{ $leadImageUrl }}" alt="{{ $leadImageCredit ? $leadTitle.' · '.$leadImageCredit : $leadTitle }}" @if ($leadImageCredit) title="{{ $leadImageCredit }}" @endif
class="absolute inset-0 h-full w-full object-cover opacity-90 transition-opacity group-hover:opacity-100"
loading="eager">
<div class="absolute inset-0 bg-black/10"></div>
@if ($leadImageCredit)
<span class="absolute top-2 right-3 z-10 text-[10.5px] text-white/85 bg-black/35 px-1.5 py-0.5" data-image-credit-teaser>{{ $leadImageCredit }}</span>
@endif
@elseif ($leadPlaceholderUrl)
<img src="{{ $leadPlaceholderUrl }}" alt="" aria-hidden="true"
class="absolute inset-0 h-full w-full object-cover" loading="eager">

View file

@ -12,6 +12,7 @@
$company = $release?->company?->name;
$href = route('release.detail', ['slug' => $release->slug]);
$imageUrl = $release?->cardImageUrl();
$imageCredit = $imageUrl ? $release?->cardImageCredit() : null;
$placeholderUrl = $imageUrl ? null : $release?->chosenPlaceholderUrl();
$gradient = 'linear-gradient(135deg,#3A4A5A,#6B7C95)';
$number = str_pad((string) $index, 2, '0', STR_PAD_LEFT);
@ -30,7 +31,7 @@
<div class="w-16 h-16 flex-shrink-0 relative overflow-hidden" style="background: {{ $gradient }};">
@if ($imageUrl)
<img src="{{ $imageUrl }}" alt="" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
<img src="{{ $imageUrl }}" alt="" @if ($imageCredit) title="{{ $imageCredit }}" @endif class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@elseif ($placeholderUrl)
<img src="{{ $placeholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@else

View file

@ -310,6 +310,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
if ($submitStatus === 'review') {
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
}
$pr = $this->persistDraft();

View file

@ -300,6 +300,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
if ($submitAfterSave) {
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
}
$result = $this->persistEdit();

View file

@ -94,6 +94,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
// Submit-Modal validieren und vor dem Einreichen persistieren.
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
$pr->update($this->aiDeclarationAttributes());
try {

View file

@ -170,6 +170,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
// Submit-Modal validieren und vor dem Einreichen persistieren.
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
$pr->update($this->aiDeclarationAttributes());
try {

View file

@ -171,7 +171,13 @@
Text im Flow (min-h + flex), damit lange Titel auf Mobile nicht geclippt werden. --}}
<div class="relative overflow-hidden bg-hero-grad min-h-[240px] sm:min-h-[300px] lg:min-h-[360px] flex flex-col justify-end">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadRelease->title }}" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@php
$leadImageCredit = $leadRelease->cardImageCredit();
@endphp
<img src="{{ $leadImageUrl }}" alt="{{ $leadImageCredit ? $leadRelease->title.' · '.$leadImageCredit : $leadRelease->title }}" @if ($leadImageCredit) title="{{ $leadImageCredit }}" @endif class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@if ($leadImageCredit)
<span class="absolute top-2 right-3 z-10 text-[10.5px] text-white/85 bg-black/35 px-1.5 py-0.5" data-image-credit-teaser>{{ $leadImageCredit }}</span>
@endif
@elseif ($leadPlaceholderUrl)
<img src="{{ $leadPlaceholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@else

View file

@ -192,7 +192,13 @@
Text im Flow (min-h + flex), damit lange Titel auf Mobile nicht geclippt werden. --}}
<div class="relative overflow-hidden bg-hero-grad min-h-[220px] sm:min-h-[280px] lg:min-h-[340px] flex flex-col justify-end">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadRelease->title }}" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@php
$leadImageCredit = $leadRelease->cardImageCredit();
@endphp
<img src="{{ $leadImageUrl }}" alt="{{ $leadImageCredit ? $leadRelease->title.' · '.$leadImageCredit : $leadRelease->title }}" @if ($leadImageCredit) title="{{ $leadImageCredit }}" @endif class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@if ($leadImageCredit)
<span class="absolute top-2 right-3 z-10 text-[10.5px] text-white/85 bg-black/35 px-1.5 py-0.5" data-image-credit-teaser>{{ $leadImageCredit }}</span>
@endif
@elseif ($leadPlaceholderUrl)
<img src="{{ $leadPlaceholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@else

View file

@ -169,7 +169,7 @@
@section('meta_description', filled($lead) ? $lead : $teaser)
@section('og_type', 'article')
@if ($imageUrl)
@section('og_image', $imageUrl)
@section('og_image', $image?->variantUrl('og', $signMedia) ?? $imageUrl)
@endif
@if ($isPreview)
@section('robots', 'noindex,nofollow')

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\CompanyController;
use App\Http\Controllers\Api\V1\LegalAcceptanceController;
use App\Http\Controllers\Api\V1\NewsletterSubscriptionController;
use App\Http\Controllers\Api\V1\PressReleaseController;
use App\Http\Controllers\Api\V1\PressReleaseImageController;
@ -29,4 +30,8 @@ Route::prefix('v1')
Route::get('categories', [CategoryController::class, 'index'])->name('categories.index');
Route::post('newsletter/subscribe', [NewsletterSubscriptionController::class, 'store'])
->name('newsletter.subscribe');
// Zustimmungsgate (Bildkennzeichnung Block G, O2): Annahme je
// Kontoinhaber und Dokumentversion; submit antwortet sonst 428.
Route::get('legal/acceptances', [LegalAcceptanceController::class, 'index'])->name('legal.acceptances.index');
Route::post('legal/acceptances', [LegalAcceptanceController::class, 'store'])->name('legal.acceptances.store');
});

View file

@ -0,0 +1,139 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\ImageCorrectionService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Bildkennzeichnung Block H: OG-Variante mit eingebranntem Nachweis,
* Neuaufbau nach Korrektur, Fokuspunkt/Letterbox, Nachweis in Teasern.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('local');
Storage::fake('public');
});
function ogPixel(string $path, int $x, int $y): array
{
$img = imagecreatefromjpeg(Storage::disk('local')->path($path));
$rgb = imagecolorat($img, $x, $y);
imagedestroy($img);
return [($rgb >> 16) & 0xFF, ($rgb >> 8) & 0xFF, $rgb & 0xFF];
}
test('the og variant carries a darker caption bar at the bottom while the rest stays untouched', function () {
$upload = UploadedFile::fake()->image('hell.jpg', 2400, 1260)->size(200);
// Ein helles Bild, damit der Balken messbar ist.
$white = imagecreatetruecolor(2400, 1260);
imagefilledrectangle($white, 0, 0, 2400, 1260, imagecolorallocate($white, 250, 250, 250));
imagejpeg($white, $upload->getRealPath(), 95);
imagedestroy($white);
$stored = (new ImageService)->storePressReleaseImage($upload, 7, 'local', ogCaption: 'Foto: Anna Beispiel · KI-generiert');
expect($stored['variants'])->toHaveKey('og');
[$w, $h] = getimagesize(Storage::disk('local')->path($stored['variants']['og']));
expect($w)->toBe(1200)->and($h)->toBe(630);
$top = ogPixel($stored['variants']['og'], 600, 40);
$bar = ogPixel($stored['variants']['og'], 1100, 615);
expect($top[0])->toBeGreaterThan(235)
->and($bar[0])->toBeLessThan(200);
// Cover-Variante ohne Balken.
$coverBottom = ogPixel($stored['variants']['cover'], 1200, 570);
expect($coverBottom[0])->toBeGreaterThan(235);
});
test('the focus point moves the crop and no_crop keeps the whole image', function () {
$upload = UploadedFile::fake()->image('split.jpg', 2000, 1000)->size(200);
// Linke Hälfte rot, rechte Hälfte blau.
$img = imagecreatetruecolor(2000, 1000);
imagefilledrectangle($img, 0, 0, 999, 1000, imagecolorallocate($img, 220, 30, 30));
imagefilledrectangle($img, 1000, 0, 2000, 1000, imagecolorallocate($img, 30, 30, 220));
imagejpeg($img, $upload->getRealPath(), 95);
imagedestroy($img);
$service = new ImageService;
// thumb 320×240 aus 2000×1000: Ausschnitt 1333 px breit. Fokus links →
// Ausschnitt 0–1333 (rot bis Pixel 240 der Miniatur), Fokus rechts →
// 667–2000 (blau ab Pixel 80).
$left = $service->storePressReleaseImage($upload, 1, 'local', focus: ['x' => 0, 'y' => 50]);
$leftPixel = ogPixel($left['variants']['thumb'], 100, 120);
expect($leftPixel[0])->toBeGreaterThan($leftPixel[2]);
$upload2 = UploadedFile::fake()->image('split2.jpg', 2000, 1000)->size(200);
copy($upload->getRealPath(), $upload2->getRealPath());
$right = $service->storePressReleaseImage($upload2, 2, 'local', focus: ['x' => 100, 'y' => 50]);
$rightPixel = ogPixel($right['variants']['thumb'], 100, 120);
expect($rightPixel[2])->toBeGreaterThan($rightPixel[0]);
$upload3 = UploadedFile::fake()->image('split3.jpg', 2000, 1000)->size(200);
copy($upload->getRealPath(), $upload3->getRealPath());
$boxed = $service->storePressReleaseImage($upload3, 3, 'local', noCrop: true);
[$w, $h] = getimagesize(Storage::disk('local')->path($boxed['variants']['cover']));
// 2000×1000 im 1280×580-Rahmen: Bild 1160×580, Ränder links/rechts je 60 px.
$edge = ogPixel($boxed['variants']['cover'], 10, 290);
expect($w)->toBe(1280)->and($h)->toBe(580)
->and($edge[0])->toBeGreaterThan(230)->and(abs($edge[0] - $edge[2]))->toBeLessThan(15);
});
test('a credit correction regenerates the og variant and the detail page uses it', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-og']);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
'slug' => 'og-test', 'published_ever_at' => now()->subDay(),
]);
$upload = UploadedFile::fake()->image('foto.jpg', 1600, 900)->size(100);
$stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local', ogCaption: 'Foto: Alt');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
'disk' => 'local', 'path' => $stored['path'], 'variants' => $stored['variants'], 'copyright' => 'Alt', 'published_ever_at' => now()->subDay(),
]);
$before = md5((string) Storage::disk('local')->get($stored['variants']['og']));
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer sehr langer Nachweis der gekürzt werden muss weil er nicht in den Balken passt Anna Beispiel Musterfirma GmbH', 'Einwand');
$after = md5((string) Storage::disk('local')->get($image->fresh()->variants['og']));
expect($after)->not->toBe($before);
$this->get('https://presseecho.test/de/press-release/og-test')
->assertSuccessful()
->assertSee('<meta property="og:image" content="'.route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'og']).'">', false);
});
test('teasers carry the credit in alt and title and the hero shows a caption', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-teaser']);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
'title' => 'Teaser-Meldung', 'published_at' => now()->subHour(), 'published_ever_at' => now()->subHour(),
]);
PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Anna Beispiel', 'ai_declaration' => 'ai_generated']);
expect($pr->fresh()->load('images', 'company')->cardImageCredit())->toBe('Foto: Anna Beispiel · KI-generiert');
$this->get('https://presseecho.test/de/category/energie-teaser')
->assertSuccessful()
->assertSee('alt="Teaser-Meldung · Foto: Anna Beispiel · KI-generiert"', false)
->assertSee('data-image-credit-teaser', false);
});

View file

@ -0,0 +1,136 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Legal\LegalAcceptanceGate;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Laravel\Sanctum\Sanctum;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Zustimmungsgate (Bildkennzeichnung Block G, Decision-Update E12, O2).
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$this->seed(LegalDocumentSeeder::class);
Mail::fake();
config()->set('billing.enforce_booking', false);
});
/**
* @return array{owner: User, pr: PressRelease}
*/
function gateDraft(): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$owner->companies()->attach($company->id, ['role' => 'owner']);
$pr = PressRelease::factory()->create([
'user_id' => $owner->id,
'company_id' => $company->id,
'category_id' => Category::factory()->create()->id,
'portal' => $company->portal->value,
'status' => 'draft',
]);
return compact('owner', 'pr');
}
test('with the gate off nothing is required and submitting works as before', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = gateDraft();
$this->actingAs($owner);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty();
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Neue Fassung unserer Bedingungen')
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->call('submitForReview')
->assertHasNoErrors();
expect($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft)
->and(LegalDocumentAcceptance::query()->count())->toBe(0);
});
test('with the gate on the submit requires an active acceptance of agb and guidelines and records both', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = gateDraft();
$this->actingAs($owner);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('type')->all())->toBe(['agb', 'richtlinien']);
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Neue Fassung unserer Bedingungen')
->assertSee('Version 0.6')
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->call('submitForReview')
->assertHasErrors(['legalAcceptanceConfirmed']);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
$component->set('legalAcceptanceConfirmed', true)->call('submitForReview')->assertHasNoErrors();
// Der synchrone Prüf-Job kann die Meldung direkt veröffentlichen — entscheidend ist: nicht mehr Entwurf.
expect($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft)
->and(LegalDocumentAcceptance::query()->where('user_id', $owner->id)->pluck('type')->sort()->values()->all())->toBe(['agb', 'richtlinien'])
->and(LegalDocumentAcceptance::query()->first()->context)->toBe('submit')
->and(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty();
// Neue AGB-Fassung: das Gate fordert genau diese eine Fassung erneut.
LegalDocument::factory()->agb()->create(['version' => '0.7', 'effective_from' => now()->subDay()->toDateString()]);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('version')->all())->toBe(['0.7']);
});
test('acceptances are insert-only', function () {
config()->set('legal.acceptance_gate', true);
['owner' => $owner] = gateDraft();
$gate = app(LegalAcceptanceGate::class);
$rows = $gate->accept($owner, $gate->requiredFor($owner), 'gate', '203.0.113.5', 'Pest');
expect($rows)->toHaveCount(2)
->and($rows[0]->ip_hmac)->toStartWith('v1:')
->and(fn () => $rows[0]->delete())->toThrow(LogicException::class)
->and($gate->accept($owner, $gate->requiredFor($owner), 'gate'))->toBe([]);
});
test('the api answers 428 with the required documents until they are accepted', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = gateDraft();
Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']);
$this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(428)
->assertJsonPath('required_documents.0.type', 'agb')
->assertJsonPath('required_documents.1.version', '0.2');
$this->postJson('/api/v1/legal/acceptances', ['documents' => [['type' => 'agb', 'version' => '0.5']]])
->assertUnprocessable();
$this->postJson('/api/v1/legal/acceptances', ['documents' => [
['type' => 'agb', 'version' => '0.6'],
['type' => 'richtlinien', 'version' => '0.2'],
]])->assertCreated()->assertJsonPath('required', []);
expect(LegalDocumentAcceptance::query()->where('context', 'api')->count())->toBe(2);
$this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])->assertSuccessful();
$this->getJson('/api/v1/legal/acceptances')->assertSuccessful()->assertJsonPath('gate_enabled', true)->assertJsonCount(2, 'accepted');
});