From 075d0d931f521e6ecc24a94c464004617406e9a6 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 22 Sep 2026 10:12:17 +0000 Subject: [PATCH] =?UTF-8?q?legal/web:=20Zustimmungsgate=20je=20Dokumentver?= =?UTF-8?q?sion=20(aus=20bis=20AGB=200.7),=20OG-Variante=20mit=20eingebran?= =?UTF-8?q?ntem=20Bildnachweis,=20Nachweis=20in=20Teasern=20(Bildkennzeich?= =?UTF-8?q?nung=20Bl=C3=B6cke=20G=20und=20H)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 2 + .../Api/V1/LegalAcceptanceController.php | 67 +++++++++ .../Api/V1/PressReleaseController.php | 13 ++ .../Concerns/InteractsWithAiDeclaration.php | 40 ++++- app/Models/LegalDocumentAcceptance.php | 63 ++++++++ app/Models/PressRelease.php | 12 ++ app/Services/Image/ImageService.php | 129 +++++++++++++++- app/Services/Legal/LegalAcceptanceGate.php | 104 +++++++++++++ .../Images/ImageCorrectionService.php | 27 ++++ .../Images/PressReleaseImageUploader.php | 8 +- app/Support/ImageCredit.php | 20 +++ config/legal.php | 14 ++ ...reate_legal_document_acceptances_table.php | 37 +++++ .../press-release-submit-modal.blade.php | 31 +++- .../components/web/feed-top-item.blade.php | 3 +- .../views/components/web/focus-hero.blade.php | 6 +- .../components/web/focus-side-item.blade.php | 3 +- .../customer/press-releases/create.blade.php | 1 + .../customer/press-releases/edit.blade.php | 1 + .../customer/press-releases/index.blade.php | 1 + .../customer/press-releases/show.blade.php | 1 + resources/views/web/kategorie.blade.php | 8 +- resources/views/web/newsroom.blade.php | 8 +- resources/views/web/release-detail.blade.php | 2 +- routes/api.php | 5 + .../Images/OpenGraphAndTeaserCreditTest.php | 139 ++++++++++++++++++ .../Feature/Legal/LegalAcceptanceGateTest.php | 136 +++++++++++++++++ 27 files changed, 866 insertions(+), 15 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/LegalAcceptanceController.php create mode 100644 app/Models/LegalDocumentAcceptance.php create mode 100644 app/Services/Legal/LegalAcceptanceGate.php create mode 100644 database/migrations/2026_09_22_130000_create_legal_document_acceptances_table.php create mode 100644 tests/Feature/Images/OpenGraphAndTeaserCreditTest.php create mode 100644 tests/Feature/Legal/LegalAcceptanceGateTest.php diff --git a/.env.example b/.env.example index 19d853e5..27748cf1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Http/Controllers/Api/V1/LegalAcceptanceController.php b/app/Http/Controllers/Api/V1/LegalAcceptanceController.php new file mode 100644 index 00000000..8aa866d4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/LegalAcceptanceController.php @@ -0,0 +1,67 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index f51f37a9..64cbf544 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -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(), diff --git a/app/Livewire/Concerns/InteractsWithAiDeclaration.php b/app/Livewire/Concerns/InteractsWithAiDeclaration.php index e6d25399..45727ac0 100644 --- a/app/Livewire/Concerns/InteractsWithAiDeclaration.php +++ b/app/Livewire/Concerns/InteractsWithAiDeclaration.php @@ -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; } /** diff --git a/app/Models/LegalDocumentAcceptance.php b/app/Models/LegalDocumentAcceptance.php new file mode 100644 index 00000000..766469c0 --- /dev/null +++ b/app/Models/LegalDocumentAcceptance.php @@ -0,0 +1,63 @@ + '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 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function legalDocument(): BelongsTo + { + return $this->belongsTo(LegalDocument::class); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 9393f96c..99561d1d 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -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(); diff --git a/app/Services/Image/ImageService.php b/app/Services/Image/ImageService.php index 8135007e..3c156c53 100644 --- a/app/Services/Image/ImageService.php +++ b/app/Services/Image/ImageService.php @@ -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 */ - 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|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); diff --git a/app/Services/Legal/LegalAcceptanceGate.php b/app/Services/Legal/LegalAcceptanceGate.php new file mode 100644 index 00000000..ed490df3 --- /dev/null +++ b/app/Services/Legal/LegalAcceptanceGate.php @@ -0,0 +1,104 @@ + */ + 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 + */ + 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 $documents + * @return list + */ + 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']), + ]; + } +} diff --git a/app/Services/PressRelease/Images/ImageCorrectionService.php b/app/Services/PressRelease/Images/ImageCorrectionService.php index db40cba1..a99fb74b 100644 --- a/app/Services/PressRelease/Images/ImageCorrectionService.php +++ b/app/Services/PressRelease/Images/ImageCorrectionService.php @@ -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(); diff --git a/app/Services/PressRelease/Images/PressReleaseImageUploader.php b/app/Services/PressRelease/Images/PressReleaseImageUploader.php index f4c0e1b7..488f104d 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageUploader.php +++ b/app/Services/PressRelease/Images/PressReleaseImageUploader.php @@ -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'], diff --git a/app/Support/ImageCredit.php b/app/Support/ImageCredit.php index fe387022..0c7b436a 100644 --- a/app/Support/ImageCredit.php +++ b/app/Support/ImageCredit.php @@ -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". */ diff --git a/config/legal.php b/config/legal.php index f9098229..f0dbedc5 100644 --- a/config/legal.php +++ b/config/legal.php @@ -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), + ]; diff --git a/database/migrations/2026_09_22_130000_create_legal_document_acceptances_table.php b/database/migrations/2026_09_22_130000_create_legal_document_acceptances_table.php new file mode 100644 index 00000000..62cd2210 --- /dev/null +++ b/database/migrations/2026_09_22_130000_create_legal_document_acceptances_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 1a2cabdf..6b0288ab 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -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 @@ -72,7 +77,7 @@ @else {{-- Ohne eigenes Titelbild startet `images` auf true: die Checkbox ist ausgeblendet und darf den Bestätigen-Button nicht blockieren. --}} -
+
{{ __('Veröffentlichung') }} {{ __('Pressemitteilung zur Prüfung freigeben') }} @@ -130,6 +135,28 @@ @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). --}} +
+
+ {{ __('Neue Fassung unserer Bedingungen') }} * +
+
+

