version), die beim letzten Rendern im Submit-Modal * standen. Der Schalter bestätigt genau diese; wechselt zwischen Anzeige * und Absenden die Fassung, wird die Annahme abgelehnt und der neue * Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). `#[Locked]`: der * Browser kann die Werte nicht setzen (Umsetzungsaudit 29.09.2026, K-07). * * @var array */ #[Locked] public array $legalAcceptanceVersions = []; /** * Offene Fassungen für die Anzeige, einmal je Request ermittelt (OPT-7): * Rendering-Hook und jedes Submit-Modal der Seite (die Liste hat eines * je Zeile) teilen sich das Ergebnis. Nicht öffentlich, also nie Teil * des Livewire-Zustands. * * @var Collection|null */ private ?Collection $legalDocumentsForDisplay = null; /** * Livewire-Trait-Hook: läuft nach der Aktion, unmittelbar vor dem * Rendern — die Property trägt danach exakt die gezeigten Fassungen. */ public function renderingInteractsWithAiDeclaration(): void { $this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf($this->legalDocumentsToAccept()); } /** * Offene Fassungen, wie sie das Submit-Modal zeigt (Beschreibungsform * aus LegalAcceptanceGate::describe()). Wird von den Seiten an * `` übergeben. * * @return Collection */ protected function legalDocumentsForSubmit(): Collection { $gate = app(LegalAcceptanceGate::class); return $this->legalDocumentsToAccept()->map(fn (LegalDocument $document): array => $gate->describe($document))->values(); } /** * Ausgeschaltetes Gate: keine Abfrage. Fehlt bei aktivem Gate ein * Pflichtdokument, zeigt die Seite trotzdem an (K-07) — die * Konfigurationslücke wird gemeldet und erst das Absenden scheitert * (validateAiDeclaration()). * * @return Collection */ private function legalDocumentsToAccept(): Collection { if ($this->legalDocumentsForDisplay !== null) { return $this->legalDocumentsForDisplay; } $gate = app(LegalAcceptanceGate::class); if (! $gate->isEnabled()) { return $this->legalDocumentsForDisplay = collect(); } try { return $this->legalDocumentsForDisplay = $gate->requiredFor(auth()->user()); } catch (RuntimeException $exception) { report($exception); return $this->legalDocumentsForDisplay = collect(); } } /** * Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im * Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht. */ protected function validateAiDeclaration(): void { $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.'), ]; $required = $this->requiredLegalDocumentsForSubmit(); if ($required->isNotEmpty()) { $rules['legalAcceptanceConfirmed'] = ['accepted']; $messages['legalAcceptanceConfirmed.accepted'] = __('Bitte nehmen Sie die aktuelle Fassung der AGB und der Veröffentlichungsrichtlinien an.'); } $this->validate($rules, $messages); if ($required->isNotEmpty() && LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) { $this->legalAcceptanceConfirmed = false; throw ValidationException::withMessages([ 'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'), ]); } } /** * Annahme protokollieren (nach validateAiDeclaration, vor dem Einreichen). * Ohne Gate oder ohne offene Fassung passiert nichts. * * Wechselt die Fassung zwischen Validierung und Speichern, scheitert das * Einreichen wie in validateAiDeclaration() — nicht still ohne Annahme * weiter (K-07). */ protected function persistLegalAcceptances(): void { $required = $this->requiredLegalDocumentsForSubmit(); if ($required->isEmpty()) { return; } if (! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) { $this->legalAcceptanceConfirmed = false; $this->legalDocumentsForDisplay = null; throw ValidationException::withMessages([ 'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'), ]); } app(LegalAcceptanceGate::class)->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent()); $this->legalAcceptanceConfirmed = false; } /** * Verbindlicher, ungecachter Stand fürs Absenden. Ein fehlendes * Pflichtdokument (Konfigurationsfehler) wird gemeldet und verhindert * das Einreichen mit einer verständlichen Meldung statt eines 500ers. * * @return Collection * * @throws ValidationException */ private function requiredLegalDocumentsForSubmit(): Collection { try { return app(LegalAcceptanceGate::class)->requiredFor(auth()->user()); } catch (RuntimeException $exception) { report($exception); throw ValidationException::withMessages([ 'legalAcceptanceConfirmed' => __('Das Einreichen ist gerade nicht möglich, weil die aktuellen Bedingungen nicht geladen werden konnten. Bitte versuchen Sie es später erneut.'), ]); } } /** * 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, ]; } }