++ 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 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 17:55:38 +02:00
parent 96084499f5
commit f48d0e96c0
9 changed files with 164 additions and 12 deletions

View File

@@ -157,6 +157,20 @@ e non vede nient'altro. Scelte dell'utente:
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I `LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
territori già dati restano ai membri. Si riparte con un gruppo nuovo. 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: Dove sta:
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella - Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e `gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e

View File

@@ -758,7 +758,10 @@ docker compose --profile dev up -d --build
ancora dati tornano in reparto, i membri si liberano, l'utenza viene ancora dati tornano in reparto, i membri si liberano, l'utenza viene
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello 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 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 ### Campagne
- CRUD con date di inizio e fine - 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 - **Diritto all'oblio**: funzione di anonimizzazione irreversibile
- **Retention**: cancellazione automatica dei log oltre il periodo configurato - **Retention**: cancellazione automatica dei log oltre il periodo configurato
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi - **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 - **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
in un luogo protetto e cancellati quando non servono in un luogo protetto e cancellati quando non servono

View File

@@ -77,10 +77,10 @@ class GruppiIndex extends Component
->get(); ->get();
// Possono diventare sorveglianti i proclamatori attivi senza gruppo e // 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() $candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id') ->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente') ->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at'))
->get(); ->get();
return view('livewire.gruppi.gruppi-index', [ return view('livewire.gruppi.gruppi-index', [

View File

@@ -6,6 +6,7 @@ use App\Livewire\Concerns\Avvisa;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\GruppoServizio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreIndex extends Component 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."); $this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return; 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(); $proclamatore->delete();
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino."); $this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
} }
@@ -80,10 +86,27 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) {
return;
}
$proclamatore->anonimizza(); $proclamatore->anonimizza();
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)."); $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() public function render()
{ {
// withCount rather than letting the view call // withCount rather than letting the view call

View File

@@ -108,7 +108,22 @@ class Proclamatore extends Model
$this->nome = 'Anonimo'; $this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id; $this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false; $this->attivo = false;
$this->gruppo_servizio_id = null;
$this->save(); $this->save();
$this->delete(); // soft delete $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();
}
} }
} }

View File

@@ -91,6 +91,32 @@ class User extends Authenticatable implements MustVerifyEmail
->dontSubmitEmptyLogs(); ->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 protected static function booted(): void
{ {
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi. // Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.

View File

@@ -42,28 +42,32 @@ class GruppiServizio
if ($sorvegliante->gruppo_servizio_id) { if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo."); 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."); 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."); throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
} }
$password = Str::password(12, symbols: false); $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]); $gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]); $sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri); $this->aggiungiMembri($gruppo, $membri);
$utente = User::create([ $utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
'name' => $sorvegliante->nome_completo, 'name' => $sorvegliante->nome_completo,
'email' => $email, 'email' => $email,
'password' => $password, 'password' => $password,
'password_change_required' => true, 'password_change_required' => true,
'proclamatore_id' => $sorvegliante->id, 'disattivato_at' => null,
]); ])->save();
$utente->assignRole('sorvegliante'); $utente->syncRoles(['sorvegliante']);
activity()->causedBy(auth()->user())->performedOn($gruppo) activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id]) ->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])

View File

@@ -100,7 +100,7 @@
<section> <section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2> <h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile.</li> <li>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.</li>
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li> <li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li> <li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li> <li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>

View File

@@ -354,6 +354,72 @@ class GruppiServizioTest extends TestCase
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login')); $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 ───────────────────────────────────────────────── // ─── Backup ─────────────────────────────────────────────────
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void