From 7904c96db83c1882dee79ba0f186ae0294ed1039 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Wed, 21 Oct 2026 17:32:09 +0200 Subject: [PATCH] ++ Richieste dal modulo pubblico, territori cartacei, S-13 mensile per email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /richiesta: modulo pubblico (nuovo territorio o riconsegna) con campo trappola, tempo minimo e limite orario; nome e note cifrati, avviso per email a chi ha richieste.gestisci (amministratore, assistente) - Home: riquadro Richieste con i proclamatori dal nome simile; porta su Assegna/Rientra già esistenti, che chiudono la richiesta; Scarta - Cartaceo sull'assegnazione (Assegna e Il mio gruppo), etichetta nell'elenco degli assegnati, pagina Cartacei, passa per l'XML - registro:invia-s13 agli amministratori l'ultima domenica del mese; richieste:pulizia dopo 90 giorni dalla gestione - Privacy, README e HANDOFF aggiornati; test in RichiesteTest Co-Authored-By: Claude Opus 5.5 --- HANDOFF.md | 65 ++- README.md | 67 ++- app/Console/Commands/InviaModuloS13.php | 75 ++++ app/Console/Commands/PuliziaRichieste.php | 34 ++ app/Jobs/ResetApplication.php | 5 +- app/Livewire/Assegnazioni/Assegna.php | 33 ++ app/Livewire/Assegnazioni/Cartacei.php | 25 ++ app/Livewire/Assegnazioni/Rientra.php | 19 + app/Livewire/Gruppi/MioGruppo.php | 10 +- app/Livewire/Home.php | 43 ++ app/Livewire/Privacy.php | 6 +- app/Livewire/Richieste/ModuloRichiesta.php | 97 +++++ app/Livewire/Settings/XmlExchange.php | 4 +- app/Mail/ModuloS13Mail.php | 50 +++ app/Models/Assegnazione.php | 3 + app/Models/Richiesta.php | 112 +++++ app/Notifications/RichiestaRicevuta.php | 42 ++ app/Services/GruppiServizio.php | 5 +- app/Services/RichiesteServizio.php | 154 +++++++ ...2026_10_21_000010_richieste_e_cartacei.php | 66 +++ .../seeders/RolesAndPermissionsSeeder.php | 3 + resources/css/app.css | 11 + .../views/components/layouts/app.blade.php | 5 + .../livewire/assegnazioni/assegna.blade.php | 13 + .../livewire/assegnazioni/cartacei.blade.php | 47 +++ .../assegnazioni/elenco-assegnati.blade.php | 2 +- .../livewire/assegnazioni/rientra.blade.php | 7 + .../livewire/gruppi/mio-gruppo.blade.php | 6 +- resources/views/livewire/home.blade.php | 55 +++ resources/views/livewire/privacy.blade.php | 9 +- .../richieste/modulo-richiesta.blade.php | 74 ++++ resources/views/mail/modulo-s13.blade.php | 7 + routes/console.php | 12 + routes/web.php | 8 + tests/Feature/GruppiServizioTest.php | 20 +- tests/Feature/RichiesteTest.php | 393 ++++++++++++++++++ 36 files changed, 1563 insertions(+), 24 deletions(-) create mode 100644 app/Console/Commands/InviaModuloS13.php create mode 100644 app/Console/Commands/PuliziaRichieste.php create mode 100644 app/Livewire/Assegnazioni/Cartacei.php create mode 100644 app/Livewire/Richieste/ModuloRichiesta.php create mode 100644 app/Mail/ModuloS13Mail.php create mode 100644 app/Models/Richiesta.php create mode 100644 app/Notifications/RichiestaRicevuta.php create mode 100644 app/Services/RichiesteServizio.php create mode 100644 database/migrations/2026_10_21_000010_richieste_e_cartacei.php create mode 100644 resources/views/livewire/assegnazioni/cartacei.blade.php create mode 100644 resources/views/livewire/richieste/modulo-richiesta.blade.php create mode 100644 resources/views/mail/modulo-s13.blade.php create mode 100644 tests/Feature/RichiesteTest.php diff --git a/HANDOFF.md b/HANDOFF.md index 98904e7..f59db61 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. -Ultimo aggiornamento: **20 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale). +Ultimo aggiornamento: **21 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale). --- @@ -34,14 +34,18 @@ produzione** — l'utente ha chiesto di aspettare il suo ok: - pulsante **Rientro** nell'elenco dei territori assegnati; - storico assegnazioni in una tabella sola, con le colonne in linea; - *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori - assegnati. + assegnati; +- password del sorvegliante dal pannello *Utenti*, anteprima dal numero in "Il + mio gruppo"; +- [modulo pubblico delle richieste, cartacei, S-13 per email](#richieste-dal-modulo-pubblico-cartacei-s-13-per-email-21-ottobre-2026-solo-su-dev) + (migrazione `2026_10_21_000010`). **Per portarli in produzione**: backup del database di produzione, merge di `dev` in `main` (dopo il rilascio di sicurezza del 21/10 non è più un fast-forward), deploy (l'autodeploy non parte: vedi [Dove gira davvero](#dove-gira-davvero)), poi controllo di migrazioni e -conteggi. In produzione non ci sono gruppi: le quattro migrazioni non hanno -dati da convertire. +conteggi. In produzione non ci sono gruppi né richieste: le migrazioni non +hanno dati da convertire. | Aspetto | Stato | |-----------------------------|-------| @@ -63,7 +67,58 @@ sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo). ## Lavori recenti — ottobre 2026 Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del -20 ottobre e i gruppi di servizio solo sulla demo. +20-21 ottobre e i gruppi di servizio solo sulla demo. + +### Richieste dal modulo pubblico, cartacei, S-13 per email (21 ottobre 2026, solo su dev) + +Dal documento `_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx` +(attività per la versione 2): analizzato con l'utente, che ha deciso così. + +- **Modulo pubblico** `/richiesta` (rotta `richiesta`, fuori dal login, + `throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest): + nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota. Non + mostra dati esistenti. Anti-spam: campo trappola `sito` (classe + `.tm-trappola`), tempo minimo `SECONDI_MINIMI` = 3 s (in questi due casi + risponde "inviata" senza salvare), `MASSIMO_ORA` = 5 per IP (chiave del + RateLimiter con lo sha1 dell'IP). Il tempo si legge con `now()`, così i + test possono viaggiare nel tempo. +- **Tabella `richieste`** (migrazione `2026_10_21_000010`, che crea anche il + permesso `richieste.gestisci` per amministratore e assistente e la colonna + `assegnazioni.cartaceo`): nome e note cifrati; nel registro delle attività + solo tipo, stato, numero (`richiesta_ricevuta` all'arrivo, poi i cambi di + stato). `RichiesteServizio`: `registra()`, `destinatari()`, `nomiSimili()`, + `assegnazioneAperta()`. +- **Email di avviso** (`Notifications\RichiestaRicevuta`) a chi ha + `richieste.gestisci` con l'email confermata, solo se la posta funziona + (`ConfigurazionePosta::funzionante()`). Non in coda: contiene il nome. +- **Conferma dalla Home** (riquadro *Richieste*, solo con il permesso): + scelta dell'utente, "così non si modifica troppo il codice del form + precompilato". Per un nuovo territorio la Home propone i proclamatori attivi + con un nome simile (punteggio per parola: uguale 3, iniziale 2, una lettera + di differenza 1; serve almeno 2) e ognuno apre *Assegna* con + `?proclamatore=&richiesta=&dalla_home=1`; per una riconsegna, *Rientra* con + `?richiesta=`. I due moduli leggono `richiesta` solo se aperta e se l'utente + ha il permesso (`#[Locked] richiestaId`), la mostrano sopra il form e, fatta + l'operazione, la segnano **evasa** (`Richiesta::evadi()`). Altrimenti + *Scarta*. La Home "messaggi" del documento sono solo queste richieste. +- **Pulizia**: `richieste:pulizia` ogni notte cancella le richieste gestite da + più di `PuliziaRichieste::GIORNI` = 90 giorni. Le richieste non sono + nell'XML né nel `.zip` (un ripristino non le tocca); l'azzeramento le + cancella (passo "Richieste" di `ResetApplication`, ora 12 passi). +- **Cartaceo sull'assegnazione** (scelta dell'utente: "va sull'assegnazione"): + spunta su *Assegna* e in "Il mio gruppo" (`GruppiServizio::assegna(..., + cartaceo:)`), etichetta "cartaceo" nell'elenco degli assegnati (solo dove è + vera: la riga resta sotto i 2,6 KB) e in "Il mio gruppo", pagina + **Cartacei** (`assegnazioni/cartacei`, `territori.assign`, voce di menu sotto + *Assegnazioni*) con i territori cartacei fuori. Passa per l'XML. +- **S-13 per email**: `registro:invia-s13 {--anno=}` manda + `Mail\ModuloS13Mail` (PDF allegato, non in coda) agli amministratori con + l'email confermata; nello scheduler `weeklyOn(0, '08:00')` con + `when(settimana dopo in un altro mese)` = **ultima domenica del mese**. Senza + posta non parte e lo dice. Nel registro `s13_inviato` con anno e quanti. +- Informativa privacy aggiornata (dati, conteggi, posta, conservazione; + revisione 21 ottobre 2026). Test in `tests/Feature/RichiesteTest.php` e + `GruppiServizioTest` (cartaceo dal sorvegliante, cartaceo nell'XML). ### Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026) diff --git a/README.md b/README.md index 30017c8..b01cedf 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ Applicazione web per la gestione dell'assegnazione e del rientro dei territori (cartoline) di congregazione, con statistiche di percorrenza, campagne speciali, gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di -territori) e registro storico completo. +territori), un modulo pubblico con cui i proclamatori chiedono un territorio o +ne annunciano la riconsegna, l'elenco delle cartoline cartacee fuori, il +modulo S-13 per email ogni mese e registro storico completo. I dati personali dei proclamatori sono **cifrati nel database**, e ogni operazione è tracciata in un **audit log** consultabile. @@ -50,7 +52,7 @@ eseguito né `composer install` né `npm build` all'avvio. |----------------|------------------------------------------------|---------| | `app` | nginx + php-fpm — l'applicazione | via Traefik | | `queue-worker` | Job in coda (import PDF massivi) | no | -| `scheduler` | Task pianificati (pulizia audit log) | no | +| `scheduler` | Task pianificati (pulizie, S-13 mensile) | no | | `mariadb` | Database | no | | `redis` | Cache, sessioni, code | no | @@ -630,12 +632,21 @@ docker exec -it php artisan audit:cleanup docker exec -it php artisan backup:cleanup docker exec -it php artisan backup:cleanup --ore=0 # tutti +# Pulizia manuale delle richieste dal modulo pubblico gestite da più di 90 giorni +docker exec -it php artisan richieste:pulizia + +# Mandare subito il modulo S-13 agli amministratori (anno in corso, o un altro) +docker exec -it php artisan registro:invia-s13 +docker exec -it php artisan registro:invia-s13 --anno=2025-2026 + # Svuotare le cache docker exec -it php artisan optimize:clear ``` -Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i -comandi qui sopra servono solo per anticiparle. +Le pulizie girano già da sole ogni notte e il modulo S-13 parte da solo +l'ultima domenica del mese alle 8 (container `scheduler`); i comandi qui sopra +servono solo per anticiparli. Senza un server di posta funzionante il modulo +S-13 non parte (il comando lo dice). --- @@ -696,6 +707,8 @@ docker compose --profile dev up -d --build l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con il territorio scelto e senza l'elenco dei territori assegnati +- **Richieste** dal modulo pubblico (amministratore e assistenti): vedi + [Richieste dal modulo pubblico](#richieste-dal-modulo-pubblico) ### Territori - CRUD completo con upload PDF e miniatura generata automaticamente @@ -745,6 +758,33 @@ docker compose --profile dev up -d --build compare un avviso di tempo di percorrenza molto alto - **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima) +- **Cartolina cartacea**: assegnando (anche il sorvegliante di gruppo) si può + spuntare *Ha chiesto la cartolina cartacea*. Il segno sta sull'assegnazione: + nell'elenco degli assegnati compare l'etichetta "cartaceo" e la pagina + **Cartacei** (menu, sotto *Assegnazioni*) elenca i territori cartacei fuori + e a chi, con i giorni e il pulsante Rientro. Al rientro escono dall'elenco + +### Richieste dal modulo pubblico +- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo + completo è scritto nella Home, da passare ai proclamatori): nome e cognome, + *nuovo territorio* o *riconsegna* (con il numero del territorio), una nota. + Non mostra nessun dato già presente +- **Contro lo spam**: un campo trappola nascosto, un tempo minimo fra apertura + e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5 + richieste all'ora dallo stesso indirizzo +- **Avviso per email** ad amministratori e assistenti (permesso + `richieste.gestisci`) con l'email confermata, se il server di posta + funziona; la richiesta resta comunque nella Home +- **Nella Home**, riquadro *Richieste*: per un nuovo territorio i proclamatori + attivi con un nome simile a quello scritto (uguale, iniziale, una lettera + sbagliata, in qualsiasi ordine e senza accenti), ognuno porta al modulo + *Assegna* già con quel proclamatore; *Nessuno di questi* apre il modulo + vuoto. Per una riconsegna, a chi risulta assegnato il territorio e *Registra + rientro →*. Fatta l'assegnazione o il rientro, la richiesta risulta evasa; + altrimenti si può **scartare** +- Nome e nota sono cifrati nel database e non vanno nel registro delle + attività; le richieste gestite si cancellano da sole dopo 90 giorni + (`richieste:pulizia`, ogni notte) ### Gruppi di servizio - Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome, @@ -815,6 +855,10 @@ docker compose --profile dev up -d --build ### Registro assegnazioni - Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale - Colonne: territorio, proclamatore, date, giorni, campagna +- **Modulo S-13 per email**: l'ultima domenica di ogni mese alle 8 il modulo + S-13 dell'anno di servizio in corso parte in PDF agli amministratori, + all'email della loro utenza se confermata (`registro:invia-s13`, anche a + mano). Non passa dalla coda: il PDF contiene i nomi dei proclamatori ### Audit log - Tracciamento automatico su tutti i modelli @@ -855,6 +899,7 @@ Quattro ruoli con permessi granulari (spatie/laravel-permission): | `territori.return` | ✅ | ✅ | ✅ | | | `audit.view` | ✅ | | | | | `audit.export` | ✅ | | | | +| `richieste.gestisci` | ✅ | ✅ | | | | `gruppo.gestisci` | | | | ✅ | L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`). @@ -881,7 +926,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina. - **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle richieste Livewire - **CSRF**: token su tutti i form -- **Rate limiting**: sul login +- **Rate limiting**: sul login e sul modulo pubblico delle richieste (5 + all'ora per indirizzo, più campo trappola e tempo minimo contro lo spam) - **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL` - **Scheme forwarding**: lo schema è deciso dal server, non dall'header del client, per non falsare la validazione dei link firmati @@ -914,6 +960,14 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina. - **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome di un proclamatore) +- **Richieste dal modulo pubblico**: nome e nota cifrati come i dati dei + proclamatori, fuori dal registro delle attività e dalla coda (l'email di + avviso parte subito); cancellate 90 giorni dopo essere state gestite. Non + sono nei backup (né XML né `.zip`, che contiene lo stesso XML): sono + messaggi in arrivo, non dati gestionali, e un ripristino non le tocca. + L'azzeramento (*Zona pericolosa*) le cancella +- **Email**: il modulo S-13 (con i nomi) va solo agli amministratori con + l'email confermata, l'avviso delle richieste solo a chi le gestisce - **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi in un luogo protetto e cancellati quando non servono @@ -933,7 +987,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina. | `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo | | `anni_teocratici` | Periodi settembre → agosto | | `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 ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento | +| `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; `cartaceo` se il proclamatore ha chiesto la cartolina cartacea | +| `richieste` | Richieste dal modulo pubblico: tipo (`nuovo`/`riconsegna`), nome e nota cifrati, numero del territorio, stato (`aperta`/`evasa`/`scartata`), l'assegnazione che l'ha evasa, chi e quando l'ha gestita | | `activity_log` | Audit trail | **Vincoli e indici principali** diff --git a/app/Console/Commands/InviaModuloS13.php b/app/Console/Commands/InviaModuloS13.php new file mode 100644 index 0000000..e1cb999 --- /dev/null +++ b/app/Console/Commands/InviaModuloS13.php @@ -0,0 +1,75 @@ +funzionante()) { + $this->warn('Nessun server di posta funzionante: modulo S-13 non inviato.'); + + return self::SUCCESS; + } + + $anno = $this->option('anno') + ? AnnoTeocratico::where('label', $this->option('anno'))->first() + : AnnoTeocratico::corrente(); + if (! $anno) { + $this->error("Anno di servizio {$this->option('anno')} non trovato."); + + return self::FAILURE; + } + + $destinatari = User::role('amministratore')->get() + ->filter(fn (User $u) => $u->email && $u->email_verified_at !== null) + ->values(); + if ($destinatari->isEmpty()) { + $this->warn('Nessun amministratore con un\'email confermata: modulo S-13 non inviato.'); + + return self::SUCCESS; + } + + $pdf = $modulo->genera($anno); + + // Uno per uno: un indirizzo rifiutato non ferma gli altri. + $inviati = 0; + foreach ($destinatari as $utente) { + try { + Mail::to($utente->email)->send(new ModuloS13Mail($utente, $anno, $pdf)); + $inviati++; + } catch (\Throwable $e) { + report($e); + $this->error("Invio a {$utente->email} non riuscito: {$e->getMessage()}"); + } + } + + // Nel registro quanti, non chi: gli indirizzi stanno nelle utenze. + activity()->withProperties(['anno' => $anno->label, 'destinatari' => $inviati])->log('s13_inviato'); + $this->info("Modulo S-13 {$anno->label} inviato a {$inviati} amministratori su {$destinatari->count()}."); + + return $inviati === $destinatari->count() ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Console/Commands/PuliziaRichieste.php b/app/Console/Commands/PuliziaRichieste.php new file mode 100644 index 0000000..dffc1d7 --- /dev/null +++ b/app/Console/Commands/PuliziaRichieste.php @@ -0,0 +1,34 @@ +where('stato', '!=', Richiesta::APERTA) + ->where('gestita_at', '<', now()->subDays(self::GIORNI)) + ->delete(); + + $this->info("Richieste cancellate: {$cancellate}"); + + return self::SUCCESS; + } +} diff --git a/app/Jobs/ResetApplication.php b/app/Jobs/ResetApplication.php index 9698758..46d90f3 100644 --- a/app/Jobs/ResetApplication.php +++ b/app/Jobs/ResetApplication.php @@ -7,6 +7,7 @@ use App\Models\Assegnazione; use App\Models\Campagna; use App\Models\GruppoServizio; use App\Models\Proclamatore; +use App\Models\Richiesta; use App\Models\Setting; use App\Models\Territorio; use App\Models\TerritorioMappa; @@ -76,10 +77,12 @@ class ResetApplication implements ShouldQueue $stato->avanza($this->operationId, 1); $stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.'); - $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 11); + $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 12); DB::transaction(function () use ($stato) { $passi = [ + // Le richieste dal modulo pubblico, prima delle assegnazioni che evadono. + 'Richieste' => fn () => Richiesta::query()->delete(), 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), 'Campagne' => fn () => Campagna::query()->delete(), // Prima dei territori, e per esteso: le piantine hanno la diff --git a/app/Livewire/Assegnazioni/Assegna.php b/app/Livewire/Assegnazioni/Assegna.php index 1e767ba..39eef93 100644 --- a/app/Livewire/Assegnazioni/Assegna.php +++ b/app/Livewire/Assegnazioni/Assegna.php @@ -9,7 +9,9 @@ use App\Models\Territorio; use App\Models\Proclamatore; use App\Models\Assegnazione; use App\Models\AnnoTeocratico; +use App\Models\Richiesta; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\Locked; class Assegna extends Component { @@ -20,6 +22,17 @@ class Assegna extends Component public ?int $proclamatore_id = null; public string $assigned_at = ''; + /** Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */ + public bool $cartaceo = false; + + /** + * La richiesta dal modulo pubblico da cui si è partiti (?richiesta=, + * dalla Home): si mostra sopra il modulo e, fatta l'assegnazione, risulta + * evasa. Locked: la imposta solo mount(). + */ + #[Locked] + public ?int $richiestaId = null; + // Optional pre-selection from parent context public ?int $preselectedTerritorioId = null; @@ -37,6 +50,17 @@ class Assegna extends Component $this->assigned_at = now()->format('Y-m-d'); $this->elencoNascosto = request()->boolean('dalla_home'); + // Dalla Home, da una richiesta: il proclamatore scelto fra i nomi + // simili arriva già impostato (?proclamatore=), il resto del modulo è + // quello di sempre. + if (($id = request()->integer('proclamatore')) && Proclamatore::attivi()->whereKey($id)->exists()) { + $this->proclamatore_id = $id; + } + if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci') + && Richiesta::aperte()->whereKey($id)->exists()) { + $this->richiestaId = $id; + } + // ?invia=: riprende l'invio di un link rimasto in sospeso (dalla // scheda del territorio). if ($invia = request()->integer('invia')) { @@ -109,6 +133,7 @@ class Assegna extends Component 'created_by' => auth()->id(), // C'è un link da mandare solo se il territorio ha il PDF. 'link_da_inviare' => (bool) $territorio->pdf_path, + 'cartaceo' => $this->cartaceo, ]); }); @@ -131,6 +156,13 @@ class Assegna extends Component ]) ->log('assigned'); + // Partita da una richiesta del modulo pubblico: ora è evasa. + if ($this->richiestaId) { + Richiesta::find($this->richiestaId)?->evadi($assegnazione, auth()->user()); + $this->richiestaId = null; + } + $this->cartaceo = false; + // Non si va avanti: si apre l'invio del link (vedi in cima). $this->apriInvio($assegnazione->id); } @@ -177,6 +209,7 @@ class Assegna extends Component 'opzioniTerritori' => $opzioniTerritori, 'opzioniProclamatori' => $opzioniProclamatori, 'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null, + 'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null, ]); } } diff --git a/app/Livewire/Assegnazioni/Cartacei.php b/app/Livewire/Assegnazioni/Cartacei.php new file mode 100644 index 0000000..1b91eaf --- /dev/null +++ b/app/Livewire/Assegnazioni/Cartacei.php @@ -0,0 +1,25 @@ + Assegnazione::aperte() + ->where('cartaceo', true) + ->with(['territorio.zona', 'proclamatore']) + ->orderBy('assigned_at') + ->get(), + ]); + } +} diff --git a/app/Livewire/Assegnazioni/Rientra.php b/app/Livewire/Assegnazioni/Rientra.php index 69e2a44..137c50d 100644 --- a/app/Livewire/Assegnazioni/Rientra.php +++ b/app/Livewire/Assegnazioni/Rientra.php @@ -7,6 +7,7 @@ use App\Livewire\Concerns\RicordaRitorno; use Livewire\Component; use App\Models\Assegnazione; use App\Models\Campagna; +use App\Models\Richiesta; class Rientra extends Component { @@ -25,6 +26,13 @@ class Rientra extends Component public bool $counted_in_campaign = false; + /** + * La richiesta di riconsegna dal modulo pubblico da cui si è partiti + * (?richiesta=, dalla Home): registrato il rientro, risulta evasa. + */ + #[Locked] + public ?int $richiestaId = null; + public function mount(Assegnazione $assegnazione) { $this->autorizza($assegnazione); @@ -34,6 +42,11 @@ class Rientra extends Component abort(404, 'Assegnazione già rientrata.'); } + if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci') + && Richiesta::aperte()->whereKey($id)->exists()) { + $this->richiestaId = $id; + } + $this->returned_at = now()->format('Y-m-d'); $this->checkCampaign(); } @@ -132,6 +145,11 @@ class Rientra extends Component ); } + // Partito da una richiesta di riconsegna (Home): ora è evasa. + if ($this->richiestaId) { + Richiesta::find($this->richiestaId)?->evadi($this->assegnazione, auth()->user()); + } + session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni."); return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true); } @@ -164,6 +182,7 @@ class Rientra extends Component 'campagna' => $campagna, 'giorniPercorrenza' => $giorniPercorrenza, 'sogliaPercorrenza' => $sogliaPercorrenza, + 'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null, ]); } } diff --git a/app/Livewire/Gruppi/MioGruppo.php b/app/Livewire/Gruppi/MioGruppo.php index 86593bb..96344f0 100644 --- a/app/Livewire/Gruppi/MioGruppo.php +++ b/app/Livewire/Gruppi/MioGruppo.php @@ -36,6 +36,9 @@ class MioGruppo extends Component public string $dataAssegnazione = ''; + /** Il membro ha chiesto la cartolina cartacea (elenco Cartacei). */ + public bool $cartaceo = false; + // Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano. #[Url(except: '')] public string $filtroZona = ''; @@ -84,13 +87,14 @@ class MioGruppo extends Component $this->territorioDelPacchetto($territorioId); $this->territorioDaAssegnare = $territorioId; $this->membroId = null; + $this->cartaceo = false; $this->dataAssegnazione = now()->format('Y-m-d'); $this->resetValidation(); } public function annullaAssegnazione(): void { - $this->reset(['territorioDaAssegnare', 'membroId']); + $this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']); $this->resetValidation(); } @@ -115,14 +119,14 @@ class MioGruppo extends Component } try { - $assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione)); + $assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione), $this->cartaceo); } catch (InvalidArgumentException $e) { $this->addError('membroId', $e->getMessage()); return; } - $this->reset(['territorioDaAssegnare', 'membroId']); + $this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']); // Come dalla pagina Assegna: si apre l'invio del link. $this->apriInvio($assegnazione->id); diff --git a/app/Livewire/Home.php b/app/Livewire/Home.php index 9b2645b..a02da6a 100644 --- a/app/Livewire/Home.php +++ b/app/Livewire/Home.php @@ -3,16 +3,58 @@ namespace App\Livewire; use Livewire\Component; +use App\Livewire\Concerns\Avvisa; use App\Models\Territorio; use App\Models\Proclamatore; use App\Models\Assegnazione; use App\Models\AnnoTeocratico; use App\Models\Campagna; +use App\Models\Richiesta; use App\Models\Setting; +use App\Services\RichiesteServizio; use Barryvdh\DomPDF\Facade\Pdf; class Home extends Component { + use Avvisa; + + /** + * Le richieste dal modulo pubblico si confermano portandole su Assegna o + * Rientra (link nel riquadro); qui si possono solo scartare. + */ + public function scartaRichiesta(int $id): void + { + $this->authorize('richieste.gestisci'); + Richiesta::findOrFail($id)->scarta(auth()->user()); + $this->avvisa('Richiesta scartata.'); + } + + /** + * Le richieste aperte, con quello che serve a confermarle: per un + * territorio nuovo i proclamatori dal nome simile a quello scritto, per + * una riconsegna l'assegnazione aperta di quel territorio. + */ + private function richiesteAperte(): \Illuminate\Support\Collection + { + if (! auth()->user()->can('richieste.gestisci')) { + return collect(); + } + + $aperte = Richiesta::aperte()->orderBy('created_at')->get(); + if ($aperte->isEmpty()) { + return collect(); + } + + $servizio = app(RichiesteServizio::class); + $proclamatori = Proclamatore::attivi()->get(); + + return $aperte->map(fn (Richiesta $r) => [ + 'richiesta' => $r, + 'simili' => $r->tipo === Richiesta::NUOVO ? $servizio->nomiSimili($r->nome, $proclamatori) : collect(), + 'assegnazione' => $r->tipo === Richiesta::RICONSEGNA ? $servizio->assegnazioneAperta($r->numero_territorio) : null, + ]); + } + public function downloadPdfDaAssegnare() { $this->authorize('territori.assign'); @@ -147,6 +189,7 @@ class Home extends Component 'homeLimit' => $homeLimit, 'territoriDaAssegnare' => $territoriDaAssegnare, 'daRientrare' => $daRientrare, + 'richieste' => $this->richiesteAperte(), ]); } } diff --git a/app/Livewire/Privacy.php b/app/Livewire/Privacy.php index d80a545..b9a95e3 100644 --- a/app/Livewire/Privacy.php +++ b/app/Livewire/Privacy.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Models\Assegnazione; use App\Models\Proclamatore; +use App\Models\Richiesta; use App\Models\Setting; use App\Models\User; use App\Services\ConfigurazionePosta; @@ -23,7 +24,7 @@ use Spatie\Permission\Models\Role; class Privacy extends Component { /** Data dell'ultima revisione del testo dell'informativa. */ - public const REVISIONE = '19 ottobre 2026'; + public const REVISIONE = '21 ottobre 2026'; /** I permessi spiegati a chi legge l'informativa. */ public const PERMESSI = [ @@ -36,6 +37,7 @@ class Privacy extends Component 'registro.export' => 'esportazione del registro', 'territori.assign' => 'assegnazione dei territori', 'territori.return' => 'rientro dei territori', + 'richieste.gestisci' => 'richieste arrivate dal modulo pubblico (nuovo territorio o riconsegna)', 'audit.view' => 'consultazione del registro delle attività', 'audit.export' => 'esportazione del registro delle attività', ]; @@ -78,6 +80,8 @@ class Privacy extends Component 'utenti' => User::count(), 'assegnazioni' => Assegnazione::count(), 'assegnazioniAperte' => Assegnazione::aperte()->count(), + 'richieste' => Richiesta::count(), + 'richiesteAperte' => Richiesta::aperte()->count(), 'attivita' => Activity::count(), 'attivitaDal' => Activity::min('created_at'), ], diff --git a/app/Livewire/Richieste/ModuloRichiesta.php b/app/Livewire/Richieste/ModuloRichiesta.php new file mode 100644 index 0000000..4313297 --- /dev/null +++ b/app/Livewire/Richieste/ModuloRichiesta.php @@ -0,0 +1,97 @@ +apertoAlle = now()->timestamp; + } + + public function invia(RichiesteServizio $richieste): void + { + if ($this->sito !== '' || now()->timestamp - $this->apertoAlle < self::SECONDI_MINIMI) { + $this->inviata = true; + + return; + } + + $chiave = 'richiesta-pubblica:' . sha1((string) request()->ip()); + if (RateLimiter::tooManyAttempts($chiave, self::MASSIMO_ORA)) { + $this->addError('nome', "Hai già mandato diverse richieste: riprova fra un po'."); + + return; + } + + $this->validate([ + 'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA, + 'nome' => 'required|string|min:3|max:100', + 'numero' => 'nullable|required_if:tipo,' . Richiesta::RICONSEGNA . '|string|max:20', + 'note' => 'nullable|string|max:1000', + ], [ + 'nome.required' => 'Scrivi nome e cognome.', + 'nome.min' => 'Scrivi nome e cognome.', + 'numero.required_if' => 'Scrivi il numero del territorio che riconsegni.', + ]); + + RateLimiter::hit($chiave, 3600); + $richieste->registra($this->tipo, $this->nome, $this->numero, $this->note); + + $this->reset(['nome', 'numero', 'note']); + $this->inviata = true; + } + + public function nuova(): void + { + $this->reset(['tipo', 'nome', 'numero', 'note', 'inviata']); + $this->apertoAlle = now()->timestamp; + } + + public function render() + { + return view('livewire.richieste.modulo-richiesta', [ + 'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2', + ])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']); + } +} diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index 94c69f3..af70129 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -817,6 +817,7 @@ class XmlExchange extends Component 'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null, 'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1, 'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1, + 'cartaceo' => ((int) ($assegnazioneNode->cartaceo ?? 0)) === 1, '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, @@ -887,7 +888,7 @@ class XmlExchange extends Component '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']), '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', 'gruppo_nome', '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', 'cartaceo', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']), ]; } @@ -1388,6 +1389,7 @@ class XmlExchange extends Component $node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 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('cartaceo', (string) ((int) ($assegnazione['cartaceo'] ?? 0))); $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'] ?? '')); diff --git a/app/Mail/ModuloS13Mail.php b/app/Mail/ModuloS13Mail.php new file mode 100644 index 0000000..fcbe387 --- /dev/null +++ b/app/Mail/ModuloS13Mail.php @@ -0,0 +1,50 @@ +anno->label}.pdf"; + } + + public function envelope(): Envelope + { + return new Envelope(subject: "Modulo S-13 — anno di servizio {$this->anno->label}"); + } + + public function content(): Content + { + return new Content( + text: 'mail.modulo-s13', + with: ['data' => now()->format('d/m/Y')], + ); + } + + /** @return array */ + public function attachments(): array + { + return [ + Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'), + ]; + } +} diff --git a/app/Models/Assegnazione.php b/app/Models/Assegnazione.php index a419562..bae3db3 100644 --- a/app/Models/Assegnazione.php +++ b/app/Models/Assegnazione.php @@ -22,6 +22,7 @@ class Assegnazione extends Model 'link_sent', 'link_da_inviare', 'link_canale', + 'cartaceo', 'note', 'created_by', 'returned_by', @@ -43,6 +44,8 @@ class Assegnazione extends Model 'counted_in_campaign' => 'boolean', 'link_sent' => 'boolean', 'link_da_inviare' => 'boolean', + // Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). + 'cartaceo' => 'boolean', 'gruppo_servizio_id' => 'integer', ]; } diff --git a/app/Models/Richiesta.php b/app/Models/Richiesta.php new file mode 100644 index 0000000..e6aad6d --- /dev/null +++ b/app/Models/Richiesta.php @@ -0,0 +1,112 @@ + self::APERTA]; + + // L'arrivo lo registra RichiesteServizio (con il numero del territorio): + // qui solo i cambi di stato, con chi li ha fatti. + protected static $recordEvents = ['updated']; + + protected $fillable = ['tipo', 'nome', 'numero_territorio', 'note', 'stato', 'assegnazione_id', 'gestita_da', 'gestita_at']; + + protected function casts(): array + { + return [ + 'nome' => 'encrypted', + 'note' => 'encrypted', + 'gestita_at' => 'datetime', + 'assegnazione_id' => 'integer', + ]; + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['tipo', 'stato', 'assegnazione_id']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function assegnazione() + { + return $this->belongsTo(Assegnazione::class); + } + + public function gestitaDa() + { + return $this->belongsTo(User::class, 'gestita_da'); + } + + public function scopeAperte($query) + { + return $query->where('stato', self::APERTA); + } + + public function isAperta(): bool + { + return $this->stato === self::APERTA; + } + + public function getTipoEtichettaAttribute(): string + { + return $this->tipo === self::RICONSEGNA ? 'Riconsegna' : 'Nuovo territorio'; + } + + /** Chiusa da un'assegnazione o da un rientro fatti a partire da lei. */ + public function evadi(?Assegnazione $assegnazione, ?User $chi): void + { + if (! $this->isAperta()) { + return; + } + + $this->update([ + 'stato' => self::EVASA, + 'assegnazione_id' => $assegnazione?->id, + 'gestita_da' => $chi?->id, + 'gestita_at' => now(), + ]); + } + + public function scarta(?User $chi): void + { + if (! $this->isAperta()) { + return; + } + + $this->update([ + 'stato' => self::SCARTATA, + 'gestita_da' => $chi?->id, + 'gestita_at' => now(), + ]); + } +} diff --git a/app/Notifications/RichiestaRicevuta.php b/app/Notifications/RichiestaRicevuta.php new file mode 100644 index 0000000..187f399 --- /dev/null +++ b/app/Notifications/RichiestaRicevuta.php @@ -0,0 +1,42 @@ +richiesta; + $messaggio = (new MailMessage) + ->subject("Nuova richiesta: {$r->tipo_etichetta} — TerManager2") + ->greeting('Ciao ' . $notifiable->name . ',') + ->line($r->tipo === Richiesta::RICONSEGNA + ? "{$r->nome} annuncia la riconsegna del territorio N° {$r->numero_territorio}." + : "{$r->nome} chiede un territorio nuovo."); + + if ($r->note) { + $messaggio->line('Note: ' . $r->note); + } + + return $messaggio + ->action('Apri la Home di TerManager2', route('dashboard')) + ->line('La richiesta si conferma dalla Home, nel riquadro "Richieste".') + ->salutation('TerManager2'); + } +} diff --git a/app/Services/GruppiServizio.php b/app/Services/GruppiServizio.php index 9e0b332..a18eaf9 100644 --- a/app/Services/GruppiServizio.php +++ b/app/Services/GruppiServizio.php @@ -215,7 +215,7 @@ class GruppiServizio * nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il * territorio resta nel pacchetto finché non rientra. */ - public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione + public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data, bool $cartaceo = false): Assegnazione { $this->attivo($gruppo); @@ -238,7 +238,7 @@ class GruppiServizio throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.'); } - $esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) { + $esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data, $cartaceo) { $bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first(); if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) { return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo."; @@ -259,6 +259,7 @@ class GruppiServizio // Il nome resta anche quando il gruppo, sciolto, non c'è più. 'gruppo_nome' => $gruppo->nome, 'link_da_inviare' => (bool) $territorio->pdf_path, + 'cartaceo' => $cartaceo, ]); }); diff --git a/app/Services/RichiesteServizio.php b/app/Services/RichiesteServizio.php new file mode 100644 index 0000000..1a79aee --- /dev/null +++ b/app/Services/RichiesteServizio.php @@ -0,0 +1,154 @@ + $tipo === Richiesta::RICONSEGNA ? Richiesta::RICONSEGNA : Richiesta::NUOVO, + 'nome' => trim($nome), + 'numero_territorio' => $tipo === Richiesta::RICONSEGNA ? trim((string) $numero) : null, + 'note' => trim((string) $note) !== '' ? trim((string) $note) : null, + ]); + + // Nel registro tipo e numero, mai il nome. + activity()->performedOn($richiesta) + ->withProperties(array_filter(['tipo' => $richiesta->tipo, 'territorio' => $richiesta->numero_territorio])) + ->log('richiesta_ricevuta'); + + $this->avvisa($richiesta); + + return $richiesta; + } + + /** + * Chi gestisce le richieste e ha un'email vera: amministratori e + * assistenti (permesso richieste.gestisci) con l'indirizzo confermato. + */ + public function destinatari(): Collection + { + return User::permission('richieste.gestisci')->get() + ->filter(fn (User $u) => $u->email && $u->email_verified_at !== null) + ->values(); + } + + /** + * L'email parte subito e non in coda: contiene il nome scritto nella + * richiesta, che nel database è cifrato e in coda starebbe in chiaro. + * Senza un server di posta funzionante non si manda niente: la richiesta + * resta comunque nella Home. + */ + private function avvisa(Richiesta $richiesta): void + { + if (! $this->posta->funzionante()) { + return; + } + + $destinatari = $this->destinatari(); + if ($destinatari->isEmpty()) { + return; + } + + try { + Notification::send($destinatari, new RichiestaRicevuta($richiesta)); + } catch (\Throwable $e) { + report($e); + } + } + + /** + * I proclamatori attivi con un nome riconducibile a quello scritto nella + * richiesta, dal più simile: uguale, iniziale, una lettera sbagliata. + * I nomi sono cifrati, quindi il confronto si fa qui, non in SQL. + * + * @return Collection + */ + public function nomiSimili(string $nome, ?Collection $proclamatori = null, int $quanti = 5): Collection + { + $cercate = self::parole($nome); + if ($cercate === []) { + return collect(); + } + + return ($proclamatori ?? Proclamatore::attivi()->get()) + ->map(function (Proclamatore $p) use ($cercate) { + $sue = self::parole($p->cognome . ' ' . $p->nome); + $punti = 0; + foreach ($cercate as $parola) { + $migliore = 0; + foreach ($sue as $sua) { + $migliore = max($migliore, self::somiglianza($parola, $sua)); + } + $punti += $migliore; + } + + return [$p, $punti]; + }) + ->filter(fn ($coppia) => $coppia[1] >= 2) + ->sortBy([[fn ($coppia) => $coppia[1], 'desc'], [fn ($coppia) => mb_strtolower($coppia[0]->nome_completo), 'asc']]) + ->take($quanti) + ->map(fn ($coppia) => $coppia[0]) + ->values(); + } + + /** L'assegnazione aperta del territorio indicato in una riconsegna, se c'è. */ + public function assegnazioneAperta(?string $numero): ?Assegnazione + { + $numero = trim((string) $numero); + if ($numero === '') { + return null; + } + + $territorio = Territorio::query() + ->whereRaw('LOWER(numero) = ?', [Str::lower($numero)]) + ->with('assegnazioneCorrente.proclamatore') + ->first(); + + return $territorio?->assegnazioneCorrente; + } + + /** @return string[] parole minuscole, senza accenti né segni */ + private static function parole(string $testo): array + { + $pulito = preg_replace('/[^a-z0-9 ]+/', ' ', Str::lower(Str::ascii($testo))); + + return array_values(array_filter(explode(' ', (string) $pulito), fn ($p) => strlen($p) >= 2)); + } + + /** 3 uguale, 2 una inizia con l'altra, 1 una lettera di differenza. */ + private static function somiglianza(string $a, string $b): int + { + if ($a === $b) { + return 3; + } + if (min(strlen($a), strlen($b)) >= 3 && (str_starts_with($a, $b) || str_starts_with($b, $a))) { + return 2; + } + if (min(strlen($a), strlen($b)) >= 4 && levenshtein($a, $b) <= 1) { + return 1; + } + + return 0; + } +} diff --git a/database/migrations/2026_10_21_000010_richieste_e_cartacei.php b/database/migrations/2026_10_21_000010_richieste_e_cartacei.php new file mode 100644 index 0000000..c4223ac --- /dev/null +++ b/database/migrations/2026_10_21_000010_richieste_e_cartacei.php @@ -0,0 +1,66 @@ +id(); + // 'nuovo' (richiesta di un territorio) | 'riconsegna' + $table->string('tipo', 20); + // Nome e cognome come li ha scritti chi compila: cifrati, come i + // proclamatori (cast `encrypted` nel modello). + $table->text('nome'); + $table->string('numero_territorio', 20)->nullable(); + $table->text('note')->nullable(); + // 'aperta' | 'evasa' | 'scartata' + $table->string('stato', 20)->default('aperta')->index(); + $table->foreignId('assegnazione_id')->nullable()->constrained('assegnazioni')->nullOnDelete(); + $table->foreignId('gestita_da')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('gestita_at')->nullable(); + $table->timestamps(); + }); + + Schema::table('assegnazioni', function (Blueprint $table) { + $table->boolean('cartaceo')->default(false)->after('link_canale'); + }); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $permesso = Permission::firstOrCreate(['name' => 'richieste.gestisci', 'guard_name' => 'web']); + foreach (['amministratore', 'assistente'] as $ruolo) { + Role::where('name', $ruolo)->where('guard_name', 'web')->first()?->givePermissionTo($permesso); + } + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::where('name', 'richieste.gestisci')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + Schema::table('assegnazioni', function (Blueprint $table) { + $table->dropColumn('cartaceo'); + }); + + Schema::dropIfExists('richieste'); + } +}; diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index 5a81e5e..a06647c 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -26,6 +26,7 @@ class RolesAndPermissionsSeeder extends Seeder 'audit.view', 'audit.export', 'gruppo.gestisci', + 'richieste.gestisci', ]; foreach ($permissions as $permission) { @@ -56,6 +57,8 @@ class RolesAndPermissionsSeeder extends Seeder 'territori.assign', 'territori.return', 'registro.view', + // Le richieste dal modulo pubblico, confermate dalla Home. + 'richieste.gestisci', ]); // Operatore: assign and return only diff --git a/resources/css/app.css b/resources/css/app.css index b7a0552..187b0ff 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1805,6 +1805,12 @@ html { scroll-behavior: smooth; } } .tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; } .tm-assegnato-numero:hover { text-decoration: underline; } +/* Campo trappola del modulo pubblico delle richieste: fuori dallo schermo, + non `display:none` (alcuni programmi saltano i campi nascosti così). */ +.tm-trappola { + position: absolute; left: -10000px; top: auto; + width: 1px; height: 1px; overflow: hidden; +} /* Il numero come pulsante ("Il mio gruppo": apre l'anteprima): stesso aspetto. */ button.tm-assegnato-numero { background: none; border: 0; padding: 0; cursor: pointer; @@ -1817,6 +1823,11 @@ button.tm-assegnato-numero:focus-visible { margin-left: 6px; padding: 2px 6px; border-radius: 4px; background: #f3f4f6; color: #9ca3af; font-size: 12px; } +/* Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */ +.tm-cartaceo { + margin-left: 4px; padding: 2px 6px; border-radius: 4px; + background: #fffbeb; color: #b45309; font-size: 12px; font-weight: 600; +} .tm-assegnato-info { color: #6b7280; font-size: 12px; } .tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; } .tm-assegnato-giorni { diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 4cd311d..e02cb4a 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -120,6 +120,11 @@ Assegnazioni + + + Cartacei + @endcan @can('campagne.manage') diff --git a/resources/views/livewire/assegnazioni/assegna.blade.php b/resources/views/livewire/assegnazioni/assegna.blade.php index a0a7563..4646870 100644 --- a/resources/views/livewire/assegnazioni/assegna.blade.php +++ b/resources/views/livewire/assegnazioni/assegna.blade.php @@ -26,6 +26,13 @@ {{-- Form --}}
+ @if($richiesta) + {{-- Partita da una richiesta del modulo pubblico (Home): assegnando, risulta evasa. --}} +
+ Richiesta di {{ $richiesta->nome }} del {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif. + Assegnando il territorio, la richiesta risulta evasa. +
+ @endif
{{-- L'anteprima segue la scelta nel browser (evento "scelta" di , miniatura nelle opzioni): scegliere un @@ -65,6 +72,12 @@ @error('assigned_at')

{{ $message }}

@enderror
+ +
Assegna @if($ritorno) diff --git a/resources/views/livewire/assegnazioni/cartacei.blade.php b/resources/views/livewire/assegnazioni/cartacei.blade.php new file mode 100644 index 0000000..2dad515 --- /dev/null +++ b/resources/views/livewire/assegnazioni/cartacei.blade.php @@ -0,0 +1,47 @@ +
+
+

Territori cartacei

+

+ Le cartoline cartacee fuori e a chi: territori assegnati per cui il proclamatore ha chiesto il cartaceo. + Al rientro escono dall'elenco. +

+
+ +
+ @if($assegnazioni->isEmpty()) +

Nessun territorio cartaceo fuori.

+ @else +
+ + + + + + + + + + + + @foreach($assegnazioni as $a) + + + + + + + + @endforeach + +
TerritorioProclamatoreDalGiorni
+ N° {{ $a->territorio?->numero }} + {{ $a->territorio?->zona?->nome }} + {{ $a->proclamatore?->nome_completo }}{{ $a->assigned_at->format('d/m/Y') }}{{ $a->giorni }} + @can('territori.return') + Rientro + @endcan +
+
+ @endif +
+
diff --git a/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php b/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php index 7a3dde4..edbab40 100644 --- a/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php +++ b/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php @@ -45,7 +45,7 @@ (data-ritorno); indirizzo relativo e niente a capo: pesa meno. --}}
- N° {{ $a->territorio?->numero }} @if($a->territorio?->zona?->nome){{ $a->territorio->zona->nome }}@endif + N° {{ $a->territorio?->numero }} @if($a->territorio?->zona?->nome){{ $a->territorio->zona->nome }}@endif @if($a->cartaceo)cartaceo@endif {{ $nome }} · {{ $a->assigned_at->format('d/m/Y') }} · {{ $a->giorni }}g
@if($a->link_in_sospeso) diff --git a/resources/views/livewire/assegnazioni/rientra.blade.php b/resources/views/livewire/assegnazioni/rientra.blade.php index e9b54d6..8c9fe13 100644 --- a/resources/views/livewire/assegnazioni/rientra.blade.php +++ b/resources/views/livewire/assegnazioni/rientra.blade.php @@ -7,6 +7,13 @@

Rientro Territorio

+ @if($richiesta) + {{-- Partito da una richiesta di riconsegna (Home): registrando il rientro, risulta evasa. --}} +
+ Riconsegna annunciata da {{ $richiesta->nome }} il {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif. +
+ @endif + {{-- Assignment summary --}}
diff --git a/resources/views/livewire/gruppi/mio-gruppo.blade.php b/resources/views/livewire/gruppi/mio-gruppo.blade.php index 9424b0f..e24f420 100644 --- a/resources/views/livewire/gruppi/mio-gruppo.blade.php +++ b/resources/views/livewire/gruppi/mio-gruppo.blade.php @@ -89,7 +89,7 @@ data-miniatura="{{ $t->miniaturaGruppoUrl() }}" data-pdf="{{ $t->pdf_path ? route('mio-gruppo.pdf', $t) : '' }}" x-on:click="anteprima = { titolo: $el.dataset.titolo, miniatura: $el.dataset.miniatura, pdf: $el.dataset.pdf }">N° {{ $t->numero }}@elseN° {{ $t->numero }}@endif @if($t->zona?->nome){{ $t->zona->nome }}@endif - @if($t->in_priorita)★ Prioritario@endif + @if($t->in_priorita)★ Prioritario@endif @if($a?->cartaceo)cartaceo@endif @if($a) {{ $a->proclamatore?->nome_completo }} · dal {{ $a->assigned_at->format('d/m/Y') }} · {{ $a->giorni }}g @else @@ -126,6 +126,10 @@ class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500"> @error('dataAssegnazione')

{{ $message }}

@enderror
+
Assegna Annulla diff --git a/resources/views/livewire/home.blade.php b/resources/views/livewire/home.blade.php index 7077ac5..e2ee51c 100644 --- a/resources/views/livewire/home.blade.php +++ b/resources/views/livewire/home.blade.php @@ -78,6 +78,61 @@ @endif
+ {{-- Richieste dal modulo pubblico: si confermano portandole sul modulo + di assegnazione o di rientro, che le chiude da solo. --}} + @can('richieste.gestisci') +
+
+
+

Richieste ({{ $richieste->count() }})

+

Dal modulo pubblico: {{ route('richiesta') }}

+
+
+ @forelse($richieste as $voce) + @php + $r = $voce['richiesta']; + $a = $voce['assegnazione']; + @endphp +
+
+

+ {{ $r->tipo_etichetta }} + {{ $r->nome }} + @if($r->numero_territorio) · territorio N° {{ $r->numero_territorio }} @endif +

+ {{ $r->created_at->format('d/m/Y H:i') }} +
+ @if($r->note) +

«{{ $r->note }}»

+ @endif + +
+ @if($r->tipo === \App\Models\Richiesta::NUOVO) + {{ $voce['simili']->isNotEmpty() ? 'Chi è? Assegna a:' : 'Nessun proclamatore con un nome simile.' }} + @foreach($voce['simili'] as $p) + {{ $p->nome_completo }} → + @endforeach + {{ $voce['simili']->isNotEmpty() ? 'Nessuno di questi: apri il modulo' : 'Apri il modulo' }} + @elseif($a) + Assegnato a {{ $a->proclamatore?->nome_completo }} dal {{ $a->assigned_at->format('d/m/Y') }} + Registra rientro → + @else + Il territorio N° {{ $r->numero_territorio }} non risulta assegnato. + @endif + Scarta +
+
+ @empty +

Nessuna richiesta aperta.

+ @endforelse +
+ @endcan + {{-- Quick lists --}}
{{-- Da assegnare --}} diff --git a/resources/views/livewire/privacy.blade.php b/resources/views/livewire/privacy.blade.php index 22177c2..037e409 100644 --- a/resources/views/livewire/privacy.blade.php +++ b/resources/views/livewire/privacy.blade.php @@ -18,7 +18,8 @@
  • Proclamatori: 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.
  • Territori e piantine: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.
  • -
  • Assegnazioni: 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.
  • +
  • Assegnazioni: 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) se il proclamatore ha chiesto la cartolina cartacea e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.
  • +
  • Richieste dal modulo pubblico: chi chiede un territorio o ne annuncia la riconsegna scrive il proprio nome, l'eventuale numero del territorio e una nota. Nome e nota sono cifrati nel database ({{ $cifratura }}) e non finiscono nel registro delle attività. Dell'indirizzo IP resta solo un'impronta temporanea, per limitare i tentativi.
  • Utenti dell'applicazione: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio entra con un'email fittizia creata dall'applicazione (iniziale del nome e cognome), che non riceve posta e non va confermata. La sua utenza è collegata al suo proclamatore e viene eliminata quando il gruppo si scioglie; anche il gruppo, sciolto, viene eliminato.
  • Registro delle attività: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.
@@ -29,6 +30,7 @@
  • {{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.
  • {{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.
  • +
  • {{ $conteggi['richieste'] }} richieste dal modulo pubblico, di cui {{ $conteggi['richiesteAperte'] }} da gestire.
  • {{ $conteggi['utenti'] }} utenti dell'applicazione.
  • {{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.
@@ -59,6 +61,7 @@ @endforeach

Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.

+

Il modulo pubblico di richiesta non mostra nessun dato: chi lo compila scrive soltanto. Le richieste le vedono e le gestiscono, dalla Home, solo i ruoli con il permesso sulle richieste.

@@ -67,6 +70,7 @@
  • Cifratura: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.
  • Accesso: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.
  • Permessi: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).
  • +
  • Modulo pubblico: protetto dallo spam con un campo trappola, un tempo minimo di compilazione e un limite di richieste per indirizzo.
  • Registro delle attività: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.
  • Comunicazioni: l'applicazione risponde all'indirizzo {{ $indirizzo }}@if($https), su connessione cifrata (HTTPS)@else. Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.@endif.
  • Hosting: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.
  • @@ -88,7 +92,7 @@
  • Condivisione del link: il messaggio con il nome del proclamatore e il link passa per l'app scelta da chi assegna il territorio (WhatsApp, Telegram, SMS…), dal suo telefono, non dall'applicazione.
  • Posta: @if($postaAttiva) - le email agli utenti dell'applicazione (conferma dell'indirizzo) partono dal server di posta {{ $hostPosta ?? 'configurato' }}. Ai proclamatori non si mandano email. + le email agli utenti dell'applicazione partono dal server di posta {{ $hostPosta ?? 'configurato' }}: conferma dell'indirizzo, avviso di una nuova richiesta dal modulo pubblico a chi le gestisce, e il modulo S-13 (registro delle assegnazioni, in PDF) agli amministratori l'ultima domenica del mese. Ai proclamatori non si mandano email. @else nessun server di posta configurato: l'applicazione non invia email. Ai proclamatori non se ne mandano comunque. @endif @@ -104,6 +108,7 @@
  • Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.
  • Il registro delle attività è conservato per {{ $auditRetention }} giorni (modificabile nelle impostazioni), poi cancellato automaticamente.
  • Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.
  • +
  • Le richieste dal modulo pubblico sono cancellate automaticamente {{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni dopo essere state gestite.
  • I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori in chiaro: vanno custoditi in un luogo protetto e cancellati quando non servono più.
  • diff --git a/resources/views/livewire/richieste/modulo-richiesta.blade.php b/resources/views/livewire/richieste/modulo-richiesta.blade.php new file mode 100644 index 0000000..0012cce --- /dev/null +++ b/resources/views/livewire/richieste/modulo-richiesta.blade.php @@ -0,0 +1,74 @@ +
    + {{-- Modulo pubblico (ModuloRichiesta): nessun dato già presente si vede + qui, solo i campi da compilare. --}} +
    +
    T2
    +

    Richiesta territorio

    +

    {{ $congregazione }}

    +
    + + @if($inviata) +
    +

    Richiesta inviata.

    +

    Il responsabile dei territori la vedrà e ti farà sapere.

    +
    +
    + Fai un'altra richiesta +
    + @else + +
    + Cosa ti serve? * +
    + + +
    +
    + +
    + + + @error('nome')

    {{ $message }}

    @enderror +
    + + @if($tipo === \App\Models\Richiesta::RICONSEGNA) +
    + + + @error('numero')

    {{ $message }}

    @enderror +
    + @endif + +
    + + + @error('note')

    {{ $message }}

    @enderror +
    + + {{-- Trappola per i programmi che riempiono ogni campo: nascosta a chi + guarda e ai lettori di schermo. --}} + + + Invia la richiesta + +

    + Nome e note servono solo a gestire questa richiesta: li vedono il responsabile dei territori e i suoi + assistenti, e vengono cancellati {{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni dopo che la richiesta è stata gestita. +

    + + @endif +
    diff --git a/resources/views/mail/modulo-s13.blade.php b/resources/views/mail/modulo-s13.blade.php new file mode 100644 index 0000000..3a5fc36 --- /dev/null +++ b/resources/views/mail/modulo-s13.blade.php @@ -0,0 +1,7 @@ +Ciao {!! $utente->name !!}, + +in allegato il modulo S-13 (registro di assegnazione dei territori) dell'anno di servizio {!! $anno->label !!}, aggiornato al {!! $data !!}. + +Lo manda TerManager2 l'ultima domenica di ogni mese agli amministratori. + +TerManager2 diff --git a/routes/console.php b/routes/console.php index f6dd04e..589c92e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,3 +15,15 @@ Schedule::command('audit:cleanup')->daily(); // mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei // territori, quindi accumularli finisce per riempirlo. Schedule::command('backup:cleanup')->daily(); + +// Modulo S-13 per email agli amministratori, l'ultima domenica di ogni mese +// alle 8 (documento delle attività per la versione 2, deciso il 21/10/2026): +// una domenica è l'ultima del mese quando la settimana dopo è già nel mese +// seguente. +Schedule::command('registro:invia-s13') + ->weeklyOn(0, '08:00') + ->when(fn () => now()->addWeek()->month !== now()->month) + ->withoutOverlapping(); + +// Richieste del modulo pubblico già gestite: nome e note non servono più. +Schedule::command('richieste:pulizia')->daily(); diff --git a/routes/web.php b/routes/web.php index afec0c2..c53edbb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ use App\Livewire\Proclamatori\ProclamatoreShow; use App\Livewire\Proclamatori\ProclamatoreEdit; use App\Livewire\Proclamatori\ProclamatoreCestino; use App\Livewire\Assegnazioni\Assegna; +use App\Livewire\Assegnazioni\Cartacei; use App\Livewire\Assegnazioni\Rientra; use App\Livewire\Gruppi\GruppiIndex; use App\Livewire\Gruppi\GruppoShow; @@ -68,6 +69,12 @@ Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::cla Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file']) ->name('assignments.pdf.file'); +// Modulo pubblico delle richieste (territorio nuovo, riconsegna): senza login. +// Le difese anti-spam stanno nel componente (ModuloRichiesta). +Route::get('richiesta', App\Livewire\Richieste\ModuloRichiesta::class) + ->middleware('throttle:60,1') + ->name('richiesta'); + /* |-------------------------------------------------------------------------- | Authenticated Routes @@ -165,6 +172,7 @@ Route::middleware(['auth', 'verified'])->group(function () { // Assegnazioni Route::middleware('permission:territori.assign')->group(function () { Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna'); + Route::get('assegnazioni/cartacei', Cartacei::class)->name('assegnazioni.cartacei'); }); // Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il // suo gruppo: il controllo fine è in Rientra::autorizza(). diff --git a/tests/Feature/GruppiServizioTest.php b/tests/Feature/GruppiServizioTest.php index d90ea0b..9ae328a 100644 --- a/tests/Feature/GruppiServizioTest.php +++ b/tests/Feature/GruppiServizioTest.php @@ -306,6 +306,23 @@ class GruppiServizioTest extends TestCase $this->assertTrue($a->link_da_inviare); $this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra'); $this->assertSame('assegnato', $territori[0]->fresh()->stato); + $this->assertFalse($a->cartaceo); + } + + public function test_il_sorvegliante_segna_la_cartolina_cartacea(): void + { + ['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo(); + $this->flushSession(); + + Livewire::actingAs($s)->test(MioGruppo::class) + ->call('apriAssegnazione', $territori[0]->id) + ->set('membroId', $verdi->id) + ->set('cartaceo', true) + ->call('assegna') + ->assertHasNoErrors() + ->assertSet('cartaceo', false); + + $this->assertTrue(Assegnazione::sole()->cartaceo); } public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void @@ -783,11 +800,12 @@ class GruppiServizioTest extends TestCase public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void { ['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo(); - app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()); + app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now(), cartaceo: true); $xml = (new XmlExchange())->currentXmlBackup(); $this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml)); + $this->assertTrue(Assegnazione::sole()->cartaceo, 'Il segno del cartaceo passa per l\'XML'); $g = GruppoServizio::sole(); $this->assertSame('Zisa', $g->nome); $this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo); diff --git a/tests/Feature/RichiesteTest.php b/tests/Feature/RichiesteTest.php new file mode 100644 index 0000000..2f9ada1 --- /dev/null +++ b/tests/Feature/RichiesteTest.php @@ -0,0 +1,393 @@ +withoutVite(); + $this->seed(RolesAndPermissionsSeeder::class); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']); + $this->travelTo(Carbon::parse('2026-10-21 10:00')); + + $this->admin = $this->utente('Admin', 'admin@example.test', 'amministratore'); + RateLimiter::clear('richiesta-pubblica:' . sha1('127.0.0.1')); + } + + private function utente(string $nome, string $email, string $ruolo): User + { + $utente = User::create(['name' => $nome, 'email' => $email, 'password' => 'password-di-prova']); + $utente->assignRole($ruolo); + + return $utente; + } + + private function proclamatore(string $cognome, string $nome): Proclamatore + { + return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]); + } + + private function territorio(string $numero): Territorio + { + return Territorio::create(['numero' => $numero, 'attivo' => true]); + } + + private function assegna(Territorio $t, Proclamatore $p, string $dal, bool $cartaceo = false): Assegnazione + { + return Assegnazione::create([ + 'territorio_id' => $t->id, + 'proclamatore_id' => $p->id, + 'anno_teocratico_id' => AnnoTeocratico::perData(Carbon::parse($dal))->id, + 'assigned_at' => $dal, + 'cartaceo' => $cartaceo, + ]); + } + + /** Il modulo compilato da una persona: aperto, qualche secondo, inviato. */ + private function compila(array $campi): \Livewire\Features\SupportTesting\Testable + { + $modulo = Livewire::test(ModuloRichiesta::class); + foreach ($campi as $campo => $valore) { + $modulo->set($campo, $valore); + } + $this->travel(ModuloRichiesta::SECONDI_MINIMI + 2)->seconds(); + + return $modulo->call('invia'); + } + + private function postaFunzionante(): void + { + Setting::instance()->update(['mail_host' => 'smtp.esempio.it']); + Setting::flushCache(); + } + + // ─── Il modulo pubblico ───────────────────────────────────── + + public function test_il_modulo_si_apre_senza_login_e_non_mostra_dati(): void + { + $this->proclamatore('Rossi', 'Mario'); + auth()->logout(); + + $this->get(route('richiesta')) + ->assertOk() + ->assertSee('Invia la richiesta') + ->assertDontSee('Rossi'); + } + + public function test_una_richiesta_si_salva_cifrata_e_avvisa_chi_la_gestisce(): void + { + Notification::fake(); + $this->postaFunzionante(); + $assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente'); + $operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore'); + $nonConfermato = $this->utente('Altro', 'altro@example.test', 'amministratore'); + $nonConfermato->forceFill(['email_verified_at' => null])->save(); + auth()->logout(); + + $this->compila(['tipo' => Richiesta::NUOVO, 'nome' => 'Mario Rossi', 'note' => 'Vicino a casa']) + ->assertHasNoErrors() + ->assertSet('inviata', true); + + $r = Richiesta::sole(); + $this->assertSame('Mario Rossi', $r->nome); + $this->assertSame(Richiesta::APERTA, $r->stato); + $this->assertNull($r->numero_territorio); + $grezzo = DB::table('richieste')->value('nome'); + $this->assertStringNotContainsString('Rossi', $grezzo, 'Il nome nel database è cifrato'); + $this->assertStringNotContainsString('Rossi', Activity::all()->toJson(), 'Il nome non va nel registro'); + + Notification::assertSentTo([$this->admin, $assistente], RichiestaRicevuta::class); + Notification::assertNotSentTo([$operatore, $nonConfermato], RichiestaRicevuta::class); + } + + public function test_senza_posta_la_richiesta_resta_comunque(): void + { + Notification::fake(); + auth()->logout(); + + $this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi', 'numero' => '12'])->assertHasNoErrors(); + + $this->assertSame('12', Richiesta::sole()->numero_territorio); + Notification::assertNothingSent(); + } + + public function test_la_riconsegna_vuole_il_numero_del_territorio(): void + { + $this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi'])->assertHasErrors('numero'); + $this->compila(['tipo' => Richiesta::NUOVO, 'nome' => 'Ma'])->assertHasErrors('nome'); + + $this->assertSame(0, Richiesta::count()); + } + + public function test_lo_spam_non_si_salva_e_non_se_ne_accorge(): void + { + // Il campo trappola riempito. + $this->compila(['nome' => 'Spam Bot', 'sito' => 'https://spam.example'])->assertSet('inviata', true); + + // Inviato subito dopo l'apertura. + Livewire::test(ModuloRichiesta::class)->set('nome', 'Spam Bot')->call('invia')->assertSet('inviata', true); + + $this->assertSame(0, Richiesta::count()); + } + + public function test_al_massimo_cinque_richieste_all_ora_dallo_stesso_indirizzo(): void + { + for ($i = 1; $i <= ModuloRichiesta::MASSIMO_ORA; $i++) { + $this->compila(['nome' => "Mario Rossi {$i}"])->assertHasNoErrors(); + } + $this->compila(['nome' => 'Mario Rossi 6'])->assertHasErrors('nome'); + + $this->assertSame(ModuloRichiesta::MASSIMO_ORA, Richiesta::count()); + } + + // ─── I nomi simili ────────────────────────────────────────── + + public function test_i_nomi_simili_trovano_il_proclamatore_anche_scritto_male(): void + { + $rossi = $this->proclamatore('Rossi', 'Mario'); + $rossini = $this->proclamatore('Rossini', 'Maria'); + $this->proclamatore('Bianchi', 'Luca'); + $servizio = app(RichiesteServizio::class); + + $this->assertSame([$rossi->id, $rossini->id], $servizio->nomiSimili('mario rossi')->pluck('id')->all()); + $this->assertSame($rossi->id, $servizio->nomiSimili('Rosi Mario')->first()?->id, 'Una lettera in meno'); + $this->assertSame($rossi->id, $servizio->nomiSimili('Màrio Rossi')->first()?->id, 'Accenti'); + $this->assertTrue($servizio->nomiSimili('Giuseppe Verdi')->isEmpty()); + } + + // ─── La Home ──────────────────────────────────────────────── + + public function test_la_home_mostra_le_richieste_con_i_nomi_simili_e_i_collegamenti(): void + { + $rossi = $this->proclamatore('Rossi', 'Mario'); + $nuova = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rosi', null, 'Anche cartaceo'); + $t = $this->territorio('12'); + $a = $this->assegna($t, $this->proclamatore('Verdi', 'Anna'), '2026-10-01'); + $riconsegna = app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Anna Verdi', '12', null); + app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Luca Neri', '99', null); + + Livewire::actingAs($this->admin)->test(Home::class) + ->assertSee('Richieste (3)') + ->assertSee('Mario Rosi') + ->assertSee('Anche cartaceo') + ->assertSee(route('assegnazioni.assegna', ['proclamatore' => $rossi->id, 'richiesta' => $nuova->id, 'dalla_home' => 1])) + ->assertSee('Nessuno di questi: apri il modulo') + ->assertSee(route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $riconsegna->id])) + ->assertSee('Il territorio N° 99 non risulta assegnato.'); + } + + public function test_solo_chi_ha_il_permesso_vede_e_scarta_le_richieste(): void + { + $r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null); + $operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore'); + + Livewire::actingAs($operatore)->test(Home::class) + ->assertDontSee('Mario Rossi') + ->call('scartaRichiesta', $r->id) + ->assertForbidden(); + $this->assertTrue($r->fresh()->isAperta()); + + $assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente'); + Livewire::actingAs($assistente)->test(Home::class) + ->assertSee('Mario Rossi') + ->call('scartaRichiesta', $r->id) + ->assertDontSee('Mario Rossi'); + + $r->refresh(); + $this->assertSame(Richiesta::SCARTATA, $r->stato); + $this->assertSame($assistente->id, $r->gestita_da); + } + + // ─── Dalla richiesta ai moduli esistenti ──────────────────── + + public function test_dalla_richiesta_il_modulo_di_assegnazione_e_precompilato_e_la_chiude(): void + { + $rossi = $this->proclamatore('Rossi', 'Mario'); + $t = $this->territorio('7'); + $r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null); + + Livewire::withQueryParams(['proclamatore' => $rossi->id, 'richiesta' => $r->id, 'dalla_home' => 1]) + ->actingAs($this->admin) + ->test(Assegna::class) + ->assertSet('proclamatore_id', $rossi->id) + ->assertSet('richiestaId', $r->id) + ->assertSee('Mario Rossi') + ->set('territorio_id', $t->id) + ->set('cartaceo', true) + ->call('save') + ->assertHasNoErrors(); + + $a = Assegnazione::sole(); + $this->assertTrue($a->cartaceo); + $r->refresh(); + $this->assertSame(Richiesta::EVASA, $r->stato); + $this->assertSame($a->id, $r->assegnazione_id); + $this->assertSame($this->admin->id, $r->gestita_da); + } + + public function test_senza_permesso_o_con_una_richiesta_chiusa_il_modulo_la_ignora(): void + { + $r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null); + $operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore'); + + Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($operatore) + ->test(Assegna::class)->assertSet('richiestaId', null); + + $r->scarta($this->admin); + Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($this->admin) + ->test(Assegna::class)->assertSet('richiestaId', null); + } + + public function test_dalla_riconsegna_il_rientro_la_chiude(): void + { + $t = $this->territorio('12'); + $a = $this->assegna($t, $this->proclamatore('Verdi', 'Anna'), '2026-10-01'); + $r = app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Anna Verdi', '12', null); + + Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($this->admin) + ->test(Rientra::class, ['assegnazione' => $a]) + ->assertSee('Riconsegna annunciata da') + ->call('save') + ->assertHasNoErrors(); + + $this->assertNotNull($a->fresh()->returned_at); + $r->refresh(); + $this->assertSame(Richiesta::EVASA, $r->stato); + $this->assertSame($a->id, $r->assegnazione_id); + } + + public function test_le_richieste_gestite_si_cancellano_dopo_novanta_giorni(): void + { + $servizio = app(RichiesteServizio::class); + $vecchia = $servizio->registra(Richiesta::NUOVO, 'Vecchia Gestita', null, null); + $vecchia->scarta($this->admin); + $aperta = $servizio->registra(Richiesta::NUOVO, 'Vecchia Aperta', null, null); + + $this->travel(PuliziaRichieste::GIORNI - 1)->days(); + $recente = $servizio->registra(Richiesta::NUOVO, 'Recente', null, null); + $recente->scarta($this->admin); + + $this->travel(2)->days(); + $this->artisan('richieste:pulizia')->assertSuccessful(); + + $this->assertModelMissing($vecchia); + $this->assertModelExists($aperta); + $this->assertModelExists($recente); + } + + // ─── Cartacei ─────────────────────────────────────────────── + + public function test_l_elenco_dei_cartacei_mostra_quelli_fuori_e_a_chi(): void + { + $rossi = $this->proclamatore('Rossi', 'Mario'); + $this->assegna($this->territorio('21'), $rossi, '2026-10-01', cartaceo: true); + $this->assegna($this->territorio('22'), $rossi, '2026-10-01'); + $rientrato = $this->assegna($this->territorio('23'), $rossi, '2026-09-01', cartaceo: true); + $rientrato->update(['returned_at' => '2026-10-01']); + + $this->actingAs($this->admin)->get(route('assegnazioni.cartacei')) + ->assertOk() + ->assertSee('N° 21') + ->assertSee('Rossi Mario') + ->assertDontSee('N° 22') + ->assertDontSee('N° 23'); + + // Nell'elenco degli assegnati, il segno solo dove serve. + $html = $this->actingAs($this->admin)->get(route('assegnazioni.assegna'))->getContent(); + $this->assertStringContainsString('assegnazioni.elenco-assegnati', $html); + Livewire::withoutLazyLoading()->actingAs($this->admin)->test(\App\Livewire\Assegnazioni\ElencoAssegnati::class) + ->assertSeeHtml('cartaceo'); + + $sorvegliante = $this->utente('Sorvegliante', 'sorvegliante@example.test', 'sorvegliante'); + $this->actingAs($sorvegliante)->get(route('assegnazioni.cartacei'))->assertForbidden(); + } + + // ─── Modulo S-13 per email ────────────────────────────────── + + public function test_l_s13_va_agli_amministratori_con_l_email_confermata(): void + { + Mail::fake(); + $this->postaFunzionante(); + $assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente'); + $nonConfermato = $this->utente('Altro', 'altro@example.test', 'amministratore'); + $nonConfermato->forceFill(['email_verified_at' => null])->save(); + + $this->artisan('registro:invia-s13')->assertSuccessful(); + + Mail::assertSent(ModuloS13Mail::class, fn (ModuloS13Mail $m) => $m->hasTo('admin@example.test') + && collect($m->attachments())->contains(fn ($a) => $a->as === 'S-13-registro-assegnazioni-2026-2027.pdf' && $a->mime === 'application/pdf')); + Mail::assertSent(ModuloS13Mail::class, 1); + Mail::assertNotSent(ModuloS13Mail::class, fn (ModuloS13Mail $m) => $m->hasTo($assistente->email) || $m->hasTo($nonConfermato->email)); + $this->assertSame(1, Activity::where('description', 's13_inviato')->sole()->properties['destinatari']); + } + + public function test_senza_posta_l_s13_non_parte(): void + { + Mail::fake(); + + $this->artisan('registro:invia-s13')->assertSuccessful(); + + Mail::assertNothingSent(); + } + + public function test_l_s13_parte_solo_l_ultima_domenica_del_mese(): void + { + $evento = collect(app(Schedule::class)->events()) + ->first(fn ($e) => str_contains((string) $e->command, 'registro:invia-s13')); + $this->assertNotNull($evento); + + $parte = function (string $quando) use ($evento) { + $this->travelTo(Carbon::parse($quando, config('app.timezone'))); + + return $evento->isDue($this->app) && $evento->filtersPass($this->app); + }; + + $this->assertTrue($parte('2026-10-25 08:00'), 'Ultima domenica di ottobre'); + $this->assertFalse($parte('2026-10-18 08:00'), 'Penultima domenica'); + $this->assertFalse($parte('2026-10-24 08:00'), 'Sabato'); + $this->assertTrue($parte('2026-11-29 08:00'), 'Ultima domenica di novembre'); + $this->assertTrue($parte('2027-01-31 08:00'), 'Domenica 31'); + } +}