diff --git a/app/Enums/AiImageDeclaration.php b/app/Enums/AiImageDeclaration.php new file mode 100644 index 00000000..22c03742 --- /dev/null +++ b/app/Enums/AiImageDeclaration.php @@ -0,0 +1,66 @@ + __('Originalfoto'), + self::AiGenerated => __('KI-generiertes Bild'), + self::AiEdited => __('KI-bearbeitetes Bild'), + }; + } + + /** + * Öffentlicher Bildhinweis direkt am Bild — null bei Originalfotos + * (keine Nicht-KI-Zertifizierung in Portalstimme). + */ + public function publicLabel(): ?string + { + return match ($this) { + self::Original => null, + self::AiGenerated => __('Bildhinweis: KI-generierte Illustration.'), + self::AiEdited => __('Bildhinweis: KI-bearbeitetes Bild.'), + }; + } + + /** + * Optionsliste für Radios/Selects: value => label. + * + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/AiTextDeclaration.php b/app/Enums/AiTextDeclaration.php new file mode 100644 index 00000000..cd93346b --- /dev/null +++ b/app/Enums/AiTextDeclaration.php @@ -0,0 +1,70 @@ + __('Ohne generative KI erstellt'), + self::Assisted => __('Mit KI als Formulierungshilfe erstellt und redaktionell überarbeitet'), + self::MostlyAi => __('Überwiegend mit generativer KI erstellt und anschließend geprüft'), + self::FullyAi => __('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)'), + }; + } + + /** + * Öffentliches Label in Einreicher-Stimme — null, wenn kein Pflicht-Label + * fällig ist ("Ohne KI" wird bewusst nicht als Portal-Aussage zertifiziert, + * die Formulierungshilfe entspricht dem Entlastungsfall). + */ + public function publicLabel(): ?string + { + return match ($this) { + self::None, self::Assisted => null, + self::MostlyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.'), + self::FullyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'), + }; + } + + /** + * Optionsliste für Radios/Selects: value => label. + * + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Livewire/Concerns/InteractsWithAiDeclaration.php b/app/Livewire/Concerns/InteractsWithAiDeclaration.php new file mode 100644 index 00000000..e6d25399 --- /dev/null +++ b/app/Livewire/Concerns/InteractsWithAiDeclaration.php @@ -0,0 +1,52 @@ +validate([ + 'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())], + 'aiDeclarationConfirmed' => ['accepted'], + ], [ + '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.'), + ]); + } + + /** + * Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur + * gestempelt, wenn die Checkbox tatsächlich gesetzt wurde. + * + * @return array{ai_text_declaration: ?string, ai_declaration_confirmed_at: ?Carbon} + */ + protected function aiDeclarationAttributes(): array + { + return [ + 'ai_text_declaration' => $this->aiTextDeclaration, + 'ai_declaration_confirmed_at' => $this->aiDeclarationConfirmed ? now() : null, + ]; + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 109059b0..d66c1330 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\AiTextDeclaration; use App\Enums\Portal; use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseContentTier; @@ -71,6 +72,8 @@ class PressRelease extends Model 'placeholder_variant', 'backlink_url', 'keywords', + 'ai_text_declaration', + 'ai_declaration_confirmed_at', 'status', 'classification', 'classified_at', @@ -95,6 +98,8 @@ class PressRelease extends Model return [ 'portal' => Portal::class, 'placeholder_variant' => PressReleasePlaceholder::class, + 'ai_text_declaration' => AiTextDeclaration::class, + 'ai_declaration_confirmed_at' => 'datetime', 'status' => PressReleaseStatus::class, 'classification' => PressReleaseClassification::class, 'classified_at' => 'datetime', @@ -166,6 +171,25 @@ class PressRelease extends Model return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at'); } + /** + * Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) — + * jeder Submit erzeugt einen neuen, unveränderlichen Eintrag. + */ + public function aiDeclarations(): HasMany + { + return $this->hasMany(PressReleaseAiDeclaration::class)->orderByDesc('created_at'); + } + + /** + * Öffentliches KI-Label in Einreicher-Stimme — null, wenn nach dem + * deklarierten KI-Grad kein Pflicht-Label fällig ist (ohne KI / + * Formulierungshilfe) oder noch keine Deklaration vorliegt. + */ + public function aiTextPublicLabel(): ?string + { + return $this->ai_text_declaration?->publicLabel(); + } + /** * Öffentliche Detailseiten je Portal, auf dem die PM erscheint * (bei Portal::Both beide Spokes). Zentrale Quelle für Mail, diff --git a/app/Models/PressReleaseAiDeclaration.php b/app/Models/PressReleaseAiDeclaration.php new file mode 100644 index 00000000..cdeccaae --- /dev/null +++ b/app/Models/PressReleaseAiDeclaration.php @@ -0,0 +1,55 @@ + AiTextDeclaration::class, + 'image_declarations' => 'array', + 'confirmed_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } +} diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 391dae78..299084e8 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\AiImageDeclaration; use App\Enums\ImageLicenseType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -31,6 +32,7 @@ class PressReleaseImage extends Model 'rights_notes', 'rights_confirmed_at', 'is_ai_generated', + 'ai_declaration', 'is_preview', 'sort_order', 'width', @@ -48,6 +50,7 @@ class PressReleaseImage extends Model 'persons_consent' => 'boolean', 'rights_confirmed_at' => 'datetime', 'is_ai_generated' => 'boolean', + 'ai_declaration' => AiImageDeclaration::class, 'is_preview' => 'boolean', 'sort_order' => 'integer', 'width' => 'integer', diff --git a/app/Services/PressRelease/AiDeclarationRequiredException.php b/app/Services/PressRelease/AiDeclarationRequiredException.php new file mode 100644 index 00000000..ee55bdc6 --- /dev/null +++ b/app/Services/PressRelease/AiDeclarationRequiredException.php @@ -0,0 +1,19 @@ +assertAiDeclarationComplete($pressRelease); + $previous = $pressRelease->status; if ($word = $this->blacklist->findInPressRelease($pressRelease)) { @@ -75,6 +82,11 @@ class PressReleaseService ]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer'); + // Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt + // einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung — + // "Kunde hat am Datum X Option Y deklariert und bestätigt"). + $this->logAiDeclaration($pressRelease); + // KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand // des Ergebnisses übernimmt der Job über routeByClassification(). ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification'); @@ -397,6 +409,52 @@ class PressReleaseService } } + /** + * KI-Kennzeichnung vollständig? Pflicht sind der deklarierte KI-Grad des + * Textes, die verbindliche Bestätigung sowie eine Kennzeichnung für + * jedes Bild (Art. 50 KI-VO, Decision-Update 08.07.2026). + */ + private function assertAiDeclarationComplete(PressRelease $pressRelease): void + { + if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) { + throw new AiDeclarationRequiredException; + } + + if ($pressRelease->images()->whereNull('ai_declaration')->exists()) { + throw new AiDeclarationRequiredException( + __('Bitte für jedes Bild die KI-Kennzeichnung angeben (Originalfoto, KI-generiert oder KI-bearbeitet) — im Bild-Manager der Bearbeiten-Seite.'), + ); + } + } + + /** + * Schreibt den unveränderlichen Audit-Eintrag der aktuellen Deklaration — + * inkl. Bild-Snapshot zum Submit-Zeitpunkt. + */ + private function logAiDeclaration(PressRelease $pressRelease): void + { + $imageDeclarations = $pressRelease->images() + ->orderBy('sort_order') + ->get(['id', 'path', 'ai_declaration']) + ->map(fn (PressReleaseImage $image): array => [ + 'image_id' => $image->id, + 'path' => $image->path, + 'declaration' => $image->ai_declaration?->value, + ]) + ->values() + ->all(); + + PressReleaseAiDeclaration::query()->create([ + 'press_release_id' => $pressRelease->id, + 'user_id' => $pressRelease->user_id, + 'company_id' => $pressRelease->company_id, + 'text_declaration' => $pressRelease->ai_text_declaration?->value, + 'image_declarations' => $imageDeclarations ?: null, + 'confirmed_at' => $pressRelease->ai_declaration_confirmed_at, + 'created_at' => now(), + ]); + } + private function logStatusChange( PressRelease $pressRelease, ?PressReleaseStatus $from, diff --git a/database/factories/PressReleaseFactory.php b/database/factories/PressReleaseFactory.php index ac7de9c2..04fb559d 100644 --- a/database/factories/PressReleaseFactory.php +++ b/database/factories/PressReleaseFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\AiTextDeclaration; use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\Category; @@ -35,9 +36,25 @@ class PressReleaseFactory extends Factory 'status' => PressReleaseStatus::Draft->value, 'hits' => 0, 'no_export' => false, + // KI-Kennzeichnung (Art. 50 KI-VO): Standard „ohne KI", bestätigt — + // damit erfüllen Test-PMs den Submit-Guard. Gate-Tests setzen die + // Felder explizit auf null (siehe aiUndeclared()). + 'ai_text_declaration' => AiTextDeclaration::None->value, + 'ai_declaration_confirmed_at' => now(), ]; } + /** + * PM ohne KI-Deklaration — zum Testen des Submit-Gates (Art. 50 KI-VO). + */ + public function aiUndeclared(): static + { + return $this->state([ + 'ai_text_declaration' => null, + 'ai_declaration_confirmed_at' => null, + ]); + } + public function published(): static { return $this->state([ diff --git a/database/migrations/2026_07_08_172023_add_ai_declaration_to_press_releases.php b/database/migrations/2026_07_08_172023_add_ai_declaration_to_press_releases.php new file mode 100644 index 00000000..d45dc42a --- /dev/null +++ b/database/migrations/2026_07_08_172023_add_ai_declaration_to_press_releases.php @@ -0,0 +1,51 @@ +after(): mit Positionierung kopiert MySQL die große + * press_releases-Tabelle komplett (30+ Min auf Dev), am Tabellenende greift + * ALGORITHM=INSTANT. Die hasColumn-Guards machen die Migration nach einem + * Abbruch mitten im Lauf (08.07.2026 auf Dev passiert) wiederaufnehmbar. + */ +return new class extends Migration +{ + public function up(): void + { + if (! Schema::hasColumn('press_releases', 'ai_text_declaration')) { + Schema::table('press_releases', function (Blueprint $table): void { + $table->string('ai_text_declaration', 20)->nullable(); + }); + } + + if (! Schema::hasColumn('press_releases', 'ai_declaration_confirmed_at')) { + Schema::table('press_releases', function (Blueprint $table): void { + $table->timestamp('ai_declaration_confirmed_at')->nullable(); + }); + } + + if (! Schema::hasColumn('press_release_images', 'ai_declaration')) { + Schema::table('press_release_images', function (Blueprint $table): void { + $table->string('ai_declaration', 20)->nullable(); + }); + } + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table): void { + $table->dropColumn(['ai_text_declaration', 'ai_declaration_confirmed_at']); + }); + + Schema::table('press_release_images', function (Blueprint $table): void { + $table->dropColumn('ai_declaration'); + }); + } +}; diff --git a/database/migrations/2026_07_08_172023_create_press_release_ai_declarations_table.php b/database/migrations/2026_07_08_172023_create_press_release_ai_declarations_table.php new file mode 100644 index 00000000..5841fdf2 --- /dev/null +++ b/database/migrations/2026_07_08_172023_create_press_release_ai_declarations_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('press_release_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('company_id')->nullable()->constrained()->nullOnDelete(); + $table->string('text_declaration', 20); + // Snapshot je Bild zum Submit-Zeitpunkt: + // [{image_id, path, declaration}, ...] + $table->json('image_declarations')->nullable(); + $table->timestamp('confirmed_at'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('press_release_ai_declarations'); + } +}; diff --git a/database/migrations/2026_07_08_172113_backfill_ai_declaration_on_press_release_images.php b/database/migrations/2026_07_08_172113_backfill_ai_declaration_on_press_release_images.php new file mode 100644 index 00000000..036fcda5 --- /dev/null +++ b/database/migrations/2026_07_08_172113_backfill_ai_declaration_on_press_release_images.php @@ -0,0 +1,28 @@ +where('is_ai_generated', true) + ->whereNull('ai_declaration') + ->update(['ai_declaration' => 'ai_generated']); + } + + public function down(): void + { + // Bewusst leer: der Backfill ist eine reine Datenanreicherung; das + // Entfernen der Spalte übernimmt die zugehörige DDL-Migration. + } +}; diff --git a/resources/views/components/portal/ai-public-label.blade.php b/resources/views/components/portal/ai-public-label.blade.php new file mode 100644 index 00000000..f13776a0 --- /dev/null +++ b/resources/views/components/portal/ai-public-label.blade.php @@ -0,0 +1,17 @@ +{{-- + Öffentliches KI-Label (Art. 50 KI-VO) in Einreicher-Stimme. + + Neutrales ℹ-Icon, bewusst KEIN grünes Häkchen — das würde als redaktionelle + Prüfung/Freigabe durch das Portal gelesen. Rendert nichts, wenn kein + Pflicht-Label fällig ist (label = null). + + Verwendung: +--}} +@props(['label']) + +@if ($label) +
merge(['class' => 'flex items-start gap-2 rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12px] leading-[1.5] text-[color:var(--color-ink-2)]']) }}> + + {{ $label }} +
+@endif diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 6526733f..0d0b4250 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -62,7 +62,7 @@ @else -
+
{{ __('Veröffentlichung') }} {{ __('Pressemitteilung zur Prüfung einreichen') }} @@ -104,13 +104,40 @@ @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 + App\Livewire\Concerns\InteractsWithAiDeclaration bereit. --}} +
+
+ + {{ __('KI-Kennzeichnung') }} + * + +
+
+

