++ gruppi: email fittizia iniziale+cognome per il sorvegliante; sciolto il gruppo l'utenza si elimina

- L'utenza del sorvegliante ha un'email fittizia creata dall'app:
  iniziale del nome e cognome @termanager.it (ffontana@termanager.it),
  come gli altri utenti. Via il nome utente: login di nuovo per email.
- Sciolto il gruppo l'utenza si elimina, il sorvegliante torna un
  proclamatore come gli altri: assegnazioni a chi scioglie, registro a nome
  "Sorvegliante del gruppo ...". Via le utenze disattivate.
- User::eliminaConservandoStorico(): l'eliminazione della pagina Utenti in
  un metodo solo, usato anche dallo scioglimento.
- Migrazione 000030: elimina le utenze gia' disattivate, toglie
  users.username e users.disattivato_at, email di nuovo obbligatoria.
- XML, informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-20 12:09:15 +02:00
parent 69250ecb8c
commit ec2e7bd7e9
26 changed files with 347 additions and 311 deletions

View File

@@ -150,21 +150,12 @@ e non vede nient'altro. Scelte dell'utente:
pacchetti li gestisce **solo l'amministratore**.
- Utenza: password temporanea da cambiare al primo accesso, **senza
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
sorvegliante). *Dal 20 ottobre: niente email, nome utente dal nome del
gruppo — vedi sotto.*
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
`LimitaSorvegliante`). I territori già dati restano ai membri. Si riparte
con un gruppo nuovo. *Dal 20 ottobre il gruppo sciolto si elimina — vedi
sorvegliante). *Dal 20 ottobre l'email è fittizia, creata dall'app — vedi
sotto.*
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 nome utente e password temporanea nuovi.
- `Proclamatore::anonimizza()` anonimizza anche l'utenza dell'ex sorvegliante
(nome, email, password, ruoli).
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi. I territori già dati restano ai membri.
Si riparte con un gruppo nuovo. *Dal 20 ottobre gruppo e utenza del
sorvegliante si eliminano — vedi sotto.*
Decisioni successive dell'utente (20 ottobre 2026):
- **Sorvegliante non più attivo ⇒ gruppo sciolto** e territori non dati
@@ -179,7 +170,7 @@ Decisioni successive dell'utente (20 ottobre 2026):
territori); nel cestino esce dal gruppo.
- **Dettagli nel profilo**: la scheda del proclamatore (`ProclamatoreShow`)
mostra il gruppo che guida con i numeri (`GruppoServizio::riepilogo()`),
l'utenza (nome utente solo all'amministratore, password temporanea o no,
l'utenza (email solo all'amministratore, password temporanea o no,
ultimo accesso dal registro), oppure il gruppo di cui è membro. Il
*Profilo* del sorvegliante mostra il suo gruppo con gli stessi numeri e
"Torna al mio gruppo".
@@ -192,19 +183,33 @@ Decisioni successive dell'utente (20 ottobre 2026):
territorio): **confermato dall'utente il 20 ottobre 2026**. Dopo lo
scioglimento si torna all'elenco dei gruppi; nella scheda del proclamatore
non c'è più "In passato sorvegliante di".
- **Nome utente al posto dell'email**: creando il gruppo non si chiede
l'email; `GruppiServizio::nomeUtente()` ricava `users.username` dal nome
del gruppo (`Str::slug` con il punto: «Gruppo Zisa» → `gruppo.zisa`,
numero in coda se già preso; mai una chiocciola). `users.email` è diventata
facoltativa. Il login ha un solo campo, "Email o nome utente"
(`LoginController`: con la chiocciola cerca l'email, senza il nome utente,
in minuscolo). Dove si mostrava l'email dell'utente ora c'è
`User::identificativo` (email, o nome utente). Il profilo del sorvegliante
mostra il nome utente e non chiede l'email. Il nome del gruppo è unico.
Sciolto il gruppo, il nome utente si libera e l'utenza disattivata prende
`ex.sorvegliante.<id>` (l'anonimizzata `anonimo.<id>`): resta riconoscibile
per l'import XML, che individua gli utenti per email o, senza, per nome
utente. Un gruppo con `<sciolto_at>` in un XML vecchio non si importa.
- **Email fittizia, niente nome utente** (seconda decisione del 20 ottobre,
che sostituisce il nome utente ricavato dal nome del gruppo fatto qualche
ora prima): creando il gruppo l'email non si chiede,
`GruppiServizio::emailFittizia()` la ricava da iniziale del nome e cognome,
minuscoli, senza accenti, spazi né apostrofi, sul dominio
`GruppiServizio::DOMINIO_EMAIL` = `termanager.it` (Francesco Fontana →
`ffontana@termanager.it`, D'Angelo → `fdangelo@…`, numero in coda se già
presa) — la stessa convenzione degli altri utenti. Il login è di nuovo solo
per email. Il profilo del sorvegliante mostra l'email ma non la fa
cambiare. Il nome del gruppo è unico.
- **Sciolto il gruppo, l'utenza del sorvegliante si elimina** ("torna
proclamatore normale, non tenere traccia"): `User::eliminaConservandoStorico()`
— la stessa eliminazione della pagina *Utenti*, ora in un metodo solo — passa
le assegnazioni che aveva fatto o fatto rientrare a chi scioglie (o al primo
amministratore) e lascia le sue voci nel registro a nome "Sorvegliante del
gruppo …", senza email. Niente più utenze disattivate: via
`users.disattivato_at` e i controlli in `LoginController` e
`LimitaSorvegliante`; una sessione aperta resta senza utente.
`Proclamatore::anonimizza()` non tocca più utenze: se era sorvegliante, il
salvataggio scioglie il gruppo e l'utenza sparisce.
- Migrazione `2026_10_20_000030_sorveglianti_con_email_fittizia`: elimina le
utenze già disattivate (assegnazioni al primo amministratore, registro a
nome "Ex sorvegliante di gruppo"), dà l'email fittizia a chi non l'aveva,
toglie `users.username` e `users.disattivato_at`, rende di nuovo
obbligatoria `users.email`. XML: niente più `<username>` né
`<disattivato_at>`; da un file vecchio non si importano le utenze
disattivate né i gruppi con `<sciolto_at>`.
- **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta`
("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco
(più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa
@@ -220,8 +225,8 @@ Decisioni successive dell'utente (20 ottobre 2026):
Dove sta:
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`,
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`
(tolto dalla `000030`), permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
@@ -238,7 +243,7 @@ Dove sta:
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni (id e nome del gruppo), nome utente e legame utenza ↔
assegnazioni (id e nome del gruppo) e legame utenza ↔
proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore).

View File

@@ -741,12 +741,13 @@ docker compose --profile dev up -d --build
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza senza email**: un
**nome utente ricavato dal nome del gruppo** («Gruppo Zisa» →
`gruppo.zisa`; se è già preso, un numero in coda) e una password
temporanea, mostrati una volta sola, da cambiare al primo accesso. Al login
il campo accetta l'email o il nome utente. Due gruppi non possono avere lo
stesso nome. Se perde la password, l'amministratore ne genera un'altra
- Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
è già presa, un numero in coda), e una password temporanea. Si vedono una
volta sola; la password va cambiata al primo accesso. L'email non si
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
dalla pagina del gruppo
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
@@ -759,18 +760,19 @@ docker compose --profile dev up -d --build
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
gruppo**, che non resta nello storico: i territori solo nel pacchetto
tornano in reparto, i membri si liberano, l'utenza viene disattivata (anche
una sessione aperta si chiude) e il suo nome utente torna libero. I
territori già dati restano ai membri come assegnazioni qualsiasi, che del
gruppo conservano solo il nome ("via gruppo …"). Per ripartire si crea un
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
elimina** (anche una sessione aperta si chiude): torna un proclamatore come
gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
gruppo; nel registro delle attività le sue voci restano a nome
"Sorvegliante del gruppo …". I territori già dati restano ai membri come
assegnazioni qualsiasi, che del gruppo conservano solo il nome ("via
gruppo …"). Per ripartire si crea un gruppo nuovo
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
se lo si sposta nel cestino o lo si anonimizza. Prima di disattivarlo o
cestinarlo dall'elenco si chiede conferma; riattivarlo non ricostruisce il
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
cestino esce dal gruppo. Anonimizzando un ex sorvegliante si anonimizza
anche la sua utenza
cestino esce dal gruppo
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
@@ -778,7 +780,7 @@ docker compose --profile dev up -d --build
resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, nome utente, stato della password e ultimo accesso) o membro; il
gruppo, email per entrare, stato della password e ultimo accesso) o membro; il
sorvegliante ritrova gli stessi numeri nel suo
*Profilo*
@@ -892,7 +894,7 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `email` facoltativa e `username` per il sorvegliante, che entra con il nome utente del gruppo; `proclamatore_id` lega l'utenza del sorvegliante al suo proclamatore, `disattivato_at` la chiude allo scioglimento del gruppo |
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `proclamatore_id` lega l'utenza del sorvegliante (email fittizia) al suo proclamatore; sciolto il gruppo l'utenza si elimina |
| `settings` | Configurazione singleton |
| `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio |

View File

@@ -13,11 +13,8 @@ class LoginController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
// Il campo si chiama "email" ma accetta anche il nome utente del
// sorvegliante di gruppo, che l'email non ce l'ha: con la chiocciola è
// un'email, senza un nome utente (che la chiocciola non la contiene mai).
$credentials = $request->validate([
'email' => ['required', 'string', 'max:255'],
'email' => ['required', 'email'],
'password' => ['required', 'string', 'min:6'],
'remember' => ['nullable', 'boolean'],
]);
@@ -32,11 +29,8 @@ class LoginController extends Controller
->withInput($request->only('email', 'remember'));
}
$identificativo = trim($credentials['email']);
$campo = str_contains($identificativo, '@') ? 'email' : 'username';
if (! Auth::attempt([
$campo => $campo === 'username' ? Str::lower($identificativo) : $identificativo,
'email' => $credentials['email'],
'password' => $credentials['password'],
], $request->boolean('remember'))) {
RateLimiter::hit($throttleKey);
@@ -46,16 +40,6 @@ class LoginController extends Controller
->withInput($request->only('email', 'remember'));
}
// Utenza disattivata (gruppo di servizio sciolto): la password è giusta
// ma non si entra.
if (Auth::user()->isDisattivato()) {
Auth::logout();
return back()
->withErrors(['email' => 'Questa utenza è stata disattivata.'])
->withInput($request->only('email', 'remember'));
}
RateLimiter::clear($throttleKey);
$request->session()->regenerate();
activity()->causedBy(auth()->user())->log('login');

