CluPilotCloud/app/Livewire/Admin/ProcessingAgreements.php

130 lines
4.5 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\DpaVersion;
use App\Services\Legal\ProcessingAgreement;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* The data-processing agreement: upload a version, publish it, see who has it.
*
* The TEXT is not this application's and never will be. Art. 28(3) GDPR wants a
* contract between the controller (the customer) and the processor (us); what
* belongs in it is a lawyer's answer, and inventing one here would be worse than
* having none. What this page does is the part software can do properly: keep
* the document, say which version is in force, hand it to the customer, and hold
* the evidence that they accepted it.
*
* ## Publishing is what asks every customer again
*
* Acceptance is per VERSION. Publishing a new one therefore leaves every
* customer outstanding until they accept it, which is the correct behaviour and
* an expensive one to trigger by accident — so a draft is uploaded first and
* published deliberately, in two steps.
*/
#[Layout('layouts.admin')]
class ProcessingAgreements extends Component
{
use WithFileUploads;
/** What the document itself calls this version — "1.0", "2026-08". */
public string $version = '';
public string $note = '';
/** The agreement, and the technical and organisational measures beside it. */
public $agreement = null;
public $measures = null;
public function mount(): void
{
$this->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);
}
}