- 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>
130 lines
4.3 KiB
PHP
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']),
|
|
];
|
|
}
|
|
}
|