View File

@@ -4,23 +4,18 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Due regole per gli utenti autenticati.
*
* 1. Un'utenza disattivata (gruppo di servizio sciolto) non può più lavorare:
* anche una sessione già aperta viene chiusa alla richiesta successiva.
*
* 2. Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
* componenti che può aprire controllano da sé che territori e assegnazioni
* siano del suo gruppo, e gli altri non può caricarli.
* Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
* componenti che può aprire controllano da sé che territori e assegnazioni
* siano del suo gruppo, e gli altri non può caricarli.
*
* Vale solo per chi ha il ruolo sorvegliante e nessun altro: un utente con
* anche un altro ruolo resta regolato dai permessi di quel ruolo.
* anche un altro ruolo resta regolato dai permessi di quel ruolo. Sciolto il
* gruppo l'utenza si elimina: una sessione ancora aperta non ha più utente.
*/
class LimitaSorvegliante
{
@@ -43,14 +38,6 @@ class LimitaSorvegliante
return $next($request);
}
if ($user->isDisattivato()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors(['email' => 'Questa utenza è stata disattivata.']);
}
if (! $user->isSorvegliante() || $user->roles->count() > 1) {
return $next($request);
}

View File

@@ -12,10 +12,10 @@ use Livewire\Component;
/**
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
* gruppo si sceglie il sorvegliante, che riceve un'utenza con un nome utente
* ricavato dal nome del gruppo (niente email) e una password temporanea,
* mostrati qui una volta sola. Membri e pacchetto si gestiscono nella pagina
* del gruppo (GruppoShow).
* gruppo si sceglie il sorvegliante, che riceve un'utenza con un'email
* fittizia ricavata dal suo nome (GruppiServizio::emailFittizia) e una
* password temporanea, mostrate qui una volta sola. Membri e pacchetto si
* gestiscono nella pagina del gruppo (GruppoShow).
*/
class GruppiIndex extends Component
{
@@ -72,11 +72,11 @@ class GruppiIndex extends Component
->orderBy('nome')
->get();
// Possono diventare sorveglianti i proclamatori attivi senza gruppo e
// senza un'utenza attiva (quella di un gruppo sciolto si riattiva).
// Possono diventare sorveglianti i proclamatori attivi senza gruppo (e
// quindi senza utenza: sciolto il gruppo, la si elimina).
$candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at'))
->whereDoesntHave('utente')
->get();
return view('livewire.gruppi.gruppi-index', [

View File

@@ -124,7 +124,7 @@ class GruppoShow extends Component
$nome = $this->gruppo->nome;
$this->servizio()->sciogli($this->gruppo);
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è disattivata.");
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è stata eliminata.");
return $this->redirect(route('gruppi.index'), navigate: true);
}

View File

@@ -98,7 +98,7 @@ class ProclamatoreIndex extends Component
/** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
public static function gruppoSciolto(GruppoServizio $gruppo): string
{
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è disattivata.";
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è stata eliminata.";
}
public function render()

View File

@@ -20,24 +20,24 @@ class Profile extends Component
{
$user = auth()->user();
$this->name = $user->name;
$this->email = (string) $user->email;
$this->email = $user->email;
}
public function updateProfile()
{
$user = auth()->user();
// Il sorvegliante di gruppo entra con il nome utente: l'email non gli
// si chiede, e il nome utente lo decide il nome del gruppo.
$senzaEmail = $user->isSorvegliante();
// Il sorvegliante di gruppo entra con l'email fittizia creata col
// gruppo (GruppiServizio::emailFittizia): dal profilo non la cambia.
$emailFissa = $user->isSorvegliante();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
] + ($senzaEmail ? [] : [
] + ($emailFissa ? [] : [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]));
$user->fill(['name' => $validated['name']] + ($senzaEmail ? [] : ['email' => $validated['email']]));
$user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['email' => $validated['email']]));
$emailCambiata = $user->isDirty('email');
$user->save();

View File

@@ -3,13 +3,10 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Assegnazione;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
class UsersIndex extends Component
@@ -115,7 +112,7 @@ class UsersIndex extends Component
}
/**
* Le utenze dei sorveglianti di gruppo (attive o disattivate) si gestiscono
* Le utenze dei sorveglianti di gruppo si gestiscono
* da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
*/
@@ -232,37 +229,7 @@ class UsersIndex extends Component
return;
}
DB::transaction(function () use ($user, $currentUser) {
$causerName = $user->name;
$causerEmail = $user->email;
$deletedAt = now()->toDateTimeString();
Activity::query()
->where('causer_type', User::class)
->where('causer_id', $user->id)
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
foreach ($activities as $activity) {
$properties = $activity->properties?->toArray() ?? [];
$properties['causer_name'] = $causerName;
$properties['causer_email'] = $causerEmail;
$properties['causer_deleted_at'] = $deletedAt;
$activity->properties = $properties;
$activity->save();
}
});
Assegnazione::query()
->where('created_by', $user->id)
->update(['created_by' => $currentUser->id]);
Assegnazione::query()
->where('returned_by', $user->id)
->update(['returned_by' => $currentUser->id]);
$user->syncRoles([]);
$user->delete();
});
$user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
if ($this->editingUserId === $userId) {
$this->cancelEdit();

View File

@@ -525,18 +525,22 @@ class XmlExchange extends Component
$utentiDaCollegare = [];
foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? ''));
$username = trim((string) ($userNode->username ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? '');
// Il sorvegliante di gruppo non ha email: si riconosce dal nome utente.
if ($email === '' && $username === '') {
$this->pushImportIssue('utente', $legacyId, 'Email e nome utente mancanti');
// Nei file del 19-20 ottobre 2026 c'erano le utenze disattivate
// degli ex sorveglianti: ora, sciolto il gruppo, l'utenza non
// esiste più. Le loro assegnazioni passano a chi importa.
if (trim((string) ($userNode->disattivato_at ?? '')) !== '') {
continue;
}
$user = User::query()->firstOrNew($email !== '' ? ['email' => $email] : ['username' => $username]);
$user->name = (string) ($userNode->name ?? ($email !== '' ? $email : $username));
$user->username = $username !== '' ? $username : null;
if ($email === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
continue;
}
$user = User::query()->firstOrNew(['email' => $email]);
$user->name = (string) ($userNode->name ?? $email);
if (! $user->exists) {
// password is NOT NULL; the real hash is written raw below.
$user->password = Str::password(24);
@@ -597,10 +601,7 @@ class XmlExchange extends Component
// Utenza di un sorvegliante: si collega al proclamatore dopo che
// i proclamatori sono stati importati (vedi i gruppi di servizio).
$utentiDaCollegare[$user->id] = [
'proclamatore' => (string) ($userNode->legacy_proclamatore_id ?? ''),
'disattivato_at' => $this->normalizeDateTimeForDb((string) ($userNode->disattivato_at ?? '')),
];
$utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
$stats['utenti_importati']++;
if ($legacyId !== '') {
@@ -681,10 +682,9 @@ class XmlExchange extends Component
DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]);
}
}
foreach ($utentiDaCollegare as $idUtente => $collegamento) {
foreach ($utentiDaCollegare as $idUtente => $legacyProclamatore) {
DB::table('users')->where('id', $idUtente)->update([
'proclamatore_id' => $proclamatoreMap[$collegamento['proclamatore']] ?? null,
'disattivato_at' => $collegamento['disattivato_at'],
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
]);
}
@@ -869,15 +869,13 @@ class XmlExchange extends Component
'legacy_id' => (string) $u->id,
'name' => (string) $u->name,
'email' => (string) $u->email,
'username' => (string) $u->username,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
// remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(),
// Sorvegliante di gruppo: il proclamatore collegato e se l'utenza è disattivata.
// Sorvegliante di gruppo: il proclamatore collegato.
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
'disattivato_at' => optional($u->disattivato_at)->toIso8601String() ?? '',
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(),
@@ -1285,11 +1283,9 @@ class XmlExchange extends Component
}
$this->addXmlText($node, 'name', (string) ($user['name'] ?? ''));
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'username', (string) ($user['username'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
$node->addChild('disattivato_at', (string) ($user['disattivato_at'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');

View File

@@ -175,22 +175,9 @@ class Proclamatore extends Model
$this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false;
$this->gruppo_servizio_id = null;
// Se era sorvegliante, il salvataggio (non più attivo) scioglie il
// gruppo ed elimina la sua utenza: booted().
$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' => null,
'username' => "anonimo.{$utente->id}",
'password' => \Illuminate\Support\Str::random(40),
'remember_token' => null,
'disattivato_at' => $utente->disattivato_at ?? now(),
])->save();
}
}
}

