presseportale/app/Services/Legal/LegalAcceptanceGate.php
Kevin Adametz 34fa342c7f tests/legal: Testdatenbank-Guard vor den DB-Traits, Flags in phpunit.xml gepinnt, Zustimmung an gezeigte Fassung gebunden (Umsetzungsaudit 25.09.: N22, N06, 5.3)
- Tests\TestCase prüft die Standardverbindung in setUpTraits(), also vor
  RefreshDatabase — bisher erst nach dem Refresh (N22). Eigener Test.
- phpunit.xml pinnt IMAGE_RIGHTS_V2, IMAGE_PRECHECK(_STAGE) und
  LEGAL_ACCEPTANCE_GATE auf die Vorgaben aus .env.example; die Suite hing
  sonst an der lokalen .env (Audit Abschnitt 6).
- Zustimmungsgate: die Livewire-Komponenten tragen die beim Rendern gezeigten
  Fassungen (Trait-Hook rendering); weicht die aktuelle Fassung beim Absenden
  ab, wird die Annahme abgelehnt und der neue Wortlaut gezeigt. Ein fehlendes
  Pflichtdokument ist bei aktivem Gate ein Konfigurationsfehler (N06).
- Audit vom 25.09., Decision-Update vom 22.09. und Plan Fassung 2 versioniert;
  die Antwortkopie „…-211.md" entfernt; U10-Formulierung im Plan präzisiert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 13:35:14 +00:00

130 lines
4.3 KiB
PHP

<?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;
use RuntimeException;
/**
* 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(function (string $type): LegalDocument {
// Ein fehlendes Pflichtdokument ist ein Konfigurationsfehler und
// darf das Gate nicht still verkleinern (Umsetzungsaudit
// 25.09.2026, N06).
return LegalDocument::current($type)
?? throw new RuntimeException(sprintf('Zustimmungsgate: kein gültiges Dokument vom Typ „%s“ hinterlegt (legal_documents).', $type));
})
->reject(fn (LegalDocument $document): bool => $this->hasAccepted($user, $document))
->values();
}
/**
* Fassungen je Typ, wie sie dem Nutzer gerade gezeigt werden — die
* Bestätigung im Browser wird an genau diese Kennungen gebunden.
*
* @param iterable<int, LegalDocument> $documents
* @return array<string, string> type => version
*/
public static function versionsOf(iterable $documents): array
{
$versions = [];
foreach ($documents as $document) {
$versions[$document->type] = $document->version;
}
ksort($versions);
return $versions;
}
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']),
];
}
}