diff --git a/app/Livewire/Admin/ConfirmPublishDpa.php b/app/Livewire/Admin/ConfirmPublishDpa.php new file mode 100644 index 0000000..64ce619 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPublishDpa.php @@ -0,0 +1,45 @@ +authorize('dpa.manage'); + + $version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + + $this->uuid = $uuid; + $this->version = $version->version; + } + + public function confirm(): void + { + $this->authorize('dpa.manage'); + $this->dispatch('dpa-publish-confirmed', uuid: $this->uuid); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-publish-dpa'); + } +} diff --git a/app/Livewire/Admin/ProcessingAgreements.php b/app/Livewire/Admin/ProcessingAgreements.php new file mode 100644 index 0000000..a3fb285 --- /dev/null +++ b/app/Livewire/Admin/ProcessingAgreements.php @@ -0,0 +1,129 @@ +authorize('dpa.manage'); + } + + public function upload(): void + { + $this->authorize('dpa.manage'); + + $data = $this->validate([ + 'version' => 'required|string|max:64|unique:dpa_versions,version', + 'note' => 'nullable|string|max:2000', + // PDF only, and a real size limit: this is handed to customers as + // the contract between us, not as an editable file. + 'agreement' => 'required|file|mimes:pdf|max:10240', + 'measures' => 'nullable|file|mimes:pdf|max:10240', + ]); + + // The private disk. An agreement is not a public asset, and a guessable + // URL to one would be a list of who our customers are. + $version = DpaVersion::query()->create([ + 'version' => $data['version'], + 'note' => $data['note'] ?: null, + 'agreement_path' => $this->agreement->store('dpa', 'local'), + 'measures_path' => $this->measures?->store('dpa', 'local'), + 'created_by' => Auth::guard('operator')->id(), + ]); + + $this->reset('version', 'note', 'agreement', 'measures'); + + $this->dispatch('notify', message: __('dpa_admin.uploaded', ['version' => $version->version])); + } + + /** + * Put a version in force. + * + * Deliberately separate from the upload: every customer who had accepted the + * previous one is outstanding again from this moment, and that is not + * something to do by dropping a file on a form. + */ + #[On('dpa-publish-confirmed')] + public function publish(string $uuid): void + { + $this->authorize('dpa.manage'); + + $version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + + if ($version->isPublished()) { + return; + } + + $version->update(['published_at' => now()]); + + $this->dispatch('notify', message: __('dpa_admin.published', ['version' => $version->version])); + } + + public function render() + { + $this->authorize('dpa.manage'); + + $current = app(ProcessingAgreement::class)->current(); + + // How many customers are outstanding on the version in force. Counted + // against customers who are actually still customers — a closed record + // is not somebody we are waiting on. + $customers = Customer::query()->whereNull('closed_at')->count(); + $accepted = $current === null ? 0 : $current->acceptances()->count(); + + return view('livewire.admin.processing-agreements', [ + 'versions' => DpaVersion::query()->withCount('acceptances')->latest('id')->get(), + 'current' => $current, + 'customers' => $customers, + 'accepted' => $accepted, + 'outstanding' => max(0, $customers - $accepted), + ]); + } + + /** Whether a stored file is actually there, for the list. */ + public function exists(?string $path): bool + { + return $path !== null && Storage::disk('local')->exists($path); + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 1842afb..dd4c3eb 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -10,6 +10,7 @@ use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Services\Billing\CustomDomainAccess; use App\Services\Billing\WithdrawalRight; +use App\Services\Legal\ProcessingAgreement; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -396,6 +397,32 @@ class Settings extends Component * never seen this card can still name the method — so the refusal is made * where the mutation is, once, in the customer's own words. */ + /** + * Accept the processing agreement in force. + * + * One press, no dialogue: this is an agreement, not a deletion, and putting + * a confirmation in front of it would only make the record harder to obtain. + * The evidence — the moment, the address, the login — is taken by the + * service, so nothing here can record an acceptance from an address it + * invented. + */ + public function acceptProcessingAgreement(): void + { + $c = $this->requireCustomer(); + + if ($c === null) { + return; + } + + if (app(ProcessingAgreement::class)->accept($c) === null) { + // Nothing published yet. The card does not render in that case, so + // reaching this line means somebody posted to it directly. + return; + } + + $this->dispatch('notify', message: __('dpa.accepted_notice')); + } + #[On('withdrawal-confirmed')] public function withdraw(): void { @@ -482,6 +509,11 @@ class Settings extends Component // thing somebody can choose. An unrecorded customer sees neither // selected, which is the truth about their record. 'customerTypes' => Customer::TYPES, + // The processing agreement in force and this customer's acceptance + // of THAT version — an acceptance of a superseded one is history, + // not an answer (see ProcessingAgreement). + 'dpa' => ($dpaService = app(ProcessingAgreement::class))->current(), + 'dpaAcceptance' => $dpaService->acceptanceOf($c), ]); } } diff --git a/app/Models/DpaAcceptance.php b/app/Models/DpaAcceptance.php new file mode 100644 index 0000000..b715121 --- /dev/null +++ b/app/Models/DpaAcceptance.php @@ -0,0 +1,42 @@ + 'datetime']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function version(): BelongsTo + { + return $this->belongsTo(DpaVersion::class, 'dpa_version_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/DpaVersion.php b/app/Models/DpaVersion.php new file mode 100644 index 0000000..16bc583 --- /dev/null +++ b/app/Models/DpaVersion.php @@ -0,0 +1,60 @@ + 'datetime']; + } + + /** + * The version in force: the newest published one. + * + * Newest by publication, not by id — a version prepared earlier and + * published later is the later one, and the id says nothing about that. + */ + public static function current(): ?self + { + return static::query() + ->whereNotNull('published_at') + ->where('published_at', '<=', now()) + ->latest('published_at') + ->first(); + } + + public function isPublished(): bool + { + return $this->published_at !== null && $this->published_at->isPast(); + } + + public function acceptances(): HasMany + { + return $this->hasMany(DpaAcceptance::class); + } + + public function operator() + { + return $this->belongsTo(Operator::class, 'created_by'); + } +} diff --git a/app/Services/Legal/ProcessingAgreement.php b/app/Services/Legal/ProcessingAgreement.php new file mode 100644 index 0000000..b8e8e68 --- /dev/null +++ b/app/Services/Legal/ProcessingAgreement.php @@ -0,0 +1,78 @@ +current(); + + if ($customer === null || $current === null) { + return null; + } + + return DpaAcceptance::query() + ->where('customer_id', $customer->id) + ->where('dpa_version_id', $current->id) + ->first(); + } + + public function accepted(?Customer $customer): bool + { + return $this->acceptanceOf($customer) !== null; + } + + /** + * Record an acceptance. + * + * firstOrCreate over the pair the unique index covers: pressing twice is not + * two agreements, and a double click must not become a duplicate-key error + * in front of somebody agreeing to a contract. + * + * The address is read here rather than passed in, so no caller can record an + * acceptance as coming from somewhere it invented. + */ + public function accept(Customer $customer, ?string $ip = null): ?DpaAcceptance + { + $current = $this->current(); + + if ($current === null) { + return null; + } + + return DpaAcceptance::query()->firstOrCreate( + ['customer_id' => $customer->id, 'dpa_version_id' => $current->id], + [ + 'user_id' => Auth::id(), + 'ip' => $ip ?? request()->ip(), + 'accepted_at' => now(), + ], + ); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index d77f9b2..79d027e 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -76,6 +76,10 @@ final class Navigation // it is sent from, not under Betrieb: both are about how mail // leaves this installation. ['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'], + // The processing agreement. Under System with the other things + // that are true of the whole installation rather than of one + // customer. + ['admin.dpa', 'file-text', 'dpa', 'dpa.manage'], // System, not Betrieb: the tunnel is how the console reaches // the estate at all. Nothing is provisioned, monitored or // updated through anything else, so it belongs beside the other diff --git a/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php new file mode 100644 index 0000000..61f93f2 --- /dev/null +++ b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php @@ -0,0 +1,76 @@ +id(); + $table->uuid()->unique(); + // What the operator calls it — "1.0", "2026-08", whatever the + // document itself says. Unique, because two documents with one name + // makes every acceptance ambiguous. + $table->string('version', 64)->unique(); + $table->string('agreement_path'); + $table->string('measures_path')->nullable(); + $table->text('note')->nullable(); + // Null while it is being prepared. Only a published version is + // shown to anybody, and only the newest published one is current. + $table->timestamp('published_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('operators')->nullOnDelete(); + $table->timestamps(); + + $table->index('published_at'); + }); + + Schema::create('dpa_acceptances', function (Blueprint $table) { + $table->id(); + $table->uuid()->unique(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->foreignId('dpa_version_id')->constrained('dpa_versions')->cascadeOnDelete(); + // The login that pressed it, kept as evidence even when that user is + // later removed — the acceptance was still made. + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('ip', 45)->nullable(); + $table->timestamp('accepted_at'); + $table->timestamps(); + + // One acceptance per customer per version. Pressing twice is not two + // agreements, and the unique index is what makes that true rather + // than a check somebody can forget. + $table->unique(['customer_id', 'dpa_version_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('dpa_acceptances'); + Schema::dropIfExists('dpa_versions'); + } +}; diff --git a/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php new file mode 100644 index 0000000..42f4213 --- /dev/null +++ b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php @@ -0,0 +1,40 @@ +forgetCachedPermissions(); + + $permission = Permission::findOrCreate('dpa.manage', 'operator'); + + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'operator')->givePermissionTo($permission); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::where('name', 'dpa.manage')->where('guard_name', 'operator')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index e6fd38b..c8ed3fd 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'dpa' => 'AV-Vertrag', 'mail_preview' => 'E-Mail-Vorschau', 'inbox' => 'Posteingang', 'mail_log' => 'Postausgang', diff --git a/lang/de/dpa.php b/lang/de/dpa.php new file mode 100644 index 0000000..2760de6 --- /dev/null +++ b/lang/de/dpa.php @@ -0,0 +1,17 @@ + 'AV-Vertrag & TOM', + 'sub' => 'Der Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO samt Dokumentation der technischen und organisatorischen Maßnahmen — für Ihre eigene Dokumentation und Prüfung.', + 'state_accepted' => 'Abgeschlossen', + 'state_open' => 'Offen', + 'read_agreement' => 'AV-Vertrag ansehen', + 'read_measures' => 'TOM ansehen', + 'version' => 'Fassung :version', + 'accept_hint' => 'Bitte lesen Sie den Vertrag und schließen Sie ihn ab. Wir halten dazu Zeitpunkt, Fassung und IP-Adresse fest; eine Unterschrift auf Papier ist nach Art. 28 Abs. 9 DSGVO nicht nötig.', + 'accept_cta' => 'Zur Kenntnis genommen und abgeschlossen', + 'accepted_on' => 'Fassung :version abgeschlossen am :when. Bei einer neuen Fassung melden wir uns und bitten Sie erneut um Ihre Zustimmung.', + 'accepted_notice' => 'AV-Vertrag abgeschlossen. Sie finden ihn jederzeit hier.', +]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php new file mode 100644 index 0000000..4ac6ec3 --- /dev/null +++ b/lang/de/dpa_admin.php @@ -0,0 +1,35 @@ + 'Rechtliches', + 'title' => 'AV-Vertrag & TOM', + 'subtitle' => 'Das Dokument, das Ihre Kunden nach Art. 28 DSGVO abschließen. Den Text laden Sie hoch — geschrieben wird er von Ihrer Rechtsberatung, nicht von dieser Anwendung.', + 'upload_title' => 'Neue Fassung hochladen', + 'upload_sub' => 'PDF, höchstens 10 MB. Die TOM sind eine Anlage zum Vertrag und werden mit derselben Fassung geführt.', + 'version' => 'Bezeichnung der Fassung', + 'version_hint' => 'Wie das Dokument selbst sie nennt — „1.0", „2026-08".', + 'note' => 'Interne Notiz', + 'note_hint' => 'Optional, nur für die Konsole.', + 'file_agreement' => 'AV-Vertrag (PDF)', + 'file_measures' => 'TOM (PDF)', + 'upload_cta' => 'Hochladen', + 'uploaded' => 'Fassung :version hochgeladen. Sie ist noch nicht in Kraft.', + 'current_title' => 'In Kraft', + 'none_yet' => 'Noch keine Fassung veröffentlicht. Solange nichts in Kraft ist, sehen Ihre Kunden den Punkt nicht.', + 'in_force_since' => 'seit :when', + 'accepted_count' => ':accepted von :total Kunden haben abgeschlossen.', + 'outstanding' => ':count offen', + 'versions_title' => 'Fassungen', + 'no_versions' => 'Noch nichts hochgeladen.', + 'col_version' => 'Fassung', + 'col_state' => 'Status', + 'col_accepted' => 'Abschlüsse', + 'col_files' => 'Dateien', + 'state_current' => 'In Kraft', + 'state_superseded' => 'Abgelöst', + 'state_draft' => 'Entwurf', + 'publish' => 'In Kraft setzen', + 'published' => 'Fassung :version ist in Kraft.', + 'publish_title' => 'Fassung :version in Kraft setzen?', + 'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index e008984..7b5bbd6 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'dpa' => 'Processing agreement', 'mail_preview' => 'Mail preview', 'inbox' => 'Inbox', 'mail_log' => 'Mail sent', diff --git a/lang/en/dpa.php b/lang/en/dpa.php new file mode 100644 index 0000000..c99e0e4 --- /dev/null +++ b/lang/en/dpa.php @@ -0,0 +1,17 @@ + 'Processing agreement & TOMs', + 'sub' => 'The data-processing agreement under Art. 28 GDPR together with the documentation of technical and organisational measures — for your own records and audits.', + 'state_accepted' => 'Concluded', + 'state_open' => 'Outstanding', + 'read_agreement' => 'Read the agreement', + 'read_measures' => 'Read the measures', + 'version' => 'Version :version', + 'accept_hint' => 'Please read the agreement and conclude it. We record the moment, the version and the IP address; a signature on paper is not required (Art. 28(9) GDPR).', + 'accept_cta' => 'Read and concluded', + 'accepted_on' => 'Version :version concluded on :when. When a new version is issued we will tell you and ask again.', + 'accepted_notice' => 'Agreement concluded. You can find it here at any time.', +]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php new file mode 100644 index 0000000..5a97e5e --- /dev/null +++ b/lang/en/dpa_admin.php @@ -0,0 +1,35 @@ + 'Legal', + 'title' => 'Processing agreement & TOMs', + 'subtitle' => 'The document your customers conclude under Art. 28 GDPR. You upload the text — it is written by your lawyer, not by this application.', + 'upload_title' => 'Upload a new version', + 'upload_sub' => 'PDF, at most 10 MB. The measures are an annex to the agreement and are versioned with it.', + 'version' => 'Version label', + 'version_hint' => 'What the document itself calls it — "1.0", "2026-08".', + 'note' => 'Internal note', + 'note_hint' => 'Optional, console only.', + 'file_agreement' => 'Agreement (PDF)', + 'file_measures' => 'Measures (PDF)', + 'upload_cta' => 'Upload', + 'uploaded' => 'Version :version uploaded. It is not in force yet.', + 'current_title' => 'In force', + 'none_yet' => 'No version published yet. While nothing is in force, customers do not see the section.', + 'in_force_since' => 'since :when', + 'accepted_count' => ':accepted of :total customers have concluded it.', + 'outstanding' => ':count outstanding', + 'versions_title' => 'Versions', + 'no_versions' => 'Nothing uploaded yet.', + 'col_version' => 'Version', + 'col_state' => 'State', + 'col_accepted' => 'Conclusions', + 'col_files' => 'Files', + 'state_current' => 'In force', + 'state_superseded' => 'Superseded', + 'state_draft' => 'Draft', + 'publish' => 'Put in force', + 'published' => 'Version :version is in force.', + 'publish_title' => 'Put version :version in force?', + 'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.', +]; diff --git a/resources/views/livewire/admin/confirm-publish-dpa.blade.php b/resources/views/livewire/admin/confirm-publish-dpa.blade.php new file mode 100644 index 0000000..79e75e0 --- /dev/null +++ b/resources/views/livewire/admin/confirm-publish-dpa.blade.php @@ -0,0 +1,15 @@ +
+
+ + + +
+

{{ __('dpa_admin.publish_title', ['version' => $version]) }}

+

{{ __('dpa_admin.publish_body') }}

+
+
+
+ {{ __('common.cancel') }} + {{ __('dpa_admin.publish') }} +
+
diff --git a/resources/views/livewire/admin/processing-agreements.blade.php b/resources/views/livewire/admin/processing-agreements.blade.php new file mode 100644 index 0000000..e52bad8 --- /dev/null +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -0,0 +1,151 @@ +{{-- + The processing agreement, in the console. + + Upload left, what is in force and who has it right, the versions underneath. + Uploading and publishing are two acts on purpose: publishing leaves every + customer outstanding again, and that is not something to trigger by dropping + a file on a form. +--}} +
+ +
+

{{ __('dpa_admin.eyebrow') }}

+

+ {{ __('dpa_admin.title') }} +

+

{{ __('dpa_admin.subtitle') }}

+
+ +
+ + {{-- ── Upload ─────────────────────────────────────────────────────── --}} +
+
+

{{ __('dpa_admin.upload_title') }}

+

{{ __('dpa_admin.upload_sub') }}

+
+ +
+ + +
+ +
+
+ + + @error('agreement')

{{ $message }}

@enderror +
+
+ + + @error('measures')

{{ $message }}

@enderror +
+
+ +
+ + {{ __('dpa_admin.upload_cta') }} + +
+
+ + {{-- ── What is in force ───────────────────────────────────────────── --}} +
+

{{ __('dpa_admin.current_title') }}

+ + @if ($current === null) +

{{ __('dpa_admin.none_yet') }}

+ @else +

{{ $current->version }}

+ {{-- R19: stored in UTC, read on the wall clock. --}} +

{{ __('dpa_admin.in_force_since', ['when' => $current->published_at->local()->isoFormat('LL')]) }}

+ +
+

+ {{ __('dpa_admin.accepted_count', ['accepted' => $accepted, 'total' => $customers]) }} +

+ @if ($outstanding > 0) +

{{ __('dpa_admin.outstanding', ['count' => $outstanding]) }}

+ @endif +
+ @endif +
+
+ + {{-- ── The versions ───────────────────────────────────────────────────── --}} +
+
+

{{ __('dpa_admin.versions_title') }}

+
+ + @if ($versions->isEmpty()) +

{{ __('dpa_admin.no_versions') }}

+ @else +
+ + + + + + + + + + + + @foreach ($versions as $v) + + + + + + + + @endforeach + +
{{ __('dpa_admin.col_version') }}{{ __('dpa_admin.col_state') }}{{ __('dpa_admin.col_accepted') }}{{ __('dpa_admin.col_files') }}
+ {{ $v->version }} + @if ($v->note) + {{ $v->note }} + @endif + + @if ($v->isPublished()) + + {{ $current?->id === $v->id ? __('dpa_admin.state_current') : __('dpa_admin.state_superseded') }} + + {{ $v->published_at->local()->isoFormat('LL') }} + @else + {{ __('dpa_admin.state_draft') }} + @endif + {{ $v->acceptances_count }} + + + @if (! $v->isPublished()) + {{-- R23: a modal, because publishing leaves every + customer outstanding again. --}} + + {{ __('dpa_admin.publish') }} + + @endif +
+
+ @endif +
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2b1d584..05242c8 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -338,6 +338,69 @@ @endif + {{-- ── AV-Vertrag & TOM ────────────────────────────────────── + Art. 28(3) DSGVO wants a contract wherever personal data is + processed on somebody else's behalf, which is the whole of what + this product does. "In writing" there includes electronic form, + so the document plus a recorded acceptance is enough — and the + customer needs to be able to READ it without asking us for it, + because that is what the website promises. + + Nothing renders until an operator has published a version: a + card offering an agreement that does not exist would be worse + than the silence. --}} + @if ($dpa !== null) +
+
+
+

{{ __('dpa.title') }}

+

{{ __('dpa.sub') }}

+
+ @if ($dpaAcceptance) + + {{ __('dpa.state_accepted') }} + + @else + + {{ __('dpa.state_open') }} + + @endif +
+ +
+ + {{ __('dpa.read_agreement') }} + + @if ($dpa->measures_path) + + {{ __('dpa.read_measures') }} + + @endif + {{ __('dpa.version', ['version' => $dpa->version]) }} +
+ + @if ($dpaAcceptance) + {{-- What was agreed, when, and by whom — the same three + facts the register holds. R19: stored in UTC, read on + the wall clock. --}} +

+ {{ __('dpa.accepted_on', [ + 'version' => $dpa->version, + 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), + ]) }} +

+ @else +
+

{{ __('dpa.accept_hint') }}

+ + {{ __('dpa.accept_cta') }} + +
+ @endif +
+ @endif +
diff --git a/routes/admin.php b/routes/admin.php index c236e5e..b4b218c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -59,6 +59,26 @@ Route::get('/mail/preview/{key}', function (string $key) { // into a console page: this is the document itself. return response($mailable->render()); })->name('mail.preview.show'); +// The processing agreement (Art. 28 DSGVO): the document, the version in force, +// and who has accepted it. The TEXT is uploaded, never written here. +Route::get('/processing-agreement', Admin\ProcessingAgreements::class)->name('dpa'); +Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, string $which) { + abort_unless(auth('operator')->user()?->can('dpa.manage') ?? false, 403); + abort_unless(in_array($which, ['agreement', 'measures'], true), 404); + + $version = App\Models\DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + $path = $which === 'agreement' ? $version->agreement_path : $version->measures_path; + + abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); + + // Inline: a contract is read before it is filed, and a download that opens + // nowhere is a download somebody has to go looking for. + return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline', + ]); +})->name('dpa.file'); + // The answers an operator gives over and over, written once — see // Admin\MailTemplates. Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates'); diff --git a/routes/web.php b/routes/web.php index e7e7cfe..1a2436a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -304,6 +304,25 @@ $portal = function () { ); })->name('invoices.pdf'); + // The processing agreement the customer is being asked to accept — the + // version in FORCE, never one named in the address: which document + // applies is ours to say, not a parameter. + Route::get('/processing-agreement/{which}', function (string $which) { + abort_unless(in_array($which, ['agreement', 'measures'], true), 404); + + $version = app(App\Services\Legal\ProcessingAgreement::class)->current(); + $path = $version === null + ? null + : ($which === 'agreement' ? $version->agreement_path : $version->measures_path); + + abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); + + return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline', + ]); + })->name('dpa.file'); + Route::get('/billing', Billing::class)->name('billing'); Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index 77f2c9b..87af37f 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -12,11 +12,11 @@ use Spatie\Permission\Models\Role; it('moves every permission and role to the operator guard, leaving none behind', function () { expect(Permission::where('guard_name', 'web')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(0) - // 17 from the original seed, plus customers.grant_plan and - // instances.restart — the latter created straight onto this guard, - // because since this migration `web` is where a permission goes to - // match nobody at all. - ->and(Permission::where('guard_name', 'operator')->count())->toBe(19) + // 17 from the original seed, plus customers.grant_plan, + // instances.restart and dpa.manage — the later ones created straight + // onto this guard, because since this migration `web` is where a + // permission goes to match nobody at all. + ->and(Permission::where('guard_name', 'operator')->count())->toBe(20) ->and(Role::where('guard_name', 'operator')->count())->toBe(6); }); @@ -157,7 +157,9 @@ it('preflights every customer conflict before mutating anything, listing all of // Nothing mutated: still exactly the pre-migration shape, not "moved and // then rolled back" — there is nothing here to roll back on a real // server, which is the whole reason this has to be checked up front. - expect(Permission::where('guard_name', 'web')->count())->toBe(19) + // Every capability this installation has, pushed back to `web` above to + // stage the pre-migration shape — the figure grows with each new one. + expect(Permission::where('guard_name', 'web')->count())->toBe(20) ->and(Permission::where('guard_name', 'operator')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(6) ->and(Role::where('guard_name', 'operator')->count())->toBe(0) diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php new file mode 100644 index 0000000..e978be3 --- /dev/null +++ b/tests/Feature/ProcessingAgreementTest.php @@ -0,0 +1,216 @@ +create(['email_verified_at' => now()]); + $customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]); + + return [$user, $customer]; +} + +function publishedDpa(string $version = '1.0', bool $withMeasures = true): DpaVersion +{ + Storage::fake('local'); + + return DpaVersion::query()->create([ + 'version' => $version, + 'agreement_path' => UploadedFile::fake()->create('avv.pdf', 12, 'application/pdf')->store('dpa', 'local'), + 'measures_path' => $withMeasures + ? UploadedFile::fake()->create('tom.pdf', 12, 'application/pdf')->store('dpa', 'local') + : null, + 'published_at' => now()->subDay(), + ]); +} + +it('shows the customer nothing at all until a version is in force', function () { + // A card offering an agreement that does not exist is worse than silence. + [$user] = dpaCustomer(); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertDontSee(__('dpa.title')); +}); + +it('offers the document and records the acceptance with its evidence', function () { + $version = publishedDpa(); + [$user, $customer] = dpaCustomer(); + + $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']); + + $page->assertSee(__('dpa.title')) + ->assertSee(__('dpa.state_open')) + ->assertSee(__('dpa.accept_cta')) + ->call('acceptProcessingAgreement') + ->assertDispatched('notify'); + + $acceptance = DpaAcceptance::query()->where('customer_id', $customer->id)->sole(); + + // What makes it evidence rather than a flag: the version, the moment, the + // login that pressed it, and where from. + expect($acceptance->dpa_version_id)->toBe($version->id) + ->and($acceptance->user_id)->toBe($user->id) + ->and($acceptance->accepted_at)->not->toBeNull() + ->and($acceptance->ip)->not->toBeNull(); + + // And the card now states it rather than asking again. + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('dpa.state_accepted')) + ->assertDontSee(__('dpa.accept_cta')); +}); + +it('counts pressing twice as one agreement', function () { + publishedDpa(); + [$user, $customer] = dpaCustomer(); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->call('acceptProcessingAgreement') + ->call('acceptProcessingAgreement'); + + expect(DpaAcceptance::query()->where('customer_id', $customer->id)->count())->toBe(1); +}); + +it('asks again when a new version comes into force, and keeps the old acceptance', function () { + // Acceptance is per version: a new one supersedes the last, and accepting a + // superseded document is not accepting the one in force. The old row stays — + // it was true when it was made, and the history is the point. + $first = publishedDpa('1.0'); + [$user, $customer] = dpaCustomer(); + + app(ProcessingAgreement::class)->accept($customer); + + expect(app(ProcessingAgreement::class)->accepted($customer))->toBeTrue(); + + $second = DpaVersion::query()->create([ + 'version' => '2.0', + 'agreement_path' => $first->agreement_path, + 'published_at' => now(), + ]); + + expect(app(ProcessingAgreement::class)->current()->id)->toBe($second->id) + ->and(app(ProcessingAgreement::class)->accepted($customer))->toBeFalse() + // Not deleted, not overwritten. + ->and(DpaAcceptance::query()->where('dpa_version_id', $first->id)->exists())->toBeTrue(); +}); + +it('serves the document to the customer, and only the one in force', function () { + publishedDpa(); + [$user] = dpaCustomer(); + + $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertOk(); + $this->actingAs($user)->get(route('dpa.file', 'measures'))->assertOk(); + + // Which document applies is ours to say, not a parameter somebody can put + // in the address. + $this->actingAs($user)->get(route('dpa.file', 'sonstwas'))->assertNotFound(); +}); + +it('does not hand the agreement to a stranger', function () { + publishedDpa(); + + $this->get(route('dpa.file', 'agreement'))->assertRedirect(); +}); + +it('answers 404 rather than 500 when nothing is published', function () { + [$user] = dpaCustomer(); + + $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertNotFound(); +}); + +// ---- The console ---- + +it('takes an upload as a draft, and only a second act puts it in force', function () { + // Publishing leaves every customer who accepted the previous version + // outstanding again. That is correct, and expensive to trigger by dropping a + // file on a form. + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '3.0') + ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) + ->call('upload') + ->assertHasNoErrors(); + + $version = DpaVersion::query()->where('version', '3.0')->sole(); + + expect($version->isPublished())->toBeFalse() + ->and(app(ProcessingAgreement::class)->current())->toBeNull() + ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->call('publish', $version->uuid); + + expect(app(ProcessingAgreement::class)->current()?->id)->toBe($version->id); +}); + +it('takes the agreement as a PDF and nothing else', function () { + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '4.0') + ->set('agreement', UploadedFile::fake()->create('avv.docx', 20, 'application/msword')) + ->call('upload') + ->assertHasErrors(['agreement']); +}); + +it('refuses two versions under one name', function () { + publishedDpa('1.0'); + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '1.0') + ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) + ->call('upload') + ->assertHasErrors(['version']); +}); + +it('keeps the whole thing to operators who may manage it', function () { + // Its own capability: whoever keeps the platform running does not thereby + // decide what every customer is asked to agree to. + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(ProcessingAgreements::class) + ->assertForbidden(); + + $this->actingAs(operator('Read-only'), 'operator') + ->get(route('admin.dpa')) + ->assertForbidden(); +}); + +it('confirms publication in a modal rather than on one click', function () { + // R23, and for a reason beyond the rule: this is the click that makes every + // customer outstanding again. + $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/processing-agreements.blade.php')); + + expect($page)->toContain("component: 'admin.confirm-publish-dpa'"); + + $modal = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/ConfirmPublishDpa.php')); + + expect($modal)->toContain("dispatch('dpa-publish-confirmed'") + // The modal decides nothing: the permission check stays where it was. + ->and($modal)->not->toContain('published_at'); +});