View File

@@ -9,6 +9,8 @@ use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Models\Activity;
use Illuminate\Support\Facades\DB;
/**
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
@@ -22,11 +24,9 @@ class User extends Authenticatable implements MustVerifyEmail
protected $fillable = [
'name',
'email',
'username',
'password',
'password_change_required',
'proclamatore_id',
'disattivato_at',
];
protected $hidden = [
@@ -40,7 +40,6 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime',
'password' => 'hashed',
'password_change_required' => 'boolean',
'disattivato_at' => 'datetime',
'proclamatore_id' => 'integer',
];
}
@@ -60,7 +59,7 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasRole('sorvegliante');
}
/** Il gruppo di cui è sorvegliante, se c'è (sciolto, il gruppo non esiste più). */
/** Il gruppo di cui è sorvegliante (sciolto il gruppo, l'utenza non esiste più). */
public function gruppoSorvegliato(): ?GruppoServizio
{
return $this->proclamatore_id
@@ -69,18 +68,37 @@ class User extends Authenticatable implements MustVerifyEmail
}
/**
* Con cosa entra: l'email, o il nome utente per il sorvegliante di
* gruppo, che l'email non ce l'ha. Da mostrare dove prima c'era l'email.
* Elimina l'utenza senza perdere lo storico: le assegnazioni che ha fatto
* o fatto rientrare passano a $subentra (la colonna non può restare
* vuota), e nel registro delle attività le sue voci si tengono, con
* scritto chi era ($nome, $email) al posto del collegamento all'utenza.
*/
public function getIdentificativoAttribute(): string
public function eliminaConservandoStorico(self $subentra, string $nome, ?string $email): void
{
return (string) ($this->email ?? $this->username ?? $this->name);
}
DB::transaction(function () use ($subentra, $nome, $email) {
$eliminatoIl = now()->toDateTimeString();
/** Utenza disattivata (gruppo sciolto): non può più entrare. */
public function isDisattivato(): bool
{
return $this->disattivato_at !== null;
Activity::query()
->where('causer_type', self::class)
->where('causer_id', $this->id)
->chunkById(200, function ($attivita) use ($nome, $email, $eliminatoIl) {
foreach ($attivita as $voce) {
$proprieta = $voce->properties?->toArray() ?? [];
$proprieta['causer_name'] = $nome;
$proprieta['causer_email'] = $email;
$proprieta['causer_deleted_at'] = $eliminatoIl;
$voce->properties = $proprieta;
$voce->save();
}
});
Assegnazione::query()->where('created_by', $this->id)->update(['created_by' => $subentra->id]);
Assegnazione::query()->where('returned_by', $this->id)->update(['returned_by' => $subentra->id]);
$this->syncRoles([]);
$this->delete();
});
}
/**

View File

@@ -21,12 +21,15 @@ use InvalidArgumentException;
*/
class GruppiServizio
{
/** Il dominio delle email fittizie dei sorveglianti (iniziale+cognome@…). */
public const DOMINIO_EMAIL = 'termanager.it';
/**
* Crea il gruppo e l'utenza del sorvegliante: nome utente ricavato dal
* nome del gruppo (niente email), ruolo "sorvegliante", password
* temporanea da cambiare al primo accesso. Restituisce il gruppo e la
* password temporanea, da mostrare una volta sola all'amministratore
* insieme al nome utente ($gruppo->sorvegliante->utente->username).
* Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
* suo nome (emailFittizia), ruolo "sorvegliante", password temporanea da
* cambiare al primo accesso, nessuna conferma dell'email. Restituisce il
* gruppo e la password temporanea, da mostrare una volta sola
* all'amministratore insieme all'email.
*
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string}
@@ -46,29 +49,25 @@ class GruppiServizio
if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
}
// 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()) {
if ($sorvegliante->utente()->exists()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
}
$password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password, $esistente) {
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
$utente = User::create([
'name' => $sorvegliante->nome_completo,
'username' => self::nomeUtente($nome),
'email' => self::emailFittizia($sorvegliante),
'password' => $password,
'password_change_required' => true,
'disattivato_at' => null,
])->save();
$utente->syncRoles(['sorvegliante']);
'proclamatore_id' => $sorvegliante->id,
]);
$utente->assignRole('sorvegliante');
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
@@ -81,20 +80,27 @@ class GruppiServizio
}
/**
* Il nome utente dal nome del gruppo: minuscole, senza accenti, parole
* unite da un punto ("Gruppo Noce" → "gruppo.noce"); se è già preso, un
* numero in coda ("zisa2"). Mai una chiocciola: al login distingue il
* nome utente dall'email.
* L'email fittizia del sorvegliante, con cui entra: iniziale del nome e
* cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio
* dell'app (Francesco Fontana → ffontana@termanager.it, Francesco
* D'Angelo → fdangelo@…). Se è già presa, un numero in coda
* (ffontana2@…). Non riceve posta: il sorvegliante non conferma l'email.
*/
public static function nomeUtente(string $nomeGruppo): string
public static function emailFittizia(Proclamatore $proclamatore): string
{
$base = Str::limit(Str::slug($nomeGruppo, '.') ?: 'gruppo', 50, '');
$nome = $base;
for ($n = 2; User::where('username', $nome)->exists(); $n++) {
$nome = $base . $n;
$pulisci = fn (string $testo) => preg_replace('/[^a-z0-9]/', '', Str::lower(Str::ascii($testo)));
$base = Str::limit(mb_substr($pulisci((string) $proclamatore->nome), 0, 1) . $pulisci((string) $proclamatore->cognome), 60, '');
if ($base === '') {
$base = "sorvegliante{$proclamatore->id}";
}
return $nome;
$email = "{$base}@" . self::DOMINIO_EMAIL;
for ($n = 2; User::where('email', $email)->exists(); $n++) {
$email = "{$base}{$n}@" . self::DOMINIO_EMAIL;
}
return $email;
}
/** @param int[] $ids */
@@ -250,8 +256,10 @@ class GruppiServizio
* I territori del pacchetto non ancora dati tornano in reparto; quelli
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
* L'utenza del sorvegliante si disattiva e libera il nome utente; resta
* perché è l'autrice delle assegnazioni che ha fatto.
* L'utenza del sorvegliante si elimina: torna un proclamatore come gli
* altri. Le assegnazioni che aveva fatto o fatto rientrare passano a chi
* scioglie il gruppo; nel registro le sue voci restano, a nome
* "Sorvegliante del gruppo …", senza dati personali.
*/
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{
@@ -269,30 +277,39 @@ class GruppiServizio
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente) {
$utente->removeRole('sorvegliante');
// Il nome utente del gruppo si libera; al suo posto uno che
// identifica l'utenza (backup XML) e con cui non si entra.
$utente->forceFill([
'disattivato_at' => now(),
'remember_token' => null,
'username' => "ex.sorvegliante.{$utente->id}",
])->save();
}
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_disattivato' => $utente?->id,
'utente_eliminato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto');
if ($utente) {
$utente->eliminaConservandoStorico($this->subentrante($utente), "Sorvegliante del gruppo {$gruppo->nome}", null);
}
$gruppo->delete();
});
}
/**
* Chi prende le assegnazioni fatte dal sorvegliante quando la sua utenza
* si elimina: chi sta sciogliendo il gruppo, altrimenti il primo
* amministratore.
*/
private function subentrante(User $sorvegliante): User
{
$chi = auth()->user();
if ($chi instanceof User && $chi->id !== $sorvegliante->id) {
return $chi;
}
return User::role('amministratore')->orderBy('id')->firstOrFail();
}
private function attivo(GruppoServizio $gruppo): void
{
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\Proclamatore;
use App\Services\GruppiServizio;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Gruppi di servizio, 20 ottobre 2026 (decisioni dell'utente, che
* sostituiscono il nome utente introdotto poche ore prima):
*
* - il sorvegliante entra con un'email fittizia, iniziale del nome e cognome
* (ffontana@termanager.it), come gli altri utenti: via users.username, e
* users.email torna obbligatoria;
* - sciolto il gruppo l'utenza del sorvegliante si elimina, non si disattiva:
* via users.disattivato_at e le utenze già disattivate. Le loro
* assegnazioni passano al primo amministratore; nel registro le loro voci
* restano, a nome "Ex sorvegliante di gruppo".
*/
return new class extends Migration
{
public function up(): void
{
$amministratore = DB::table('model_has_roles')
->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
->where('roles.name', 'amministratore')
->where('model_has_roles.model_type', 'App\\Models\\User')
->min('model_has_roles.model_id');
if (Schema::hasColumn('users', 'disattivato_at')) {
foreach (DB::table('users')->whereNotNull('disattivato_at')->get() as $utente) {
if (! $amministratore) {
continue; // nessuno a cui passare le assegnazioni: resta
}
DB::table('assegnazioni')->where('created_by', $utente->id)->update(['created_by' => $amministratore]);
DB::table('assegnazioni')->where('returned_by', $utente->id)->update(['returned_by' => $amministratore]);
foreach (DB::table('activity_log')->where('causer_type', 'App\\Models\\User')->where('causer_id', $utente->id)->get() as $voce) {
$proprieta = json_decode((string) $voce->properties, true) ?: [];
$proprieta['causer_name'] = 'Ex sorvegliante di gruppo';
$proprieta['causer_email'] = null;
$proprieta['causer_deleted_at'] = now()->toDateTimeString();
DB::table('activity_log')->where('id', $voce->id)->update(['properties' => json_encode($proprieta)]);
}
DB::table('model_has_roles')->where('model_type', 'App\\Models\\User')->where('model_id', $utente->id)->delete();
DB::table('model_has_permissions')->where('model_type', 'App\\Models\\User')->where('model_id', $utente->id)->delete();
DB::table('users')->where('id', $utente->id)->delete();
}
}
// Chi è rimasto senza email (sorveglianti col nome utente): la fittizia.
foreach (DB::table('users')->whereNull('email')->get() as $utente) {
$proclamatore = $utente->proclamatore_id ? Proclamatore::withTrashed()->find($utente->proclamatore_id) : null;
$email = $proclamatore
? GruppiServizio::emailFittizia($proclamatore)
: "utente{$utente->id}@" . GruppiServizio::DOMINIO_EMAIL;
DB::table('users')->where('id', $utente->id)->update(['email' => $email]);
}
if (Schema::hasColumn('users', 'username')) {
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['username']);
});
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
if (Schema::hasColumn('users', 'disattivato_at')) {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('disattivato_at');
});
}
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable()->change();
});
Schema::table('users', function (Blueprint $table) {
$table->string('username', 60)->nullable()->unique()->after('email');
$table->timestamp('disattivato_at')->nullable();
});
}
};

