++ gruppi: il gruppo sciolto si elimina; il sorvegliante entra con un nome utente dal nome del gruppo

- Sciolto, il gruppo non resta nello storico: si elimina (via sciolto_at;
  la migrazione elimina anche quelli gia' sciolti). I territori solo nel
  pacchetto tornano in reparto; quelli gia' dati restano ai membri, e
  l'assegnazione conserva il nome del gruppo (assegnazioni.gruppo_nome).
- Creando il gruppo non si chiede l'email: users.username dal nome del
  gruppo ("Gruppo Zisa" -> gruppo.zisa), users.email facoltativa. Login con
  "Email o nome utente". Nome del gruppo unico.
- Sciolto il gruppo il nome utente si libera (l'utenza disattivata prende
  ex.sorvegliante.<id>). XML: nome utente e gruppo_nome.
- Profilo, testata, cambio password e Utenti mostrano il nome utente dove
  manca l'email. 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 11:45:00 +02:00
parent a93539242a
commit aaffeb7de7
29 changed files with 408 additions and 199 deletions

View File

@@ -148,19 +148,21 @@ e non vede nient'altro. Scelte dell'utente:
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i - Il sorvegliante: assegna, manda il link (copia/condividi), registra i
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
pacchetti li gestisce **solo l'amministratore**. pacchetti li gestisce **solo l'amministratore**.
- Utenza: email e password temporanea da cambiare al primo accesso, **senza - Utenza: password temporanea da cambiare al primo accesso, **senza
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
sorvegliante; anche cambiandola dal profilo). sorvegliante). *Dal 20 ottobre: niente email, nome utente dal nome del
gruppo — vedi sotto.*
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`): - **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi, utenza senza ruolo e disattivata non dati in reparto, membri liberi, utenza senza ruolo e disattivata
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da (`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I `LimitaSorvegliante`). I territori già dati restano ai membri. Si riparte
territori già dati restano ai membri. Si riparte con un gruppo nuovo. con un gruppo nuovo. *Dal 20 ottobre il gruppo sciolto si elimina — vedi
sotto.*
Casi aggiunti di mio, da far confermare all'utente: Casi aggiunti di mio, da far confermare all'utente:
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la - Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id` sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
è unico), con email e password temporanea nuove. è unico), con nome utente e password temporanea nuovi.
- `Proclamatore::anonimizza()` anonimizza anche l'utenza dell'ex sorvegliante - `Proclamatore::anonimizza()` anonimizza anche l'utenza dell'ex sorvegliante
(nome, email, password, ruoli). (nome, email, password, ruoli).
@@ -177,10 +179,33 @@ Decisioni successive dell'utente (20 ottobre 2026):
territori); nel cestino esce dal gruppo. territori); nel cestino esce dal gruppo.
- **Dettagli nel profilo**: la scheda del proclamatore (`ProclamatoreShow`) - **Dettagli nel profilo**: la scheda del proclamatore (`ProclamatoreShow`)
mostra il gruppo che guida con i numeri (`GruppoServizio::riepilogo()`), mostra il gruppo che guida con i numeri (`GruppoServizio::riepilogo()`),
l'utenza (email solo all'amministratore, password temporanea o no, ultimo l'utenza (nome utente solo all'amministratore, password temporanea o no,
accesso dal registro), oppure il gruppo di cui è membro, e i gruppi sciolti ultimo accesso dal registro), oppure il gruppo di cui è membro. Il
che ha guidato. Il *Profilo* del sorvegliante mostra il suo gruppo con gli *Profilo* del sorvegliante mostra il suo gruppo con gli stessi numeri e
stessi numeri e "Torna al mio gruppo". "Torna al mio gruppo".
- **Il gruppo sciolto non resta nello storico**: `sciogli()` lo elimina
(colonna `sciolto_at` tolta dalla migrazione `2026_10_20_000020`, che
elimina anche i gruppi già sciolti). I territori solo nel pacchetto tornano
in reparto (conferma esplicita dell'utente); quelli già dati restano ai
membri come assegnazioni qualsiasi. Sulla traccia delle assegnazioni
l'utente non si è espresso: ho tenuto la scelta che non perde dati, il
nome del gruppo copiato sull'assegnazione (`assegnazioni.gruppo_nome`,
"via gruppo …" in registro e scheda territorio). Toglierlo è un attimo, se
lo chiede. 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.
- **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta` - **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta`
("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco ("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco
(più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa (più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa
@@ -214,7 +239,8 @@ Dove sta:
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`: - La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`). l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle - XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni e il legame utenza ↔ proclamatore. assegnazioni (id e nome del gruppo), nome utente e legame utenza ↔
proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati - Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore). del proclamatore).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi. - L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.

View File

@@ -741,10 +741,13 @@ docker compose --profile dev up -d --build
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di **sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e - Creando il gruppo il sorvegliante riceve un'**utenza senza email**: un
una password temporanea, mostrata una volta sola e da cambiare al primo **nome utente ricavato dal nome del gruppo** («Gruppo Zisa» →
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne `gruppo.zisa`; se è già preso, un numero in coda) e una password
genera un'altra dalla pagina del gruppo 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
dalla pagina del gruppo
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto. - I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
cambiano), escono da "Da assegnare" e dalla pagina *Assegna* cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
@@ -754,10 +757,12 @@ docker compose --profile dev up -d --build
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri, "via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati apre la piantina e rimanda in reparto quelli non ancora dati
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto - Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non - **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
ancora dati tornano in reparto, i membri si liberano, l'utenza viene gruppo**, che non resta nello storico: i territori solo nel pacchetto
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello tornano in reparto, i membri si liberano, l'utenza viene disattivata (anche
storico. I territori già dati restano ai membri. Per ripartire si crea un 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 gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli - **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), il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
@@ -773,8 +778,8 @@ docker compose --profile dev up -d --build
resta comunque sceglibile resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante - **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal (gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, stato dell'utenza e ultimo accesso), membro, o sorvegliante di gruppo, nome utente, stato della password e ultimo accesso) o membro; il
gruppi ormai sciolti; il sorvegliante ritrova gli stessi numeri nel suo sorvegliante ritrova gli stessi numeri nel suo
*Profilo* *Profilo*
### Campagne ### Campagne
@@ -887,16 +892,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione | | Tabella | Descrizione |
|-------------------|--------------------------------------------------------------| |-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `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; `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 |
| `settings` | Configurazione singleton | | `settings` | Configurazione singleton |
| `zone` | Zone territoriali | | `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio | | `tipologie` | Tipologie di territorio |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte | | `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
| `gruppi_servizio` | Gruppi di servizio: nome, sorvegliante, `sciolto_at` (sciolti restano per lo storico) | | `gruppi_servizio` | Gruppi di servizio attivi: nome (unico), sorvegliante. Sciolto, il gruppo si elimina |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo | | `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
| `anni_teocratici` | Periodi settembre → agosto | | `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione | | `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo | | `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**

View File

@@ -13,8 +13,11 @@ class LoginController extends Controller
{ {
public function __invoke(Request $request): RedirectResponse 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([ $credentials = $request->validate([
'email' => ['required', 'email'], 'email' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:6'], 'password' => ['required', 'string', 'min:6'],
'remember' => ['nullable', 'boolean'], 'remember' => ['nullable', 'boolean'],
]); ]);
@@ -29,8 +32,11 @@ class LoginController extends Controller
->withInput($request->only('email', 'remember')); ->withInput($request->only('email', 'remember'));
} }
$identificativo = trim($credentials['email']);
$campo = str_contains($identificativo, '@') ? 'email' : 'username';
if (! Auth::attempt([ if (! Auth::attempt([
'email' => $credentials['email'], $campo => $campo === 'username' ? Str::lower($identificativo) : $identificativo,
'password' => $credentials['password'], 'password' => $credentials['password'],
], $request->boolean('remember'))) { ], $request->boolean('remember'))) {
RateLimiter::hit($throttleKey); RateLimiter::hit($throttleKey);

View File

@@ -12,9 +12,10 @@ use Livewire\Component;
/** /**
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un * Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
* gruppo si sceglie il sorvegliante, che riceve un'utenza con l'email * gruppo si sceglie il sorvegliante, che riceve un'utenza con un nome utente
* indicata e una password temporanea, mostrata qui una volta sola. * ricavato dal nome del gruppo (niente email) e una password temporanea,
* Membri e pacchetto si gestiscono nella pagina del gruppo (GruppoShow). * mostrati qui una volta sola. Membri e pacchetto si gestiscono nella pagina
* del gruppo (GruppoShow).
*/ */
class GruppiIndex extends Component class GruppiIndex extends Component
{ {
@@ -24,8 +25,6 @@ class GruppiIndex extends Component
public ?int $sorveglianteId = null; public ?int $sorveglianteId = null;
public string $email = '';
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */ /** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
#[Locked] #[Locked]
public ?int $creatoId = null; public ?int $creatoId = null;
@@ -38,19 +37,17 @@ class GruppiIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$this->validate([ $this->validate([
'nome' => 'required|string|max:100', 'nome' => 'required|string|max:100|unique:gruppi_servizio,nome',
'sorveglianteId' => 'required|integer|exists:proclamatori,id', 'sorveglianteId' => 'required|integer|exists:proclamatori,id',
'email' => 'required|email|max:255',
], [ ], [
'nome.unique' => 'Esiste già un gruppo con questo nome.',
'sorveglianteId.required' => 'Scegli il sorvegliante.', 'sorveglianteId.required' => 'Scegli il sorvegliante.',
'email.required' => "Serve l'email con cui il sorvegliante entrerà.",
]); ]);
try { try {
[$gruppo, $password] = app(GruppiServizio::class)->crea( [$gruppo, $password] = app(GruppiServizio::class)->crea(
$this->nome, $this->nome,
Proclamatore::findOrFail($this->sorveglianteId), Proclamatore::findOrFail($this->sorveglianteId),
trim($this->email),
); );
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->addError('sorveglianteId', $e->getMessage()); $this->addError('sorveglianteId', $e->getMessage());
@@ -58,7 +55,7 @@ class GruppiIndex extends Component
return; return;
} }
$this->reset(['nome', 'sorveglianteId', 'email']); $this->reset(['nome', 'sorveglianteId']);
$this->creatoId = $gruppo->id; $this->creatoId = $gruppo->id;
$this->passwordTemporanea = $password; $this->passwordTemporanea = $password;
} }
@@ -72,7 +69,6 @@ class GruppiIndex extends Component
{ {
$gruppi = GruppoServizio::with('sorvegliante') $gruppi = GruppoServizio::with('sorvegliante')
->withCount(['membri', 'territori']) ->withCount(['membri', 'territori'])
->orderByRaw('sciolto_at IS NOT NULL')
->orderBy('nome') ->orderBy('nome')
->get(); ->get();

View File

@@ -102,7 +102,7 @@ class GruppoShow extends Component
{ {
$this->servizio(); $this->servizio();
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first(); $utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
if (! $utente || $this->gruppo->sciolto_at) { if (! $utente) {
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.'); $this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
return; return;
@@ -115,11 +115,17 @@ class GruppoShow extends Component
$this->passwordTemporanea = $password; $this->passwordTemporanea = $password;
} }
/** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */ /**
public function sciogli(): void * Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
* torna all'elenco dei gruppi.
*/
public function sciogli()
{ {
$this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto."); $nome = $this->gruppo->nome;
$this->passwordTemporanea = null; $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.");
return $this->redirect(route('gruppi.index'), navigate: true);
} }
public function render() public function render()
@@ -129,21 +135,13 @@ class GruppoShow extends Component
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(); $membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values(); $territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
$attivo = ! $this->gruppo->sciolto_at;
return view('livewire.gruppi.gruppo-show', [ return view('livewire.gruppi.gruppo-show', [
'attivo' => $attivo,
'membri' => $membri, 'membri' => $membri,
'territori' => $territori, 'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo... // Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => $attivo 'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
: collect(),
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto). // ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto).
'candidatiTerritori' => $attivo 'candidatiTerritori' => Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values(),
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values()
: collect(),
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
]); ]);
} }
} }

View File

@@ -175,7 +175,7 @@ class ProclamatoreIndex extends Component
return view('livewire.proclamatori.proclamatore-index', [ return view('livewire.proclamatori.proclamatore-index', [
'proclamatori' => $proclamatori, 'proclamatori' => $proclamatori,
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie. // Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
'gruppiSorvegliati' => GruppoServizio::attivi()->pluck('nome', 'sorvegliante_id'), 'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
]); ]);
} }
} }

View File

@@ -6,7 +6,6 @@ use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
class ProclamatoreShow extends Component class ProclamatoreShow extends Component
@@ -40,7 +39,7 @@ class ProclamatoreShow extends Component
]; ];
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della // Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
// sua utenza), membro, o sorvegliante di gruppi ormai sciolti. // sua utenza) o membro. I gruppi sciolti non esistono più.
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato(); $gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null; $utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
@@ -50,10 +49,6 @@ class ProclamatoreShow extends Component
'gruppoSorvegliato' => $gruppoSorvegliato, 'gruppoSorvegliato' => $gruppoSorvegliato,
'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(), 'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(),
'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(), 'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(),
'gruppiPassati' => GruppoServizio::whereNotNull('sciolto_at')
->where('sorvegliante_id', $this->proclamatore->id)
->orderByDesc('sciolto_at')
->get(),
'utenza' => $utenza, 'utenza' => $utenza,
'ultimoAccesso' => $utenza 'ultimoAccesso' => $utenza
? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at') ? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at')

View File

@@ -20,22 +20,24 @@ class Profile extends Component
{ {
$user = auth()->user(); $user = auth()->user();
$this->name = $user->name; $this->name = $user->name;
$this->email = $user->email; $this->email = (string) $user->email;
} }
public function updateProfile() public function updateProfile()
{ {
$user = auth()->user(); $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();
$validated = $this->validate([ $validated = $this->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
] + ($senzaEmail ? [] : [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]); ]));
$user->fill([ $user->fill(['name' => $validated['name']] + ($senzaEmail ? [] : ['email' => $validated['email']]));
'name' => $validated['name'],
'email' => $validated['email'],
]);
$emailCambiata = $user->isDirty('email'); $emailCambiata = $user->isDirty('email');
$user->save(); $user->save();

View File

@@ -285,7 +285,7 @@ class Registro extends Component
public function render() public function render()
{ {
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna', 'gruppo']); $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
if ($this->filtroAnno) { if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno); $query->where('anno_teocratico_id', $this->filtroAnno);

View File

@@ -525,15 +525,18 @@ class XmlExchange extends Component
$utentiDaCollegare = []; $utentiDaCollegare = [];
foreach (($xml->users->user ?? []) as $userNode) { foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? '')); $email = trim((string) ($userNode->email ?? ''));
$username = trim((string) ($userNode->username ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? ''); $legacyId = (string) ($userNode['legacy_id'] ?? '');
if ($email === '') { // Il sorvegliante di gruppo non ha email: si riconosce dal nome utente.
$this->pushImportIssue('utente', $legacyId, 'Email mancante'); if ($email === '' && $username === '') {
$this->pushImportIssue('utente', $legacyId, 'Email e nome utente mancanti');
continue; continue;
} }
$user = User::query()->firstOrNew(['email' => $email]); $user = User::query()->firstOrNew($email !== '' ? ['email' => $email] : ['username' => $username]);
$user->name = (string) ($userNode->name ?? $email); $user->name = (string) ($userNode->name ?? ($email !== '' ? $email : $username));
$user->username = $username !== '' ? $username : null;
if (! $user->exists) { if (! $user->exists) {
// password is NOT NULL; the real hash is written raw below. // password is NOT NULL; the real hash is written raw below.
$user->password = Str::password(24); $user->password = Str::password(24);
@@ -657,10 +660,15 @@ class XmlExchange extends Component
$gruppoMap = []; $gruppoMap = [];
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) { foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? ''); $legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
// non si importa.
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
continue;
}
$g = GruppoServizio::create([ $g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome, 'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null, 'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
'sciolto_at' => $this->normalizeDateTimeForDb((string) ($gruppoNode->sciolto_at ?? '')),
]); ]);
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode); $this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
$legacyId = (string) ($gruppoNode['legacy_id'] ?? ''); $legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
@@ -809,6 +817,7 @@ class XmlExchange extends Component
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1, 'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1, 'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null, 'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null,
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null, 'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null, 'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''), 'note' => (string) ($assegnazioneNode->note ?? ''),
@@ -860,6 +869,7 @@ class XmlExchange extends Component
'legacy_id' => (string) $u->id, 'legacy_id' => (string) $u->id,
'name' => (string) $u->name, 'name' => (string) $u->name,
'email' => (string) $u->email, 'email' => (string) $u->email,
'username' => (string) $u->username,
'password' => (string) $u->getAuthPassword(), 'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '', 'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
// remember_token no: all'import si azzera comunque, e nel file // remember_token no: all'import si azzera comunque, e nel file
@@ -874,11 +884,11 @@ class XmlExchange extends Component
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']), 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'sciolto_at', 'created_at', 'updated_at']), 'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']), 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']), 'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']), 'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'pdf_access_code', 'created_at', 'updated_at']), 'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1275,6 +1285,7 @@ class XmlExchange extends Component
} }
$this->addXmlText($node, 'name', (string) ($user['name'] ?? '')); $this->addXmlText($node, 'name', (string) ($user['name'] ?? ''));
$this->addXmlText($node, 'email', (string) ($user['email'] ?? '')); $this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'username', (string) ($user['username'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? '')); $this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? '')); $node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? '')); $node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
@@ -1352,7 +1363,6 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $gruppo); $this->addLegacyIdAttribute($node, $gruppo);
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? '')); $node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
$node->addChild('sciolto_at', (string) ($gruppo['sciolto_at'] ?? ''));
$this->addTimestampNodes($node, $gruppo); $this->addTimestampNodes($node, $gruppo);
} }
@@ -1382,6 +1392,7 @@ class XmlExchange extends Component
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0))); $node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0))); $node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? '')); $node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? '')); $this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? '')); $this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));

View File

@@ -21,7 +21,7 @@ class TerritorioShow extends Component
public function render() public function render()
{ {
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id) $assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo']) ->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
->orderByDesc('assigned_at') ->orderByDesc('assigned_at')
->get() ->get()
->groupBy(fn($a) => $a->annoTeocratico->label); ->groupBy(fn($a) => $a->annoTeocratico->label);

View File

@@ -26,6 +26,7 @@ class Assegnazione extends Model
'created_by', 'created_by',
'returned_by', 'returned_by',
'gruppo_servizio_id', 'gruppo_servizio_id',
'gruppo_nome',
]; ];
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */ /** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */

View File

@@ -17,9 +17,9 @@ use Spatie\Activitylog\Traits\LogsActivity;
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in * gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
* reparto ed esce dal pacchetto. * reparto ed esce dal pacchetto.
* *
* Un gruppo sciolto (sciolto_at) non ha più membri né pacchetto, ma resta: le * Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
* assegnazioni nate da lui continuano a citarlo. Logica in * ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
* App\Services\GruppiServizio. * il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
*/ */
class GruppoServizio extends Model class GruppoServizio extends Model
{ {
@@ -27,17 +27,17 @@ class GruppoServizio extends Model
protected $table = 'gruppi_servizio'; protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id', 'sciolto_at']; protected $fillable = ['nome', 'sorvegliante_id'];
protected function casts(): array protected function casts(): array
{ {
return ['sciolto_at' => 'datetime', 'sorvegliante_id' => 'integer']; return ['sorvegliante_id' => 'integer'];
} }
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {
return LogOptions::defaults() return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id', 'sciolto_at']) ->logOnly(['nome', 'sorvegliante_id'])
->logOnlyDirty() ->logOnlyDirty()
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
@@ -63,16 +63,6 @@ class GruppoServizio extends Model
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id'); return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
} }
public function getScioltoAttribute(): bool
{
return $this->sciolto_at !== null;
}
public function scopeAttivi($query)
{
return $query->whereNull('sciolto_at');
}
/** /**
* I numeri del gruppo per la scheda del proclamatore e il profilo del * I numeri del gruppo per la scheda del proclamatore e il profilo del
* sorvegliante: solo conteggi in SQL, nessun nome da decifrare. * sorvegliante: solo conteggi in SQL, nessun nome da decifrare.

View File

@@ -52,7 +52,7 @@ class Proclamatore extends Model
/** Il gruppo attivo di cui è sorvegliante, se c'è. */ /** Il gruppo attivo di cui è sorvegliante, se c'è. */
public function gruppoSorvegliato(): ?GruppoServizio public function gruppoSorvegliato(): ?GruppoServizio
{ {
return GruppoServizio::attivi()->where('sorvegliante_id', $this->id)->first(); return GruppoServizio::where('sorvegliante_id', $this->id)->first();
} }
private function lasciaIlGruppo(string $motivo): void private function lasciaIlGruppo(string $motivo): void
@@ -185,7 +185,8 @@ class Proclamatore extends Model
$utente->syncRoles([]); $utente->syncRoles([]);
$utente->forceFill([ $utente->forceFill([
'name' => 'Anonimo', 'name' => 'Anonimo',
'email' => "utente-{$utente->id}@anonimo.invalid", 'email' => null,
'username' => "anonimo.{$utente->id}",
'password' => \Illuminate\Support\Str::random(40), 'password' => \Illuminate\Support\Str::random(40),
'remember_token' => null, 'remember_token' => null,
'disattivato_at' => $utente->disattivato_at ?? now(), 'disattivato_at' => $utente->disattivato_at ?? now(),

View File

@@ -22,6 +22,7 @@ class User extends Authenticatable implements MustVerifyEmail
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email',
'username',
'password', 'password',
'password_change_required', 'password_change_required',
'proclamatore_id', 'proclamatore_id',
@@ -59,14 +60,23 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasRole('sorvegliante'); return $this->hasRole('sorvegliante');
} }
/** Il gruppo (attivo) di cui è sorvegliante, se c'è. */ /** Il gruppo di cui è sorvegliante, se c'è (sciolto, il gruppo non esiste più). */
public function gruppoSorvegliato(): ?GruppoServizio public function gruppoSorvegliato(): ?GruppoServizio
{ {
return $this->proclamatore_id return $this->proclamatore_id
? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first() ? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
: null; : null;
} }
/**
* 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.
*/
public function getIdentificativoAttribute(): string
{
return (string) ($this->email ?? $this->username ?? $this->name);
}
/** Utenza disattivata (gruppo sciolto): non può più entrare. */ /** Utenza disattivata (gruppo sciolto): non può più entrare. */
public function isDisattivato(): bool public function isDisattivato(): bool
{ {

View File

@@ -22,20 +22,24 @@ use InvalidArgumentException;
class GruppiServizio class GruppiServizio
{ {
/** /**
* Crea il gruppo e l'utenza del sorvegliante: ruolo "sorvegliante", * Crea il gruppo e l'utenza del sorvegliante: nome utente ricavato dal
* password temporanea da cambiare al primo accesso, nessuna conferma * nome del gruppo (niente email), ruolo "sorvegliante", password
* dell'email. Restituisce il gruppo e la password temporanea, da mostrare * temporanea da cambiare al primo accesso. Restituisce il gruppo e la
* una volta sola all'amministratore. * password temporanea, da mostrare una volta sola all'amministratore
* insieme al nome utente ($gruppo->sorvegliante->utente->username).
* *
* @param int[] $membri id dei proclamatori, oltre al sorvegliante * @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string} * @return array{0: GruppoServizio, 1: string}
*/ */
public function crea(string $nome, Proclamatore $sorvegliante, string $email, array $membri = []): array public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
{ {
$nome = trim($nome); $nome = trim($nome);
if ($nome === '') { if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.'); throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
} }
if (GruppoServizio::where('nome', $nome)->exists()) {
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
}
if (! $sorvegliante->attivo || $sorvegliante->trashed()) { if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.'); throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
} }
@@ -48,13 +52,10 @@ class GruppiServizio
if ($esistente && ! $esistente->isDisattivato()) { 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)->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); $password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) { $gruppo = DB::transaction(function () use ($nome, $sorvegliante, $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);
@@ -62,7 +63,7 @@ class GruppiServizio
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]); $utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([ $utente->forceFill([
'name' => $sorvegliante->nome_completo, 'name' => $sorvegliante->nome_completo,
'email' => $email, 'username' => self::nomeUtente($nome),
'password' => $password, 'password' => $password,
'password_change_required' => true, 'password_change_required' => true,
'disattivato_at' => null, 'disattivato_at' => null,
@@ -79,6 +80,23 @@ class GruppiServizio
return [$gruppo, $password]; return [$gruppo, $password];
} }
/**
* 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.
*/
public static function nomeUtente(string $nomeGruppo): string
{
$base = Str::limit(Str::slug($nomeGruppo, '.') ?: 'gruppo', 50, '');
$nome = $base;
for ($n = 2; User::where('username', $nome)->exists(); $n++) {
$nome = $base . $n;
}
return $nome;
}
/** @param int[] $ids */ /** @param int[] $ids */
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
{ {
@@ -205,6 +223,8 @@ class GruppiServizio
'assigned_at' => $data, 'assigned_at' => $data,
'created_by' => auth()->id(), 'created_by' => auth()->id(),
'gruppo_servizio_id' => $gruppo->id, 'gruppo_servizio_id' => $gruppo->id,
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
'gruppo_nome' => $gruppo->nome,
'link_da_inviare' => (bool) $territorio->pdf_path, 'link_da_inviare' => (bool) $territorio->pdf_path,
]); ]);
}); });
@@ -225,52 +245,58 @@ class GruppiServizio
} }
/** /**
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del * Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
* pacchetto non ancora dati tornano in reparto, i membri restano senza * sorvegliante non è più attivo) e lo elimina: non resta nello storico.
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già * I territori del pacchetto non ancora dati tornano in reparto; quelli
* fatte restano com'erano, con il loro riferimento al gruppo, che resta * già dati restano ai membri come assegnazioni qualsiasi, che conservano
* nello storico come sciolto. * 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.
*/ */
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{ {
if ($gruppo->sciolto_at) { if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
return; return;
} }
DB::transaction(function () use ($gruppo, $motivo) { DB::transaction(function () use ($gruppo, $motivo) {
// Esce dal pacchetto solo quello non ancora dato: un territorio $liberati = Territorio::withTrashed()
// assegnato resta del suo gruppo finché non rientra (Assegnazione
// lo libera al rientro), così non può essere ridato due volte.
$nonDati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id) ->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at')); ->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
$liberati = (clone $nonDati)->pluck('numero'); ->pluck('numero');
$nonDati->update(['gruppo_servizio_id' => null]); Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]); Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first(); $utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente) { if ($utente) {
$utente->removeRole('sorvegliante'); $utente->removeRole('sorvegliante');
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save(); // 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();
} }
$gruppo->update(['sciolto_at' => now()]);
activity()->causedBy(auth()->user())->performedOn($gruppo) activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(array_filter([ ->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(), 'territori_liberati' => $liberati->all(),
'utente_disattivato' => $utente?->id, 'utente_disattivato' => $utente?->id,
'motivo' => $motivo, 'motivo' => $motivo,
], fn ($v) => $v !== null)) ], fn ($v) => $v !== null))
->log('gruppo_sciolto'); ->log('gruppo_sciolto');
$gruppo->delete();
}); });
} }
private function attivo(GruppoServizio $gruppo): void private function attivo(GruppoServizio $gruppo): void
{ {
if ($gruppo->sciolto_at) { if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.'); throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
} }
} }
} }

View File

@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Gruppi di servizio, 20 ottobre 2026 (decisioni dell'utente):
*
* - un gruppo sciolto non resta nello storico: si elimina. Le assegnazioni
* già date dal sorvegliante conservano il nome del gruppo
* (assegnazioni.gruppo_nome), così "via gruppo …" resta leggibile;
* - il sorvegliante entra con un nome utente ricavato dal nome del gruppo,
* non con l'email: users.username, e users.email diventa facoltativa.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('username', 60)->nullable()->unique()->after('email');
});
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable()->change();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->string('gruppo_nome', 100)->nullable()->after('gruppo_servizio_id');
});
foreach (DB::table('gruppi_servizio')->get() as $gruppo) {
DB::table('assegnazioni')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_nome' => $gruppo->nome]);
if ($gruppo->sciolto_at !== null) {
// Già sciolto: pacchetto e membri erano già stati liberati.
DB::table('territori')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
DB::table('proclamatori')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
DB::table('assegnazioni')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
DB::table('gruppi_servizio')->where('id', $gruppo->id)->delete();
continue;
}
// Sorvegliante di un gruppo attivo: il nome utente dal nome del gruppo.
$utente = DB::table('users')->where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente && $utente->username === null) {
$base = Str::slug($gruppo->nome, '.') ?: 'gruppo';
$base = Str::limit($base, 50, '');
$username = $base;
for ($n = 2; DB::table('users')->where('username', $username)->exists(); $n++) {
$username = $base . $n;
}
DB::table('users')->where('id', $utente->id)->update(['username' => $username]);
}
}
Schema::table('gruppi_servizio', function (Blueprint $table) {
$table->dropColumn('sciolto_at');
});
}
public function down(): void
{
Schema::table('gruppi_servizio', function (Blueprint $table) {
$table->timestamp('sciolto_at')->nullable();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn('gruppo_nome');
});
// Senza email l'utenza non si può tenere com'era: chi non ce l'ha
// riceve un indirizzo segnaposto, da correggere a mano.
DB::table('users')->whereNull('email')->orderBy('id')->each(function ($u) {
DB::table('users')->where('id', $u->id)->update(['email' => ($u->username ?? "utente-{$u->id}") . '@senza-email.invalid']);
});
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['username']);
$table->dropColumn('username');
});
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable(false)->change();
});
}
};

View File

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

View File

@@ -16,8 +16,10 @@
<form method="POST" action="{{ route('login.store') }}" class="space-y-5"> <form method="POST" action="{{ route('login.store') }}" class="space-y-5">
@csrf @csrf
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label> {{-- Email, o nome utente per il sorvegliante di un gruppo di servizio --}}
<input name="email" value="{{ old('email') }}" type="email" id="email" autocomplete="email" <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"
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"> 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 @error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </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> <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"> <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">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code>{{ $creato->sorvegliante?->utente?->email }}</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">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd> <dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl> </dl>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
@@ -29,6 +29,7 @@
<label for="nome-gruppo" class="block text-sm font-medium text-gray-700 mb-1">Nome del gruppo *</label> <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" <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"> 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 @error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
@@ -37,13 +38,6 @@
<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.</p>
@error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="email-sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Email per l'accesso *</label>
<input wire:model="email" id="email-sorvegliante" type="email" autocomplete="off"
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">Resta solo nell'utenza del sorvegliante (non nel proclamatore) e non va confermata.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn> <x-btn variante="primary" type="submit">Crea il gruppo</x-btn>
</form> </form>
</section> </section>
@@ -55,15 +49,12 @@
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3"> <li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a> <a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a>
@if($g->sciolto_at)
<span class="ml-1 inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">sciolto il {{ $g->sciolto_at->format('d/m/Y') }}</span>
@endif
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }} Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
@unless($g->sciolto_at) · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto @endunless · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto
</p> </p>
</div> </div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">{{ $g->sciolto_at ? 'Dettaglio' : 'Gestisci' }}</x-btn> <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">Gestisci</x-btn>
</li> </li>
@empty @empty
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li> <li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>

View File

@@ -7,15 +7,10 @@
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1> <h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong> Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
@if($attivo && $gruppo->sorvegliante?->utente) @if($gruppo->sorvegliante?->utente)
· accede con <code>{{ $gruppo->sorvegliante->utente->email }}</code> · nome utente <code class="select-all">{{ $gruppo->sorvegliante->utente->identificativo }}</code>
@endif @endif
</p> </p>
@unless($attivo)
<p class="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
Sciolto il {{ $gruppo->sciolto_at->format('d/m/Y') }} · {{ $assegnazioniDelGruppo }} assegnazioni nate dal gruppo restano nello storico
</p>
@endunless
</div> </div>
@if($passwordTemporanea) @if($passwordTemporanea)
@@ -25,7 +20,6 @@
</div> </div>
@endif @endif
@if($attivo)
{{-- ─── Pacchetto ─────────────────────────────────────────── --}} {{-- ─── Pacchetto ─────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto"> <section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto">
<h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800"> <h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
@@ -127,8 +121,7 @@
<x-btn variante="secondary" wire:click="nuovaPassword" <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> 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" <x-btn variante="danger" wire:click="sciogli"
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie: 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.">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 disattivata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
</div> </div>
</section> </section>
@endif
</div> </div>

View File

@@ -18,8 +18,8 @@
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<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>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>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 e stato del suo invio (inviato o no, e con quale canale).</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, tranne per i sorveglianti di gruppo), password conservata solo come impronta non reversibile, ruolo. L'utenza di un sorvegliante è collegata al suo proclamatore e viene disattivata quando il gruppo si scioglie.</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>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</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> </ul>
</section> </section>
@@ -58,7 +58,7 @@
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li> <li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
@endforeach @endforeach
</ul> </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 (email 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 (nome utente e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
</section> </section>
<section> <section>
@@ -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, insieme a nome ed email 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, insieme al nome 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

@@ -40,7 +40,7 @@
</div> </div>
{{-- Gruppo di servizio --}} {{-- Gruppo di servizio --}}
@if($gruppoSorvegliato || $gruppoMembro || $gruppiPassati->isNotEmpty()) @if($gruppoSorvegliato || $gruppoMembro)
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" aria-labelledby="titolo-gruppo"> <section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" aria-labelledby="titolo-gruppo">
<h3 id="titolo-gruppo" class="text-lg font-semibold text-gray-900 mb-3">Gruppo di servizio</h3> <h3 id="titolo-gruppo" class="text-lg font-semibold text-gray-900 mb-3">Gruppo di servizio</h3>
@@ -78,7 +78,7 @@
@if($utenza) @if($utenza)
<p class="mt-4 border-t border-gray-100 pt-3 text-sm text-gray-600"> <p class="mt-4 border-t border-gray-100 pt-3 text-sm text-gray-600">
Utenza Utenza
@can('settings.manage') <code>{{ $utenza->email }}</code> @endcan @can('settings.manage') <code>{{ $utenza->identificativo }}</code> @endcan
· {{ $utenza->password_change_required ? 'password temporanea, ancora da cambiare' : 'password personale impostata' }} · {{ $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' }} · {{ $ultimoAccesso ? 'ultimo accesso il ' . \Illuminate\Support\Carbon::parse($ultimoAccesso)->format('d/m/Y H:i') : 'non è ancora entrato' }}
</p> </p>
@@ -94,15 +94,6 @@
sorvegliante {{ $gruppoMembro->sorvegliante?->nome_completo ?? '—' }}. sorvegliante {{ $gruppoMembro->sorvegliante?->nome_completo ?? '—' }}.
</p> </p>
@endif @endif
@if($gruppiPassati->isNotEmpty())
<p class="{{ $gruppoSorvegliato || $gruppoMembro ? 'mt-3' : '' }} text-xs text-gray-500">
In passato sorvegliante di:
@foreach($gruppiPassati as $g)
{{ $g->nome }} (sciolto il {{ $g->sciolto_at->format('d/m/Y') }}){{ $loop->last ? '.' : ',' }}
@endforeach
</p>
@endif
</section> </section>
@endif @endif

View File

@@ -17,7 +17,7 @@
<div class="min-w-0"> <div class="min-w-0">
<h2 class="text-xl font-bold text-gray-900 truncate">{{ $user->name }}</h2> <h2 class="text-xl font-bold text-gray-900 truncate">{{ $user->name }}</h2>
<p class="text-sm text-gray-500 truncate mt-0.5"> <p class="text-sm text-gray-500 truncate mt-0.5">
<span class="break-all">{{ $user->email }}</span> <span class="break-all">{{ $user->identificativo }}</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"> <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') }} {{ ucfirst($user->roles->first()?->name ?? 'utente') }}
</span> </span>
@@ -59,7 +59,7 @@
{{-- Dati profilo --}} {{-- Dati profilo --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3> <h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3>
<p class="text-sm text-gray-500 mb-4">Aggiorna nome e cognome ed email.</p> <p class="text-sm text-gray-500 mb-4">{{ $user->isSorvegliante() ? 'Aggiorna nome e cognome.' : 'Aggiorna nome e cognome ed email.' }}</p>
@if(session('profile_success')) @if(session('profile_success'))
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800"> <div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
@@ -73,11 +73,20 @@
<input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> <input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
@if($user->isSorvegliante())
{{-- Il sorvegliante entra con il nome utente del gruppo, senza email --}}
<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>
</div>
@else
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label> <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> <input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
@endif
<div class="flex justify-end"> <div class="flex justify-end">
<x-btn variante="primary" type="submit">Salva modifiche</x-btn> <x-btn variante="primary" type="submit">Salva modifiche</x-btn>
</div> </div>

View File

@@ -87,7 +87,7 @@
</div> </div>
</div> </div>
</td> </td>
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo->nome }}</span>@endif</td> <td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo_nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td> <td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2"> <td data-label="Rientrato" class="px-3 py-2">
@if($a->returned_at) @if($a->returned_at)

View File

@@ -111,8 +111,14 @@
@else @else
<td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td> <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"> <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 }} {{ $user->email }}
@if($user->hasVerifiedEmail()) @endif
@if(! $user->email)
@elseif($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> <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 @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" <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"

View File

@@ -178,7 +178,7 @@
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione) @foreach($assegnazioni as $assegnazione)
<tr> <tr>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo->nome }}</span>@endif</td> <td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td> <td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2"> <td data-label="Rientrato" class="px-3 py-2">
@if($assegnazione->returned_at) @if($assegnazione->returned_at)

View File

@@ -68,12 +68,12 @@ class GruppiServizioTest extends TestCase
$rossi = $this->proclamatore('Rossi', 'Mario'); $rossi = $this->proclamatore('Rossi', 'Mario');
$verdi = $this->proclamatore('Verdi', 'Anna'); $verdi = $this->proclamatore('Verdi', 'Anna');
$bianchi = $this->proclamatore('Bianchi', 'Luca'); $bianchi = $this->proclamatore('Bianchi', 'Luca');
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, 'mario@example.test', [$verdi->id, $bianchi->id]); [$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, [$verdi->id, $bianchi->id]);
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n)); $territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all()); app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
$territori = $territori->map->fresh(); $territori = $territori->map->fresh();
$sorvegliante = User::where('email', 'mario@example.test')->sole(); $sorvegliante = User::where('username', 'zisa')->sole();
// Il cambio password al primo accesso ha i suoi test: qui si lavora. // Il cambio password al primo accesso ha i suoi test: qui si lavora.
// E l'email non confermata, come nella realtà (TestCase le conferma). // E l'email non confermata, come nella realtà (TestCase le conferma).
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save(); $sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
@@ -87,16 +87,20 @@ class GruppiServizioTest extends TestCase
{ {
$rossi = $this->proclamatore('Rossi', 'Mario'); $rossi = $this->proclamatore('Rossi', 'Mario');
// Niente email: il nome utente viene dal nome del gruppo.
$pagina = Livewire::test(GruppiIndex::class) $pagina = Livewire::test(GruppiIndex::class)
->assertDontSee('Email per l')
->set('nome', 'Zisa') ->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id) ->set('sorveglianteId', $rossi->id)
->set('email', 'mario@example.test')
->call('crea') ->call('crea')
->assertHasNoErrors(); ->assertHasNoErrors()
->assertSee('Nome utente')
->assertSee('zisa');
$gruppo = GruppoServizio::sole(); $gruppo = GruppoServizio::sole();
$utente = User::where('email', 'mario@example.test')->sole(); $utente = User::where('username', 'zisa')->sole();
$this->assertNull($utente->email);
$this->assertSame($rossi->id, $gruppo->sorvegliante_id); $this->assertSame($rossi->id, $gruppo->sorvegliante_id);
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro'); $this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
$this->assertSame($rossi->id, $utente->proclamatore_id); $this->assertSame($rossi->id, $utente->proclamatore_id);
@@ -114,10 +118,27 @@ class GruppiServizioTest extends TestCase
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password)); $this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
} }
public function test_il_nome_utente_viene_dal_nome_del_gruppo(): void
{
$servizio = app(GruppiServizio::class);
$servizio->crea('Gruppo Noce Città', $this->proclamatore('Neri'));
$this->assertSame('gruppo.noce.citta', User::whereNotNull('username')->sole()->username);
// 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());
// Due gruppi con lo stesso nome no.
$this->expectException(InvalidArgumentException::class);
$servizio->crea('Zisa', $this->proclamatore('Blu'));
}
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
{ {
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo(); ['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'), 'neri@example.test'); [$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'));
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]); app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
@@ -199,16 +220,25 @@ class GruppiServizioTest extends TestCase
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio'); $this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
} }
public function test_il_sorvegliante_entra_senza_conferma_dell_email_e_va_al_suo_gruppo(): void public function test_il_sorvegliante_entra_con_il_nome_utente_e_va_al_suo_gruppo(): void
{ {
$this->gruppo(); $this->gruppo();
$this->flushSession(); $this->flushSession();
auth()->logout(); auth()->logout();
$password = 'una-password-nuova-1'; $password = 'una-password-nuova-1';
User::where('email', 'mario@example.test')->sole()->forceFill(['password' => $password])->save(); User::where('username', 'zisa')->sole()->forceFill(['password' => $password])->save();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password]) $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])
->assertRedirect(route('mio-gruppo')); ->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 ────────────────────────────── // ─── Il sorvegliante al lavoro ──────────────────────────────
@@ -322,19 +352,29 @@ class GruppiServizioTest extends TestCase
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo(); ['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()); $data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])->call('sciogli'); Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
->call('sciogli')
->assertRedirect(route('gruppi.index'));
$gruppo->refresh(); // Il gruppo non resta nello storico: non esiste più.
$this->assertNotNull($gruppo->sciolto_at); $this->assertNull(GruppoServizio::find($gruppo->id));
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale. $this->get(route('gruppi.index'))->assertOk()->assertSee('Nessun gruppo di servizio');
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione
// normale, che del gruppo conserva solo il nome.
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id); $this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id); $this->assertSame('in_reparto', $territori[2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at); $this->assertNull($data->fresh()->returned_at);
$this->assertSame($gruppo->id, $data->fresh()->gruppo_servizio_id, 'Lo storico continua a dirlo'); $this->assertSame('assegnato', $territori[0]->fresh()->stato);
// Membri liberi, utenza disattivata e senza ruolo. $this->assertNull($data->fresh()->gruppo_servizio_id);
$this->assertSame(0, Proclamatore::where('gruppo_servizio_id', $gruppo->id)->count()); $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.
$this->assertSame(0, Proclamatore::whereNotNull('gruppo_servizio_id')->count());
$this->assertTrue($s->fresh()->isDisattivato()); $this->assertTrue($s->fresh()->isDisattivato());
$this->assertFalse($s->fresh()->hasRole('sorvegliante')); $this->assertFalse($s->fresh()->hasRole('sorvegliante'));
$this->assertSame("ex.sorvegliante.{$s->id}", $s->fresh()->username);
$this->assertFalse(User::where('username', 'zisa')->exists());
// Per ripartire serve un gruppo nuovo. // Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
@@ -350,7 +390,7 @@ class GruppiServizioTest extends TestCase
$this->flushSession(); $this->flushSession();
auth()->logout(); auth()->logout();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password]) $this->post(route('login.store'), ['email' => 'zisa', 'password' => $password])
->assertSessionHasErrors('email'); ->assertSessionHasErrors('email');
$this->assertGuest(); $this->assertGuest();
@@ -365,13 +405,14 @@ class GruppiServizioTest extends TestCase
$pagina = Livewire::test(GruppiIndex::class); $pagina = Livewire::test(GruppiIndex::class);
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati'); $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'); // Sciolto, il nome non è più occupato: si può rifare "Zisa".
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
$utente = $s->fresh(); $utente = $s->fresh();
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza'); $this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
$this->assertFalse($utente->isDisattivato()); $this->assertFalse($utente->isDisattivato());
$this->assertTrue($utente->hasRole('sorvegliante')); $this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertSame('mario.nuovo@example.test', $utente->email); $this->assertSame('zisa', $utente->username);
$this->assertTrue($utente->password_change_required); $this->assertTrue($utente->password_change_required);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password)); $this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id); $this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
@@ -382,7 +423,8 @@ class GruppiServizioTest extends TestCase
/** Il gruppo è sciolto come da "Togli il ruolo": tutto tranne l'assegnazione già fatta. */ /** Il gruppo è sciolto come da "Togli il ruolo": tutto tranne l'assegnazione già fatta. */
private function assertSciolto(array $g, Assegnazione $data): void private function assertSciolto(array $g, Assegnazione $data): void
{ {
$this->assertNotNull($g['gruppo']->fresh()->sciolto_at, 'Gruppo sciolto'); $this->assertNull(GruppoServizio::find($g['gruppo']->id), 'Gruppo sciolto ed eliminato');
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto'); $this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto');
$this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato); $this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro'); $this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
@@ -406,7 +448,7 @@ class GruppiServizioTest extends TestCase
// Riattivarlo non ricostruisce il gruppo: se ne crea uno nuovo. // Riattivarlo non ricostruisce il gruppo: se ne crea uno nuovo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('toggleActive', $g['rossi']->id); Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('toggleActive', $g['rossi']->id);
$this->assertNotNull($g['gruppo']->fresh()->sciolto_at); $this->assertSame(0, GruppoServizio::count());
$this->assertNull($g['rossi']->fresh()->gruppoSorvegliato()); $this->assertNull($g['rossi']->fresh()->gruppoSorvegliato());
} }
@@ -445,7 +487,7 @@ class GruppiServizioTest extends TestCase
->call('toggleActive', $verdi->id) ->call('toggleActive', $verdi->id)
->call('deleteProclamatore', $bianchi->id); ->call('deleteProclamatore', $bianchi->id);
$this->assertNull($gruppo->fresh()->sciolto_at); $this->assertNotNull(GruppoServizio::find($gruppo->id));
$this->assertSame($gruppo->id, $verdi->fresh()->gruppo_servizio_id, 'Non attivo resta membro, ma non riceve territori'); $this->assertSame($gruppo->id, $verdi->fresh()->gruppo_servizio_id, 'Non attivo resta membro, ma non riceve territori');
$this->assertNull($bianchi->fresh()->gruppo_servizio_id, 'Nel cestino lascia il gruppo'); $this->assertNull($bianchi->fresh()->gruppo_servizio_id, 'Nel cestino lascia il gruppo');
} }
@@ -459,17 +501,18 @@ class GruppiServizioTest extends TestCase
$this->get(route('proclamatori.show', $g['rossi']))->assertOk() $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']) ->assertSeeInOrder(['Gruppo di servizio', 'Sorvegliante', 'Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Assegnazioni dal gruppo', '1'])
->assertSee('mario@example.test') ->assertSee('<code>zisa</code>', false)
->assertSee('non è ancora entrato'); ->assertSee('non è ancora entrato');
$this->get(route('proclamatori.show', $g['verdi']))->assertOk() $this->get(route('proclamatori.show', $g['verdi']))->assertOk()
->assertSee('Membro del gruppo') ->assertSee('Membro del gruppo')
->assertSee('sorvegliante Rossi Mario'); ->assertSee('sorvegliante Rossi Mario');
// Sciolto il gruppo, nella scheda non ne resta traccia.
app(GruppiServizio::class)->sciogli($g['gruppo']); app(GruppiServizio::class)->sciogli($g['gruppo']);
$this->get(route('proclamatori.show', $g['rossi']))->assertOk() $this->get(route('proclamatori.show', $g['rossi']))->assertOk()
->assertSee('In passato sorvegliante di:') ->assertDontSee('Assegnazioni dal gruppo')
->assertDontSee('Assegnazioni dal gruppo'); ->assertDontSee('In passato');
} }
public function test_chi_fa_parte_di_un_gruppo_e_segnalato_in_elenco_scheda_e_scelta(): void public function test_chi_fa_parte_di_un_gruppo_e_segnalato_in_elenco_scheda_e_scelta(): void
@@ -529,7 +572,8 @@ class GruppiServizioTest extends TestCase
$utente = $s->fresh(); $utente = $s->fresh();
$this->assertSame('Anonimo', $utente->name); $this->assertSame('Anonimo', $utente->name);
$this->assertStringEndsWith('@anonimo.invalid', $utente->email); $this->assertNull($utente->email);
$this->assertSame("anonimo.{$utente->id}", $utente->username);
$this->assertTrue($utente->isDisattivato()); $this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles); $this->assertCount(0, $utente->roles);
} }
@@ -579,6 +623,25 @@ class GruppiServizioTest extends TestCase
$this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all()); $this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all());
$this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all()); $this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id); $this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame($g->sorvegliante_id, User::where('email', 'mario@example.test')->sole()->proclamatore_id); $this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
$utenza = User::where('username', 'zisa')->sole();
$this->assertNull($utenza->email);
$this->assertSame($g->sorvegliante_id, $utenza->proclamatore_id);
}
public function test_l_xml_conserva_il_nome_del_gruppo_sulle_assegnazioni_dopo_lo_scioglimento(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
app(GruppiServizio::class)->sciogli($gruppo);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$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());
} }
} }