- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
7.9 KiB
PHP
209 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Concerns;
|
|
|
|
use App\Enums\AiTextDeclaration;
|
|
use App\Models\LegalDocument;
|
|
use App\Models\LegalDocumentAcceptance;
|
|
use App\Services\Legal\LegalAcceptanceGate;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Livewire\Attributes\Locked;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
|
|
*
|
|
* Gemeinsame Properties + Validierung der vier Customer-Submit-Pfade
|
|
* (Erstellen, Bearbeiten, Detailansicht, Liste). Die Selbst-Deklaration ist
|
|
* Pflicht bei JEDEM Einreichen: ohne KI-Grad des Textes und verbindliche
|
|
* Bestätigung kein Submit. Die Bestätigung wird nicht vorbelegt — sie muss
|
|
* bei jedem Submit erneut gesetzt werden (Audit-Trail pro Einreichung).
|
|
*/
|
|
trait InteractsWithAiDeclaration
|
|
{
|
|
public ?string $aiTextDeclaration = null;
|
|
|
|
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;
|
|
|
|
/**
|
|
* Fassungen (type => 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<string, string>
|
|
*/
|
|
#[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<int, LegalDocument>|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
|
|
* `<x-press-release-submit-modal :legal-documents>` übergeben.
|
|
*
|
|
* @return Collection<int, array{type: string, version: string, label: string, url: string}>
|
|
*/
|
|
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<int, LegalDocument>
|
|
*/
|
|
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<int, LegalDocument>
|
|
*
|
|
* @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,
|
|
];
|
|
}
|
|
}
|