+ {{ __('Wie wurde der Text dieser Pressemitteilung erstellt? Diese Angabe ist gesetzlich verpflichtend (Art. 50 KI-VO).') }} +

+ + @foreach (\App\Enums\AiTextDeclaration::cases() as $aiOption) + + @endforeach + + +

+ {{ __('Bei überwiegend oder vollständig KI-generierten Texten erscheint an der Veröffentlichung ein neutraler Hinweis in Ihrer Stimme („Nach Angaben des einreichenden Unternehmens …"). KI-generierte oder KI-bearbeitete Bilder kennzeichnen Sie direkt beim Bild-Upload.') }} +

+
+
+ {{-- Bestätigungen — Flux-Switches (Einheitlichkeit, 12.06.2026); der Alpine-State steuert weiterhin den Bestätigen-Button. --}}
{{ __('Ihre Bestätigungen') }} + x-text="[agb, images, contact, waiver, ai].filter(Boolean).length + ' / 5'">
@@ -145,6 +172,18 @@ :label="__('Mir ist bekannt, dass veröffentlichte Pressemitteilungen dauerhaft archiviert werden; eine spätere Entfernung ist nur als kostenpflichtige Serviceleistung möglich (gesetzliche Ansprüche bleiben unberührt).')" />
+ {{-- Verbindliche KI-Bestätigung (Art. 50 KI-VO): wire-gebunden, + weil der Bestätigungszeitpunkt zur Nachweisbarkeit im + Audit-Trail persistiert wird. --}} +
+ + +
@@ -154,7 +193,7 @@ + x-bind:disabled="! (agb && images && contact && waiver && ai)"> {{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index 35ca7ca5..f61fa5d1 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -463,8 +463,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Inhalt') }} + @if ($pr->ai_text_declaration) + + {{ __('KI-Deklaration:') }} {{ $pr->ai_text_declaration->label() }} + + @endif
+ {{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der + Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}} +
{!! $pr->renderedText() !!}
diff --git a/tests/Feature/PressReleaseAiDeclarationTest.php b/tests/Feature/PressReleaseAiDeclarationTest.php new file mode 100644 index 00000000..0aea4faf --- /dev/null +++ b/tests/Feature/PressReleaseAiDeclarationTest.php @@ -0,0 +1,289 @@ +seed(RolesAndPermissionsSeeder::class); + Queue::fake(); +}); + +function makeAiDeclarationCustomer(): array +{ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + + return [$customer, $company]; +} + +test('the text declaration maps to the attributed public label per decision update', function () { + expect(AiTextDeclaration::None->publicLabel())->toBeNull(); + expect(AiTextDeclaration::Assisted->publicLabel())->toBeNull(); + expect(AiTextDeclaration::MostlyAi->publicLabel()) + ->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.'); + expect(AiTextDeclaration::FullyAi->publicLabel()) + ->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'); +}); + +test('the image declaration maps to the public image hint per decision update', function () { + expect(AiImageDeclaration::Original->publicLabel())->toBeNull(); + expect(AiImageDeclaration::AiGenerated->publicLabel())->toBe('Bildhinweis: KI-generierte Illustration.'); + expect(AiImageDeclaration::AiEdited->publicLabel())->toBe('Bildhinweis: KI-bearbeitetes Bild.'); +}); + +test('submit is blocked without a complete ai declaration', function () { + /** @var TestCase $this */ + [$customer] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->aiUndeclared()->create([ + 'user_id' => $customer->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + $this->actingAs($customer); + + expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) + ->toThrow(AiDeclarationRequiredException::class); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); + expect(PressReleaseAiDeclaration::query()->count())->toBe(0); +}); + +test('submit is blocked while any image lacks its ai declaration', function () { + /** @var TestCase $this */ + [$customer] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->create([ + 'user_id' => $customer->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press-releases/'.$pr->id.'/images/title.jpg', + 'author' => 'Jane Doe', + 'license_type' => ImageLicenseType::Own->value, + 'rights_confirmed_at' => now(), + 'is_preview' => true, + 'sort_order' => 1, + 'ai_declaration' => null, + ]); + + $this->actingAs($customer); + + expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) + ->toThrow(AiDeclarationRequiredException::class); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + +test('each submit writes a versioned immutable audit entry with the image snapshot', function () { + /** @var TestCase $this */ + [$customer, $company] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'status' => PressReleaseStatus::Draft->value, + 'ai_text_declaration' => AiTextDeclaration::MostlyAi->value, + 'ai_declaration_confirmed_at' => now(), + ]); + + $image = $pr->images()->create([ + 'disk' => 'public', + 'path' => 'press-releases/'.$pr->id.'/images/title.jpg', + 'author' => 'Beispiel GmbH', + 'license_type' => ImageLicenseType::AiGenerated->value, + 'rights_confirmed_at' => now(), + 'is_ai_generated' => true, + 'ai_declaration' => AiImageDeclaration::AiGenerated->value, + 'is_preview' => true, + 'sort_order' => 1, + ]); + + $this->actingAs($customer); + + app(PressReleaseService::class)->submitForReview($pr); + + $entry = PressReleaseAiDeclaration::query()->sole(); + + expect($entry->press_release_id)->toBe($pr->id); + expect($entry->user_id)->toBe($customer->id); + expect($entry->company_id)->toBe($company->id); + expect($entry->text_declaration)->toBe(AiTextDeclaration::MostlyAi); + expect($entry->confirmed_at)->not->toBeNull(); + expect($entry->created_at)->not->toBeNull(); + expect($entry->image_declarations)->toBe([ + ['image_id' => $image->id, 'path' => $image->path, 'declaration' => 'ai_generated'], + ]); + + // Re-Submit nach Ablehnung erzeugt einen ZWEITEN Eintrag (Versionierung). + $pr->fresh()->update(['status' => PressReleaseStatus::Rejected->value]); + app(PressReleaseService::class)->submitForReview($pr->fresh()); + + expect(PressReleaseAiDeclaration::query()->count())->toBe(2); +}); + +test('the create editor requires the ai declaration only when submitting for review', function () { + /** @var TestCase $this */ + [$customer] = makeAiDeclarationCustomer(); + $category = Category::factory()->create(); + + $this->actingAs($customer); + + $component = LivewireVolt::test('customer.press-releases.create') + ->set('categoryId', $category->id) + ->set('title', 'Ein gültiger Titel für die PM') + ->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5)); + + // Einreichen ohne Deklaration → Fehler im Modal, keine PM angelegt. + $component + ->call('save', 'review') + ->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']); + + expect(PressRelease::query()->where('user_id', $customer->id)->count())->toBe(0); + + // Entwurf-Speichern bleibt ohne Deklaration möglich. + $component + ->call('save', 'draft') + ->assertHasNoErrors(); + + $draft = PressRelease::query()->where('user_id', $customer->id)->sole(); + expect($draft->ai_text_declaration)->toBeNull(); + expect($draft->ai_declaration_confirmed_at)->toBeNull(); +}); + +test('submitting from the create editor persists the declaration and the audit entry', function () { + /** @var TestCase $this */ + [$customer] = makeAiDeclarationCustomer(); + $category = Category::factory()->create(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('categoryId', $category->id) + ->set('title', 'Ein gültiger Titel für die PM') + ->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5)) + ->set('aiTextDeclaration', AiTextDeclaration::FullyAi->value) + ->set('aiDeclarationConfirmed', true) + ->call('save', 'review') + ->assertHasNoErrors(); + + $pr = PressRelease::query()->where('user_id', $customer->id)->sole(); + + expect($pr->status)->toBe(PressReleaseStatus::Review); + expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::FullyAi); + expect($pr->ai_declaration_confirmed_at)->not->toBeNull(); + expect($pr->aiTextPublicLabel()) + ->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'); + + $entry = PressReleaseAiDeclaration::query()->sole(); + expect($entry->press_release_id)->toBe($pr->id); + expect($entry->text_declaration)->toBe(AiTextDeclaration::FullyAi); +}); + +test('submitting from the detail page validates and persists the declaration', function () { + /** @var TestCase $this */ + [$customer, $company] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->aiUndeclared()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'portal' => $company->portal->value, + 'status' => PressReleaseStatus::Draft->value, + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('submitForReview') + ->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->set('aiTextDeclaration', AiTextDeclaration::Assisted->value) + ->set('aiDeclarationConfirmed', true) + ->call('submitForReview') + ->assertHasNoErrors(); + + $pr->refresh(); + expect($pr->status)->toBe(PressReleaseStatus::Review); + expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::Assisted); + // Formulierungshilfe = Entlastungsfall → kein öffentliches Pflicht-Label. + expect($pr->aiTextPublicLabel())->toBeNull(); + expect(PressReleaseAiDeclaration::query()->count())->toBe(1); +}); + +test('the submit modal renders the declaration options and the binding confirmation', function () { + /** @var TestCase $this */ + [$customer, $company] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->aiUndeclared()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'portal' => $company->portal->value, + 'status' => PressReleaseStatus::Draft->value, + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('KI-Kennzeichnung') + ->assertSee('Ohne generative KI erstellt') + ->assertSee('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)') + ->assertSee('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.'); +}); + +test('image uploads require the per-image ai declaration', function () { + /** @var TestCase $this */ + [$customer, $company] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'status' => 'draft', + ]); + + Storage::fake('public'); + $this->actingAs($customer); + + LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newCopyright', 'Foto: Jane Doe') + ->set('newAuthor', 'Jane Doe') + ->set('newLicenseType', ImageLicenseType::Own->value) + ->set('newPeopleRightsStatus', 'none') + ->set('newPropertyRightsStatus', 'none') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasErrors(['newAiDeclaration']); + + expect($pr->images()->count())->toBe(0); +});