View File

@@ -5,7 +5,7 @@
{{ $forced ? 'Imposta una nuova password' : 'Cambia password' }}
</h1>
<p class="mt-1 text-sm text-gray-500">
Account: <span class="font-medium text-gray-700 break-all">{{ $user->identificativo }}</span>
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
</p>
</div>
@@ -46,7 +46,7 @@
{{-- L'email in un campo nascosto: senza, i gestori di password non
sanno a quale account associare la credenziale che salvano. --}}
<input type="hidden" name="email" autocomplete="username" value="{{ $user->identificativo }}">
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
@unless($forced)
{{-- Dopo un ripristino non si chiede: la password attuale e'

View File

@@ -50,7 +50,7 @@
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->identificativo }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span>
</x-btn>
</div>

View File

@@ -16,10 +16,8 @@
<form method="POST" action="{{ route('login.store') }}" class="space-y-5">
@csrf
<div>
{{-- Email, o nome utente per il sorvegliante di un gruppo di servizio --}}
<label for="email" class="block text-sm font-medium text-gray-700">Email o nome utente</label>
<input name="email" value="{{ old('email') }}" type="text" id="email" autocomplete="username"
autocapitalize="none" autocorrect="off" spellcheck="false"
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input name="email" value="{{ old('email') }}" type="email" id="email" autocomplete="email"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm px-4 py-2.5">
@error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>

View File

@@ -12,7 +12,7 @@
<p class="mt-1">Consegna queste credenziali a {{ $creato->sorvegliante?->nome_completo }}: la password si vede <strong>solo ora</strong> e al primo accesso va cambiata.</p>
<dl class="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
<dt class="text-green-700">Nome utente</dt><dd><code class="select-all">{{ $creato->sorvegliante?->utente?->username }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $creato->sorvegliante?->utente?->email }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3 flex flex-wrap gap-2">
@@ -29,13 +29,12 @@
<label for="nome-gruppo" class="block text-sm font-medium text-gray-700 mb-1">Nome del gruppo *</label>
<input wire:model="nome" id="nome-gruppo" type="text" placeholder="es. Zisa"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-xs text-gray-500 mt-1">Dal nome nasce il nome utente del sorvegliante (es. «Gruppo Zisa» → <code>gruppo.zisa</code>).</p>
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Sorvegliante *</label>
<x-scelta id="sorvegliante" wire:model="sorveglianteId" :opzioni="$opzioniSorveglianti" segnaposto="Cognome o nome" :vuoto="null" />
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo.</p>
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo, e riceve un'utenza con un'email fittizia: iniziale del nome e cognome (es. Francesco Fontana → <code>ffontana@{{ \App\Services\GruppiServizio::DOMINIO_EMAIL }}</code>).</p>
@error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn>

View File

@@ -8,7 +8,7 @@
<p class="text-sm text-gray-500 mt-1">
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
@if($gruppo->sorvegliante?->utente)
· nome utente <code class="select-all">{{ $gruppo->sorvegliante->utente->identificativo }}</code>
· accede con <code class="select-all">{{ $gruppo->sorvegliante->utente->email }}</code>
@endif
</p>
</div>
@@ -121,7 +121,7 @@
<x-btn variante="secondary" wire:click="nuovaPassword"
wire:confirm="Generare una nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
<x-btn variante="danger" wire:click="sciogli"
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie e viene eliminato: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie e viene eliminato: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene eliminata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
</div>
</section>
</div>

View File

@@ -19,7 +19,7 @@
<li><strong>Proclamatori</strong>: solo nome e cognome, cifrati nel database ({{ $cifratura }}), se sono attivi e l'eventuale gruppo di servizio di cui fanno parte. Nessun numero di telefono né indirizzo email.</li>
<li><strong>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina, stato del suo invio (inviato o no, e con quale canale) e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio non ha email: entra con un nome utente ricavato dal nome del gruppo. La sua utenza è collegata al suo proclamatore e viene disattivata quando il gruppo si scioglie; il gruppo, sciolto, viene eliminato.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio entra con un'email fittizia creata dall'applicazione (iniziale del nome e cognome), che non riceve posta e non va confermata. La sua utenza è collegata al suo proclamatore e viene eliminata quando il gruppo si scioglie; anche il gruppo, sciolto, viene eliminato.</li>
<li><strong>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</li>
</ul>
</section>
@@ -58,7 +58,7 @@
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
@endforeach
</ul>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (nome utente e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
</section>
<section>
@@ -100,7 +100,7 @@
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<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, insieme al nome dell'utenza se è stato sorvegliante di un gruppo.</li>
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile; se era sorvegliante di un gruppo, il gruppo si scioglie e la sua utenza viene eliminata.</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>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>

View File

@@ -28,7 +28,7 @@
@if($gruppoSorvegliato ?? null)
<p class="-mt-2 text-xs text-amber-800">
È il sorvegliante del gruppo <strong>{{ $gruppoSorvegliato->nome }}</strong>: se non è più attivo il gruppo si scioglie,
i territori non ancora dati tornano in reparto e la sua utenza viene disattivata.
i territori non ancora dati tornano in reparto e la sua utenza viene eliminata.
</p>
@endif

View File

@@ -83,7 +83,7 @@
{{-- Sorvegliante: disattivarlo scioglie il gruppo, si chiede conferma --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $proclamatore->id }})"
wire:confirm="{{ $proclamatore->nome_completo }} è il sorvegliante del gruppo {{ $gruppiSorvegliati[$proclamatore->id] }}. Se non è più attivo il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e la sua utenza viene disattivata. Procedere?"
wire:confirm="{{ $proclamatore->nome_completo }} è il sorvegliante del gruppo {{ $gruppiSorvegliati[$proclamatore->id] }}. Se non è più attivo il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e la sua utenza viene eliminata. Procedere?"
title="Disattiva" aria-label="Disattiva il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
</x-btn>
@@ -101,7 +101,7 @@
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteProclamatore({{ $proclamatore->id }})"
wire:confirm="{{ isset($gruppiSorvegliati[$proclamatore->id]) ? 'Spostare nel cestino ' . $proclamatore->nome_completo . '? È il sorvegliante del gruppo ' . $gruppiSorvegliati[$proclamatore->id] . ': il gruppo si scioglie, i territori non ancora dati tornano in reparto e la sua utenza viene disattivata.' : 'Spostare il proclamatore nel cestino?' }}"
wire:confirm="{{ isset($gruppiSorvegliati[$proclamatore->id]) ? 'Spostare nel cestino ' . $proclamatore->nome_completo . '? È il sorvegliante del gruppo ' . $gruppiSorvegliati[$proclamatore->id] . ': il gruppo si scioglie, i territori non ancora dati tornano in reparto e la sua utenza viene eliminata.' : 'Spostare il proclamatore nel cestino?' }}"
title="Elimina" aria-label="Sposta nel cestino il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>

View File

@@ -78,7 +78,7 @@
@if($utenza)
<p class="mt-4 border-t border-gray-100 pt-3 text-sm text-gray-600">
Utenza
@can('settings.manage') <code>{{ $utenza->identificativo }}</code> @endcan
@can('settings.manage') <code>{{ $utenza->email }}</code> @endcan
· {{ $utenza->password_change_required ? 'password temporanea, ancora da cambiare' : 'password personale impostata' }}
· {{ $ultimoAccesso ? 'ultimo accesso il ' . \Illuminate\Support\Carbon::parse($ultimoAccesso)->format('d/m/Y H:i') : 'non è ancora entrato' }}
</p>

View File

@@ -17,7 +17,7 @@
<div class="min-w-0">
<h2 class="text-xl font-bold text-gray-900 truncate">{{ $user->name }}</h2>
<p class="text-sm text-gray-500 truncate mt-0.5">
<span class="break-all">{{ $user->identificativo }}</span>
<span class="break-all">{{ $user->email }}</span>
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 align-middle">
{{ ucfirst($user->roles->first()?->name ?? 'utente') }}
</span>
@@ -74,11 +74,11 @@
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@if($user->isSorvegliante())
{{-- Il sorvegliante entra con il nome utente del gruppo, senza email --}}
{{-- Il sorvegliante entra con l'email fittizia creata col gruppo --}}
<div>
<p class="block text-sm font-medium text-gray-700">Nome utente</p>
<p class="mt-1 text-sm"><code class="select-all">{{ $user->username }}</code></p>
<p class="mt-1 text-xs text-gray-500">Viene dal nome del gruppo: lo usi per entrare, insieme alla password.</p>
<p class="block text-sm font-medium text-gray-700">Email per entrare</p>
<p class="mt-1 text-sm"><code class="select-all">{{ $user->email }}</code></p>
<p class="mt-1 text-xs text-gray-500">L'ha creata l'applicazione insieme al gruppo: la usi per entrare, insieme alla password. Non riceve posta.</p>
</div>
@else
<div>

View File

@@ -111,14 +111,8 @@
@else
<td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
<td data-label="Email" class="px-4 py-2 text-gray-700">
@if(! $user->email)
{{-- Sorvegliante di gruppo: entra con il nome utente, senza email --}}
@if($user->username)<span class="text-gray-500">nome utente</span> <code>{{ $user->username }}</code>@else<span class="text-gray-400">—</span>@endif
@else
{{ $user->email }}
@endif
@if(! $user->email)
@elseif($user->hasVerifiedEmail())
@if($user->hasVerifiedEmail())
<span class="ml-1 inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">confermata</span>
@else
<span class="ml-1 inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
@@ -138,7 +132,7 @@
@if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}}
<span class="text-xs text-gray-500">
{{ $user->disattivato_at ? 'Disattivata (gruppo sciolto)' : 'Sorvegliante: si gestisce da Gruppi di servizio' }}
Sorvegliante: si gestisce da Gruppi di servizio
</span>
@else
<div class="flex gap-2">

View File

@@ -73,7 +73,7 @@ class GruppiServizioTest extends TestCase
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
$territori = $territori->map->fresh();
$sorvegliante = User::where('username', 'zisa')->sole();
$sorvegliante = User::where('email', 'mrossi@termanager.it')->sole();
// Il cambio password al primo accesso ha i suoi test: qui si lavora.
// E l'email non confermata, come nella realtà (TestCase le conferma).
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
@@ -87,20 +87,18 @@ class GruppiServizioTest extends TestCase
{
$rossi = $this->proclamatore('Rossi', 'Mario');
// Niente email: il nome utente viene dal nome del gruppo.
// L'email non si chiede: è fittizia, iniziale del nome e cognome.
$pagina = Livewire::test(GruppiIndex::class)
->assertDontSee('Email per l')
->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id)
->call('crea')
->assertHasNoErrors()
->assertSee('Nome utente')
->assertSee('zisa');
->assertSee('mrossi@termanager.it');
$gruppo = GruppoServizio::sole();
$utente = User::where('username', 'zisa')->sole();
$utente = User::where('email', 'mrossi@termanager.it')->sole();
$this->assertNull($utente->email);
$this->assertSame($rossi->id, $gruppo->sorvegliante_id);
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
$this->assertSame($rossi->id, $utente->proclamatore_id);
@@ -118,21 +116,22 @@ class GruppiServizioTest extends TestCase
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
}
public function test_il_nome_utente_viene_dal_nome_del_gruppo(): void
public function test_l_email_fittizia_viene_da_iniziale_del_nome_e_cognome(): void
{
$servizio = app(GruppiServizio::class);
$email = fn (string $cognome, string $nome) => GruppiServizio::emailFittizia($this->proclamatore($cognome, $nome));
$servizio->crea('Gruppo Noce Città', $this->proclamatore('Neri'));
$this->assertSame('gruppo.noce.citta', User::whereNotNull('username')->sole()->username);
$this->assertSame('ffontana@termanager.it', $email('Fontana', 'Francesco'));
$this->assertSame('fdangelo@termanager.it', $email("D'Angelo", 'Francesco'));
$this->assertSame('ndeluca@termanager.it', $email('De Luca', 'Niccolò'));
// Già preso (per esempio da un'utenza riattivata): un numero in coda.
User::create(['name' => 'Altro', 'username' => 'zisa', 'password' => 'password-di-prova']);
$servizio->crea('Zisa', $this->proclamatore('Gialli'));
$this->assertTrue(User::where('username', 'zisa2')->exists());
// Già presa da un altro utente: un numero in coda.
User::create(['name' => 'Francesco Fontana', 'email' => 'ffontana@termanager.it', 'password' => 'password-di-prova']);
$this->assertSame('ffontana2@termanager.it', $email('Fontana', 'Federica'));
// Due gruppi con lo stesso nome no.
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Gialli'));
$this->expectException(InvalidArgumentException::class);
$servizio->crea('Zisa', $this->proclamatore('Blu'));
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Blu'));
}
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
@@ -220,25 +219,16 @@ class GruppiServizioTest extends TestCase
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
}
public function test_il_sorvegliante_entra_con_il_nome_utente_e_va_al_suo_gruppo(): void
public function test_il_sorvegliante_entra_con_l_email_fittizia_senza_conferma_e_va_al_suo_gruppo(): void
{
$this->gruppo();
$this->flushSession();
auth()->logout();
$password = 'una-password-nuova-1';
User::where('username', 'zisa')->sole()->forceFill(['password' => $password])->save();
User::where('email', 'mrossi@termanager.it')->sole()->forceFill(['password' => $password])->save();
$this->get(route('login'))->assertSee('Email o nome utente');
// Senza badare alle maiuscole (la tastiera del telefono le mette da sé).
$this->post(route('login.store'), ['email' => 'Zisa', 'password' => $password])
$this->post(route('login.store'), ['email' => 'mrossi@termanager.it', 'password' => $password])
->assertRedirect(route('mio-gruppo'));
// Gli altri continuano a entrare con l'email.
auth()->logout();
$this->flushSession();
$this->post(route('login.store'), ['email' => 'admin@example.test', 'password' => 'password-di-prova'])
->assertRedirect(route('dashboard'));
}
// ─── Il sorvegliante al lavoro ──────────────────────────────
@@ -350,7 +340,10 @@ class GruppiServizioTest extends TestCase
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
// L'assegnazione la fa il sorvegliante, come nella realtà.
auth()->setUser($s);
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
auth()->setUser($this->admin);
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
->call('sciogli')
@@ -368,20 +361,26 @@ class GruppiServizioTest extends TestCase
$this->assertNull($data->fresh()->gruppo_servizio_id);
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->get(route('territori.show', $territori[0]))->assertOk()->assertSee('via gruppo Zisa');
// Membri liberi; utenza disattivata, senza ruolo, e il nome utente del
// gruppo è libero.
// Membri liberi; l'utenza del sorvegliante non c'è più, lui torna un
// proclamatore come gli altri.
$this->assertSame(0, Proclamatore::whereNotNull('gruppo_servizio_id')->count());
$this->assertTrue($s->fresh()->isDisattivato());
$this->assertFalse($s->fresh()->hasRole('sorvegliante'));
$this->assertSame("ex.sorvegliante.{$s->id}", $s->fresh()->username);
$this->assertFalse(User::where('username', 'zisa')->exists());
$this->assertNull(User::find($s->id));
$this->assertNotNull(Proclamatore::find($s->proclamatore_id));
$this->assertSame(0, \Illuminate\Support\Facades\DB::table('model_has_roles')->where('model_id', $s->id)->count());
// Quello che aveva fatto passa a chi ha sciolto il gruppo; nel registro
// le sue voci restano, senza dati personali.
$this->assertSame($this->admin->id, $data->fresh()->created_by);
$voce = \Spatie\Activitylog\Models\Activity::where('description', 'assigned')->sole();
$this->assertNull($voce->causer);
$this->assertSame('Sorvegliante del gruppo Zisa', $voce->properties['causer_name']);
$this->assertNull($voce->properties['causer_email']);
// Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
}
public function test_un_utenza_disattivata_non_entra_e_viene_buttata_fuori(): void
public function test_sciolto_il_gruppo_l_ex_sorvegliante_non_entra_piu(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo();
$password = 'una-password-nuova-1';
@@ -390,29 +389,25 @@ class GruppiServizioTest extends TestCase
$this->flushSession();
auth()->logout();
$this->post(route('login.store'), ['email' => 'zisa', 'password' => $password])
$this->post(route('login.store'), ['email' => 'mrossi@termanager.it', 'password' => $password])
->assertSessionHasErrors('email');
$this->assertGuest();
$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
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo(): void
{
['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
['gruppo' => $vecchio, 'rossi' => $rossi] = $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');
// Sciolto, il nome non è più occupato: si può rifare "Zisa".
// Sciolto, il nome del gruppo e l'email sono di nuovo liberi.
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
$utente = $s->fresh();
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
$this->assertFalse($utente->isDisattivato());
$utente = User::where('proclamatore_id', $rossi->id)->sole();
$this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertSame('zisa', $utente->username);
$this->assertSame('mrossi@termanager.it', $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);
@@ -429,8 +424,7 @@ class GruppiServizioTest extends TestCase
$this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
$this->assertNull($g['bianchi']->fresh()->gruppo_servizio_id, 'Membri liberi');
$this->assertTrue($g['sorvegliante']->fresh()->isDisattivato());
$this->assertFalse($g['sorvegliante']->fresh()->hasRole('sorvegliante'));
$this->assertNull(User::find($g['sorvegliante']->id), 'Utenza eliminata');
}
public function test_disattivare_il_sorvegliante_dall_elenco_scioglie_il_gruppo(): void
@@ -501,7 +495,7 @@ class GruppiServizioTest extends TestCase
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
->assertSeeInOrder(['Gruppo di servizio', 'Sorvegliante', 'Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Assegnazioni dal gruppo', '1'])
->assertSee('<code>zisa</code>', false)
->assertSee('<code>mrossi@termanager.it</code>', false)
->assertSee('non è ancora entrato');
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()
@@ -563,19 +557,15 @@ class GruppiServizioTest extends TestCase
// ─── Proclamatori: anonimizzazione, registro ────────────────
public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void
public function test_anonimizzare_il_sorvegliante_scioglie_il_gruppo_e_ne_elimina_l_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->assertNull($utente->email);
$this->assertSame("anonimo.{$utente->id}", $utente->username);
$this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles);
$this->assertNull(GruppoServizio::find($gruppo->id));
$this->assertNull(User::find($s->id), "Nome ed email dell'utenza spariscono con lei");
$this->assertSame('Anonimo', Proclamatore::withTrashed()->find($rossi->id)->nome);
}
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
@@ -624,8 +614,8 @@ class GruppiServizioTest extends TestCase
$this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
$utenza = User::where('username', 'zisa')->sole();
$this->assertNull($utenza->email);
$utenza = User::where('email', 'mrossi@termanager.it')->sole();
$this->assertTrue($utenza->hasRole('sorvegliante'));
$this->assertSame($g->sorvegliante_id, $utenza->proclamatore_id);
}
@@ -641,7 +631,7 @@ class GruppiServizioTest extends TestCase
$this->assertSame(0, GruppoServizio::count());
$this->assertNull(Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
// L'utenza disattivata, autrice dell'assegnazione, sopravvive al giro.
$this->assertTrue(User::where('username', 'like', 'ex.sorvegliante.%')->sole()->isDisattivato());
// Dell'ex sorvegliante non resta un'utenza: solo l'amministratore.
$this->assertSame([$this->admin->id], User::pluck('id')->all());
}
}