From f48d0e96c065bc9550f2464c0cace49368a2a717 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Mon, 19 Oct 2026 17:55:38 +0200 Subject: [PATCH] ++ gruppi di servizio: utenza dell'ex sorvegliante riattivabile, cestino e anonimizzazione, registro senza nomi Un ex sorvegliante puo' guidare un gruppo nuovo: si riattiva la sua utenza. Il sorvegliante di un gruppo attivo non si cestina ne' si anonimizza; anonimizzando un ex sorvegliante si anonimizza anche l'utenza. Nel registro delle attivita' nome ed email dell'utenza del sorvegliante diventano "proclamatore #id". La voce "Il mio gruppo" dipende dal ruolo: l'amministratore passa ogni @can. Co-Authored-By: Claude Opus 5.5 --- HANDOFF.md | 14 ++++ README.md | 8 ++- app/Livewire/Gruppi/GruppiIndex.php | 4 +- .../Proclamatori/ProclamatoreIndex.php | 23 +++++++ app/Models/Proclamatore.php | 15 +++++ app/Models/User.php | 26 ++++++++ app/Services/GruppiServizio.php | 18 +++-- resources/views/livewire/privacy.blade.php | 2 +- tests/Feature/GruppiServizioTest.php | 66 +++++++++++++++++++ 9 files changed, 164 insertions(+), 12 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index c2ade1a..83a1bf4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -157,6 +157,20 @@ e non vede nient'altro. Scelte dell'utente: `LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I territori già dati restano ai membri. Si riparte con un gruppo nuovo. +Casi aggiunti di mio, da far confermare all'utente: +- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la + sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id` + è unico), con email e password temporanea nuove. +- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si + anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro + spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()` + anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password, + ruoli). +- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro + delle attività `User::tapActivity()` li sostituisce con "proclamatore #id", + come vale per tutti i proclamatori. Restano invece in chiaro nella tabella + `users`, come per ogni utente. + Dove sta: - Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella `gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e diff --git a/README.md b/README.md index fbeb726..d6a33a3 100644 --- a/README.md +++ b/README.md @@ -758,7 +758,10 @@ docker compose --profile dev up -d --build ancora dati tornano in reparto, i membri si liberano, l'utenza viene disattivata (anche una sessione aperta si chiude) e il gruppo resta nello storico. I territori già dati restano ai membri. Per ripartire si crea un - gruppo nuovo + gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza +- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si + anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante + si anonimizza anche la sua utenza ### Campagne - CRUD con date di inizio e fine @@ -858,7 +861,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina. - **Diritto all'oblio**: funzione di anonimizzazione irreversibile - **Retention**: cancellazione automatica dei log oltre il periodo configurato - **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi - in chiaro + in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome + di un proclamatore) - **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi in un luogo protetto e cancellati quando non servono diff --git a/app/Livewire/Gruppi/GruppiIndex.php b/app/Livewire/Gruppi/GruppiIndex.php index d0125fd..5311b96 100644 --- a/app/Livewire/Gruppi/GruppiIndex.php +++ b/app/Livewire/Gruppi/GruppiIndex.php @@ -77,10 +77,10 @@ class GruppiIndex extends Component ->get(); // Possono diventare sorveglianti i proclamatori attivi senza gruppo e - // senza già un'utenza. + // senza un'utenza attiva (quella di un gruppo sciolto si riattiva). $candidati = Proclamatore::attivi() ->whereNull('gruppo_servizio_id') - ->whereDoesntHave('utente') + ->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at')) ->get(); return view('livewire.gruppi.gruppi-index', [ diff --git a/app/Livewire/Proclamatori/ProclamatoreIndex.php b/app/Livewire/Proclamatori/ProclamatoreIndex.php index f5a5831..7848bc9 100644 --- a/app/Livewire/Proclamatori/ProclamatoreIndex.php +++ b/app/Livewire/Proclamatori/ProclamatoreIndex.php @@ -6,6 +6,7 @@ use App\Livewire\Concerns\Avvisa; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; +use App\Models\GruppoServizio; use App\Models\Proclamatore; class ProclamatoreIndex extends Component @@ -70,7 +71,12 @@ class ProclamatoreIndex extends Component $this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori."); return; } + if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) { + return; + } + // Nel cestino non resta membro di un gruppo. + $proclamatore->update(['gruppo_servizio_id' => null]); $proclamatore->delete(); $this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino."); } @@ -80,10 +86,27 @@ class ProclamatoreIndex extends Component $this->authorize('settings.manage'); $proclamatore = Proclamatore::findOrFail($id); + if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) { + return; + } + $proclamatore->anonimizza(); $this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)."); } + /** Il sorvegliante di un gruppo attivo si toglie sciogliendo il gruppo, non da qui. */ + private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool + { + $gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first(); + if (! $gruppo) { + return false; + } + + $this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio)."); + + return true; + } + public function render() { // withCount rather than letting the view call diff --git a/app/Models/Proclamatore.php b/app/Models/Proclamatore.php index 0e5c092..62fc41d 100644 --- a/app/Models/Proclamatore.php +++ b/app/Models/Proclamatore.php @@ -108,7 +108,22 @@ class Proclamatore extends Model $this->nome = 'Anonimo'; $this->cognome = 'Proclamatore #' . $this->id; $this->attivo = false; + $this->gruppo_servizio_id = null; $this->save(); $this->delete(); // soft delete + + // L'utenza di un ex sorvegliante (già disattivata: il sorvegliante di + // un gruppo attivo non si anonimizza) porta nome ed email in chiaro. + $utente = $this->utente()->first(); + if ($utente) { + $utente->syncRoles([]); + $utente->forceFill([ + 'name' => 'Anonimo', + 'email' => "utente-{$utente->id}@anonimo.invalid", + 'password' => \Illuminate\Support\Str::random(40), + 'remember_token' => null, + 'disattivato_at' => $utente->disattivato_at ?? now(), + ])->save(); + } } } diff --git a/app/Models/User.php b/app/Models/User.php index dc30910..8fb3b02 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -91,6 +91,32 @@ class User extends Authenticatable implements MustVerifyEmail ->dontSubmitEmptyLogs(); } + /** + * L'utenza di un sorvegliante porta nome (ed email, spesso) di un + * proclamatore: nel registro delle attività i proclamatori compaiono solo + * con l'identificativo, quindi anche qui. + */ + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void + { + $proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id'); + if (! $proclamatoreId) { + return; + } + + $activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) { + if (! is_array($valori)) { + return $valori; + } + foreach (['name', 'email'] as $campo) { + if (array_key_exists($campo, $valori)) { + $valori[$campo] = "proclamatore #{$proclamatoreId}"; + } + } + + return $valori; + }); + } + protected static function booted(): void { // Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi. diff --git a/app/Services/GruppiServizio.php b/app/Services/GruppiServizio.php index 6cc427d..9465769 100644 --- a/app/Services/GruppiServizio.php +++ b/app/Services/GruppiServizio.php @@ -42,28 +42,32 @@ class GruppiServizio if ($sorvegliante->gruppo_servizio_id) { throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo."); } - if ($sorvegliante->utente()->exists()) { + // Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza, + // disattivata: si riattiva quella (un proclamatore, un'utenza). + $esistente = $sorvegliante->utente()->first(); + if ($esistente && ! $esistente->isDisattivato()) { throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza."); } - if (User::where('email', $email)->exists()) { + if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) { throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente."); } $password = Str::password(12, symbols: false); - $gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password) { + $gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) { $gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]); $sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]); $this->aggiungiMembri($gruppo, $membri); - $utente = User::create([ + $utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]); + $utente->forceFill([ 'name' => $sorvegliante->nome_completo, 'email' => $email, 'password' => $password, 'password_change_required' => true, - 'proclamatore_id' => $sorvegliante->id, - ]); - $utente->assignRole('sorvegliante'); + 'disattivato_at' => null, + ])->save(); + $utente->syncRoles(['sorvegliante']); activity()->causedBy(auth()->user())->performedOn($gruppo) ->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id]) diff --git a/resources/views/livewire/privacy.blade.php b/resources/views/livewire/privacy.blade.php index 78f61d9..6bad57b 100644 --- a/resources/views/livewire/privacy.blade.php +++ b/resources/views/livewire/privacy.blade.php @@ -100,7 +100,7 @@

Conservazione dei dati

    -
  • I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile.
  • +
  • I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, insieme a nome ed email dell'utenza se è stato sorvegliante di un gruppo.
  • Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.
  • Il registro delle attività è conservato per {{ $auditRetention }} giorni (modificabile nelle impostazioni), poi cancellato automaticamente.
  • Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.
  • diff --git a/tests/Feature/GruppiServizioTest.php b/tests/Feature/GruppiServizioTest.php index 11c129d..08981eb 100644 --- a/tests/Feature/GruppiServizioTest.php +++ b/tests/Feature/GruppiServizioTest.php @@ -354,6 +354,72 @@ class GruppiServizioTest extends TestCase $this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login')); } + public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo_con_la_stessa_utenza(): void + { + ['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo(); + app(GruppiServizio::class)->sciogli($vecchio); + + $pagina = Livewire::test(GruppiIndex::class); + $this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati'); + + [$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test'); + + $utente = $s->fresh(); + $this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza'); + $this->assertFalse($utente->isDisattivato()); + $this->assertTrue($utente->hasRole('sorvegliante')); + $this->assertSame('mario.nuovo@example.test', $utente->email); + $this->assertTrue($utente->password_change_required); + $this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password)); + $this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id); + } + + // ─── Proclamatori: cestino, anonimizzazione, registro ─────── + + public function test_il_sorvegliante_di_un_gruppo_attivo_non_si_cestina_ne_si_anonimizza(): void + { + ['rossi' => $rossi, 'verdi' => $verdi] = $this->gruppo(); + + Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class) + ->call('deleteProclamatore', $rossi->id) + ->call('anonimizza', $rossi->id); + $this->assertFalse($rossi->fresh()->trashed()); + $this->assertSame('Rossi', $rossi->fresh()->cognome); + + // Un membro qualsiasi sì, e lascia il gruppo. + Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id); + $this->assertTrue($verdi->fresh()->trashed()); + $this->assertNull($verdi->fresh()->gruppo_servizio_id); + } + + public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void + { + ['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo(); + app(GruppiServizio::class)->sciogli($gruppo); + + Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id); + + $utente = $s->fresh(); + $this->assertSame('Anonimo', $utente->name); + $this->assertStringEndsWith('@anonimo.invalid', $utente->email); + $this->assertTrue($utente->isDisattivato()); + $this->assertCount(0, $utente->roles); + } + + public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void + { + ['sorvegliante' => $s] = $this->gruppo(); + $s->update(['email' => 'mario.rossi@example.test']); + + $voci = \Spatie\Activitylog\Models\Activity::where('subject_type', User::class)->where('subject_id', $s->id)->get(); + $this->assertNotEmpty($voci); + foreach ($voci as $voce) { + $json = json_encode($voce->properties); + $this->assertStringNotContainsString('Rossi', $json); + $this->assertStringNotContainsString('mario', $json); + } + } + // ─── Backup ───────────────────────────────────────────────── public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void