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_body') }}
+{{ __('dpa_admin.eyebrow') }}
+{{ __('dpa_admin.subtitle') }}
+{{ __('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 +{{ __('dpa_admin.no_versions') }}
+ @else +| {{ __('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())
+ |
+ {{ $v->acceptances_count }} | +
+
+
+ {{ __('dpa_admin.file_agreement') }}
+
+ @if ($v->measures_path)
+
+ {{ __('dpa_admin.file_measures') }}
+
+ @endif
+
+ |
+
+ @if (! $v->isPublished())
+ {{-- R23: a modal, because publishing leaves every
+ customer outstanding again. --}}
+ |
+
{{ __('dpa.sub') }}
++ {{ __('dpa.accepted_on', [ + 'version' => $dpa->version, + 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), + ]) }} +
+ @else +{{ __('dpa.accept_hint') }}
+