{{ __('Vor dem Einreichen nehmen Sie bitte die aktuelle Fassung an:') }}

+
    + @foreach ($legalDocumentsToAccept as $legalDocument) +
  • {{ $legalDocument['label'] }} — {{ __('Version :version', ['version' => $legalDocument['version']]) }}
  • + @endforeach +
+ + +
+
+ @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 @@ + x-bind:disabled="! (agb && images && contact && waiver && ai && legal)"> {{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
diff --git a/resources/views/components/web/feed-top-item.blade.php b/resources/views/components/web/feed-top-item.blade.php index c2890189..69dde726 100644 --- a/resources/views/components/web/feed-top-item.blade.php +++ b/resources/views/components/web/feed-top-item.blade.php @@ -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 @@
@if ($imageUrl) - {{ $title }} + {{ $imageCredit ? $title.' · '.$imageCredit : $title }} @elseif ($placeholderUrl) @else diff --git a/resources/views/components/web/focus-hero.blade.php b/resources/views/components/web/focus-hero.blade.php index 9f8d4527..9e027f1e 100644 --- a/resources/views/components/web/focus-hero.blade.php +++ b/resources/views/components/web/focus-hero.blade.php @@ -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 @@
@if ($leadImageUrl) - {{ $leadTitle }}
+ @if ($leadImageCredit) + {{ $leadImageCredit }} + @endif @elseif ($leadPlaceholderUrl) diff --git a/resources/views/components/web/focus-side-item.blade.php b/resources/views/components/web/focus-side-item.blade.php index 68b0ac94..0f091ff5 100644 --- a/resources/views/components/web/focus-side-item.blade.php +++ b/resources/views/components/web/focus-side-item.blade.php @@ -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 @@
@if ($imageUrl) - + @elseif ($placeholderUrl) @else diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index beef155a..5a92bce9 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -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(); diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 82924c99..7f1d9185 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -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(); diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index cdc3195c..7b485fdf 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -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 { diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index d05a739b..5794369e 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -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 { diff --git a/resources/views/web/kategorie.blade.php b/resources/views/web/kategorie.blade.php index f51279e8..d29161b9 100644 --- a/resources/views/web/kategorie.blade.php +++ b/resources/views/web/kategorie.blade.php @@ -171,7 +171,13 @@ Text im Flow (min-h + flex), damit lange Titel auf Mobile nicht geclippt werden. --}}
@if ($leadImageUrl) - {{ $leadRelease->title }} + @php + $leadImageCredit = $leadRelease->cardImageCredit(); + @endphp + {{ $leadImageCredit ? $leadRelease->title.' · '.$leadImageCredit : $leadRelease->title }} + @if ($leadImageCredit) + {{ $leadImageCredit }} + @endif @elseif ($leadPlaceholderUrl) @else diff --git a/resources/views/web/newsroom.blade.php b/resources/views/web/newsroom.blade.php index 7bdb0122..917aaa2a 100644 --- a/resources/views/web/newsroom.blade.php +++ b/resources/views/web/newsroom.blade.php @@ -192,7 +192,13 @@ Text im Flow (min-h + flex), damit lange Titel auf Mobile nicht geclippt werden. --}}
@if ($leadImageUrl) - {{ $leadRelease->title }} + @php + $leadImageCredit = $leadRelease->cardImageCredit(); + @endphp + {{ $leadImageCredit ? $leadRelease->title.' · '.$leadImageCredit : $leadRelease->title }} + @if ($leadImageCredit) + {{ $leadImageCredit }} + @endif @elseif ($leadPlaceholderUrl) @else diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index 677926d7..35e08948 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -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') diff --git a/routes/api.php b/routes/api.php index 46f008e2..ea63a517 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); }); diff --git a/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php b/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php new file mode 100644 index 00000000..7036d09b --- /dev/null +++ b/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php @@ -0,0 +1,139 @@ +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('', 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); +}); diff --git a/tests/Feature/Legal/LegalAcceptanceGateTest.php b/tests/Feature/Legal/LegalAcceptanceGateTest.php new file mode 100644 index 00000000..a5ee78e8 --- /dev/null +++ b/tests/Feature/Legal/LegalAcceptanceGateTest.php @@ -0,0 +1,136 @@ +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'); +});