++ Richieste dal modulo pubblico, territori cartacei, S-13 mensile per email

- /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 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-21 17:32:09 +02:00
parent 5e40f0fe74
commit 7904c96db8
36 changed files with 1563 additions and 24 deletions

View File

@@ -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)

View File

@@ -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 <app> php artisan audit:cleanup
docker exec -it <app> php artisan backup:cleanup
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
# Pulizia manuale delle richieste dal modulo pubblico gestite da più di 90 giorni
docker exec -it <app> php artisan richieste:pulizia
# Mandare subito il modulo S-13 agli amministratori (anno in corso, o un altro)
docker exec -it <app> php artisan registro:invia-s13
docker exec -it <app> php artisan registro:invia-s13 --anno=2025-2026
# Svuotare le cache
docker exec -it <app> 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**

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use App\Services\Registro\ModuloS13;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
/**
* Il modulo S-13 dell'anno di servizio in corso, per email agli
* amministratori (l'indirizzo della loro utenza, se confermato). Lo lancia lo
* scheduler l'ultima domenica di ogni mese (routes/console.php); a mano si
* può scegliere l'anno con --anno=2025-2026.
*
* Parte subito, non in coda: il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro. Senza un server di posta funzionante non manda
* niente e lo dice.
*/
class InviaModuloS13 extends Command
{
protected $signature = 'registro:invia-s13 {--anno= : etichetta dell\'anno di servizio, es. 2025-2026 (predefinito: quello in corso)}';
protected $description = 'Manda per email agli amministratori il modulo S-13 dell\'anno di servizio';
public function handle(ModuloS13 $modulo, ConfigurazionePosta $posta): int
{
if (! $posta->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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Richiesta;
use Illuminate\Console\Command;
/**
* Le richieste dal modulo pubblico portano nome e note scritti da chi non ha
* un account: gestite (evase o scartate) servono ancora un po' per
* riferimento, poi si cancellano. Quelle aperte restano finché qualcuno non
* le gestisce.
*/
class PuliziaRichieste extends Command
{
/** Dopo quanti giorni dalla gestione una richiesta si cancella. */
public const GIORNI = 90;
protected $signature = 'richieste:pulizia';
protected $description = 'Cancella le richieste del modulo pubblico gestite da più di ' . self::GIORNI . ' giorni';
public function handle(): int
{
$cancellate = Richiesta::query()
->where('stato', '!=', Richiesta::APERTA)
->where('gestita_at', '<', now()->subDays(self::GIORNI))
->delete();
$this->info("Richieste cancellate: {$cancellate}");
return self::SUCCESS;
}
}

View File

@@ -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

View File

@@ -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=<id>: 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,
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Assegnazioni;
use App\Models\Assegnazione;
use Livewire\Component;
/**
* I territori fuori in cartaceo, e a chi: le assegnazioni aperte per cui il
* proclamatore ha chiesto la cartolina (spunta "cartaceo" su Assegna o in "Il
* mio gruppo"). Al rientro escono dall'elenco.
*/
class Cartacei extends Component
{
public function render()
{
return view('livewire.assegnazioni.cartacei', [
'assegnazioni' => Assegnazione::aperte()
->where('cartaceo', true)
->with(['territorio.zona', 'proclamatore'])
->orderBy('assigned_at')
->get(),
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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);

View File

@@ -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(),
]);
}
}

View File

@@ -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'),
],

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Richieste;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Services\RichiesteServizio;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
* territorio nuovo o annuncia una riconsegna. Non mostra nessun dato già
* presente: chi lo apre vede solo i campi da compilare.
*
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
* - un tempo minimo fra apertura e invio (i programmi inviano subito);
* - al massimo MASSIMO_ORA richieste all'ora per indirizzo.
* Nei primi due casi si risponde come se fosse andata bene, senza salvare:
* chi prova non impara cosa l'ha fermato.
*/
class ModuloRichiesta extends Component
{
public const MASSIMO_ORA = 5;
public const SECONDI_MINIMI = 3;
public string $tipo = Richiesta::NUOVO;
public string $nome = '';
public string $numero = '';
public string $note = '';
/** Trappola: nascosto alle persone, i programmi lo riempiono. */
public string $sito = '';
#[Locked]
public int $apertoAlle = 0;
#[Locked]
public bool $inviata = false;
public function mount(): void
{
$this->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']);
}
}

View File

@@ -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'] ?? ''));

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Il modulo S-13 in PDF per un amministratore (registro:invia-s13). Non va in
* coda (niente ShouldQueue): il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro.
*/
class ModuloS13Mail extends Mailable
{
public function __construct(
public User $utente,
public AnnoTeocratico $anno,
private string $pdf,
) {}
public function nomeFile(): string
{
return "S-13-registro-assegnazioni-{$this->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<int, Attachment> */
public function attachments(): array
{
return [
Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'),
];
}
}

View File

@@ -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',
];
}

112
app/Models/Richiesta.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Una richiesta dal modulo pubblico (/richiesta): un proclamatore chiede un
* territorio nuovo o ne annuncia la riconsegna. Non tocca niente da sola:
* compare nella Home a chi ha `richieste.gestisci` (amministratore e
* assistenti), che la porta sul modulo di assegnazione o di rientro già
* esistenti; fatta l'operazione la richiesta risulta evasa. Si può anche
* scartare. Logica in App\Services\RichiesteServizio.
*
* Nome e note li scrive chi compila, senza account: cifrati come i dati dei
* proclamatori, e fuori dal registro delle attività (che logga solo lo stato).
*/
class Richiesta extends Model
{
use LogsActivity;
public const NUOVO = 'nuovo';
public const RICONSEGNA = 'riconsegna';
public const APERTA = 'aperta';
public const EVASA = 'evasa';
public const SCARTATA = 'scartata';
protected $table = 'richieste';
// Appena creata è aperta anche nell'oggetto, non solo nel database.
protected $attributes = ['stato' => 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(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\Richiesta;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Avviso ad amministratori e assistenti: è arrivata una richiesta dal modulo
* pubblico. Volutamente non ShouldQueue: il nome scritto nella richiesta
* finirebbe in chiaro nella coda (Redis, failed_jobs).
*/
class RichiestaRicevuta extends Notification
{
public function __construct(private Richiesta $richiesta) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$r = $this->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');
}
}

View File

@@ -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,
]);
});

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Services;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Territorio;
use App\Models\User;
use App\Notifications\RichiestaRicevuta;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
/**
* Le richieste dal modulo pubblico: registrarle, avvisare chi le gestisce e
* aiutarlo a ricondurle a un proclamatore o a un'assegnazione. La conferma
* passa dai moduli già esistenti (Assegna, Rientra), che chiudono la
* richiesta da cui sono partiti.
*/
class RichiesteServizio
{
public function __construct(private ConfigurazionePosta $posta) {}
/** Registra la richiesta e avvisa per email amministratori e assistenti. */
public function registra(string $tipo, string $nome, ?string $numero, ?string $note): Richiesta
{
$richiesta = Richiesta::create([
'tipo' => $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<int, Proclamatore>
*/
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;
}
}

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Dal documento delle attività per la versione 2, decise dall'utente il
* 21/10/2026:
*
* - richieste dal modulo pubblico (nuovo territorio o riconsegna), che
* responsabile e assistenti confermano dalla Home: tabella `richieste` e
* permesso `richieste.gestisci` (amministratore e assistente);
* - territorio chiesto in cartaceo: segno sull'assegnazione
* (`assegnazioni.cartaceo`).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('richieste', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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

View File

@@ -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 {

View File

@@ -120,6 +120,11 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegnazioni
</a>
<a href="{{ route('assegnazioni.cartacei') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.cartacei') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Cartacei
</a>
@endcan
@can('campagne.manage')

View File

@@ -26,6 +26,13 @@
{{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
@if($richiesta)
{{-- Partita da una richiesta del modulo pubblico (Home): assegnando, risulta evasa. --}}
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900" role="status">
Richiesta di <strong>{{ $richiesta->nome }}</strong> del {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
Assegnando il territorio, la richiesta risulta evasa.
</div>
@endif
<form wire:submit="save" class="space-y-4">
{{-- L'anteprima segue la scelta nel browser (evento "scelta" di
<x-scelta>, miniatura nelle opzioni): scegliere un
@@ -65,6 +72,12 @@
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Ha chiesto la cartolina cartacea
<a href="{{ route('assegnazioni.cartacei') }}" class="text-xs text-indigo-600 hover:underline">(vedi i cartacei fuori)</a>
</label>
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<x-btn variante="primary" type="submit">Assegna</x-btn>
@if($ritorno)

View File

@@ -0,0 +1,47 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Territori cartacei</h1>
<p class="text-sm text-gray-500 mt-1">
Le cartoline cartacee fuori e a chi: territori assegnati per cui il proclamatore ha chiesto il cartaceo.
Al rientro escono dall'elenco.
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
@if($assegnazioni->isEmpty())
<p class="px-5 py-8 text-center text-sm text-gray-500">Nessun territorio cartaceo fuori.</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Territorio</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dal</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Giorni</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $a)
<tr wire:key="cartaceo-{{ $a->id }}">
<td data-label="Territorio" class="px-4 py-3">
<a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a>
<span class="text-gray-500">{{ $a->territorio?->zona?->nome }}</span>
</td>
<td data-label="Proclamatore" class="px-4 py-3">{{ $a->proclamatore?->nome_completo }}</td>
<td data-label="Dal" class="px-4 py-3 tabular-nums">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Giorni" class="px-4 py-3 text-right tabular-nums">{{ $a->giorni }}</td>
<td class="px-4 py-3 text-right cell-actions">
@can('territori.return')
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>

View File

@@ -45,7 +45,7 @@
(data-ritorno); indirizzo relativo e niente a capo: pesa meno. --}}
<div wire:key="assegnato-{{ $a->id }}" data-cerca="{{ $cerca[$a->id] }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa">
<span><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="tm-assegnato-numero">N° {{ $a->territorio?->numero }}</a> @if($a->territorio?->zona?->nome)<span class="tm-assegnato-zona">{{ $a->territorio->zona->nome }}</span>@endif</span>
<span><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="tm-assegnato-numero">N° {{ $a->territorio?->numero }}</a> @if($a->territorio?->zona?->nome)<span class="tm-assegnato-zona">{{ $a->territorio->zona->nome }}</span>@endif @if($a->cartaceo)<span class="tm-cartaceo">cartaceo</span>@endif</span>
<span class="tm-assegnato-info"><b>{{ $nome }}</b> · {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
</div>
@if($a->link_in_sospeso)

View File

@@ -7,6 +7,13 @@
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
</div>
@if($richiesta)
{{-- Partito da una richiesta di riconsegna (Home): registrando il rientro, risulta evasa. --}}
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900 max-w-lg" role="status">
Riconsegna annunciata da <strong>{{ $richiesta->nome }}</strong> il {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
</div>
@endif
{{-- Assignment summary --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">

View File

@@ -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 }}</button>@else<span class="tm-assegnato-numero">N° {{ $t->numero }}</span>@endif @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif</span>
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif @if($a?->cartaceo)<span class="tm-cartaceo">cartaceo</span>@endif</span>
@if($a)
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
@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') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Ha chiesto la cartolina cartacea
</label>
<div class="flex flex-wrap gap-2">
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>

View File

@@ -78,6 +78,61 @@
@endif
</div>
{{-- Richieste dal modulo pubblico: si confermano portandole sul modulo
di assegnazione o di rientro, che le chiude da solo. --}}
@can('richieste.gestisci')
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-richieste">
<div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
<div>
<h3 id="titolo-richieste" class="text-sm font-semibold" style="color:#3730a3">Richieste ({{ $richieste->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Dal modulo pubblico: <code class="select-all">{{ route('richiesta') }}</code></p>
</div>
</div>
@forelse($richieste as $voce)
@php
$r = $voce['richiesta'];
$a = $voce['assegnazione'];
@endphp
<div class="px-5 py-4 border-b border-gray-100 last:border-b-0" wire:key="richiesta-{{ $r->id }}">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="text-sm text-gray-900">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $r->tipo === \App\Models\Richiesta::RICONSEGNA ? 'bg-amber-50 text-amber-800' : 'bg-indigo-50 text-indigo-700' }}">{{ $r->tipo_etichetta }}</span>
<strong class="ml-1">{{ $r->nome }}</strong>
@if($r->numero_territorio) · territorio N° {{ $r->numero_territorio }} @endif
</p>
<span class="text-xs text-gray-500">{{ $r->created_at->format('d/m/Y H:i') }}</span>
</div>
@if($r->note)
<p class="mt-1 text-sm text-gray-600 italic">«{{ $r->note }}»</p>
@endif
<div class="mt-3 flex flex-wrap items-center gap-2">
@if($r->tipo === \App\Models\Richiesta::NUOVO)
<span class="text-xs text-gray-500">{{ $voce['simili']->isNotEmpty() ? 'Chi è? Assegna a:' : 'Nessun proclamatore con un nome simile.' }}</span>
@foreach($voce['simili'] as $p)
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm"
:href="route('assegnazioni.assegna', ['proclamatore' => $p->id, 'richiesta' => $r->id, 'dalla_home' => 1])">{{ $p->nome_completo }} →</x-btn>
@endforeach
<x-btn data-ritorno variante="ghost" dimensione="sm"
:href="route('assegnazioni.assegna', ['richiesta' => $r->id, 'dalla_home' => 1])">{{ $voce['simili']->isNotEmpty() ? 'Nessuno di questi: apri il modulo' : 'Apri il modulo' }}</x-btn>
@elseif($a)
<span class="text-xs text-gray-500">Assegnato a <strong>{{ $a->proclamatore?->nome_completo }}</strong> dal {{ $a->assigned_at->format('d/m/Y') }}</span>
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm"
:href="route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $r->id])">Registra rientro →</x-btn>
@else
<span class="text-xs text-amber-800">Il territorio N° {{ $r->numero_territorio }} non risulta assegnato.</span>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm" class="ml-auto"
wire:click="scartaRichiesta({{ $r->id }})"
wire:confirm="Scartare la richiesta di {{ $r->nome }}?">Scarta</x-btn>
</div>
</div>
@empty
<p class="px-5 py-4 text-sm text-gray-500">Nessuna richiesta aperta.</p>
@endforelse
</section>
@endcan
{{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}}

View File

@@ -18,7 +18,8 @@
<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>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina, stato del suo invio (inviato o no, e con quale canale) e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
<li><strong>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) se il proclamatore ha chiesto la cartolina cartacea e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
<li><strong>Richieste dal modulo pubblico</strong>: 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.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio entra con un'email fittizia creata dall'applicazione (iniziale del nome e cognome), che non riceve posta e non va confermata. La sua utenza è collegata al suo proclamatore e viene eliminata quando il gruppo si scioglie; anche il gruppo, sciolto, viene eliminato.</li>
<li><strong>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</li>
</ul>
@@ -29,6 +30,7 @@
<ul class="list-disc list-inside space-y-1">
<li>{{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.</li>
<li>{{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.</li>
<li>{{ $conteggi['richieste'] }} richieste dal modulo pubblico, di cui {{ $conteggi['richiesteAperte'] }} da gestire.</li>
<li>{{ $conteggi['utenti'] }} utenti dell'applicazione.</li>
<li>{{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.</li>
</ul>
@@ -59,6 +61,7 @@
@endforeach
</ul>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia 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 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.</p>
</section>
<section>
@@ -67,6 +70,7 @@
<li><strong>Cifratura</strong>: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.</li>
<li><strong>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li>
<li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li>
<li><strong>Modulo pubblico</strong>: protetto dallo spam con un campo trappola, un tempo minimo di compilazione e un limite di richieste per indirizzo.</li>
<li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li>
<li><strong>Comunicazioni</strong>: l'applicazione risponde all'indirizzo <code>{{ $indirizzo }}</code>@if($https), su connessione cifrata (HTTPS)@else. <span class="text-red-700 font-medium">Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.</span>@endif.</li>
<li><strong>Hosting</strong>: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.</li>
@@ -88,7 +92,7 @@
<li><strong>Condivisione del link</strong>: 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.</li>
<li><strong>Posta</strong>:
@if($postaAttiva)
le email agli utenti dell'applicazione (conferma dell'indirizzo) partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>. Ai proclamatori non si mandano email.
le email agli utenti dell'applicazione partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>: 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 @@
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
<li>Le richieste dal modulo pubblico sono cancellate automaticamente <strong>{{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni</strong> dopo essere state gestite.</li>
<li>I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori <strong>in chiaro</strong>: vanno custoditi in un luogo protetto e cancellati quando non servono più.</li>
</ul>
</section>

View File

@@ -0,0 +1,74 @@
<div class="bg-white shadow-xl rounded-2xl p-6 sm:p-8 my-8" style="border-top:4px solid #6366f1">
{{-- Modulo pubblico (ModuloRichiesta): nessun dato già presente si vede
qui, solo i campi da compilare. --}}
<div class="text-center mb-6">
<div class="mx-auto h-14 w-14 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4 shadow-lg" style="background:linear-gradient(135deg,#4338ca,#6366f1)">T2</div>
<h1 class="text-2xl font-bold text-gray-900">Richiesta territorio</h1>
<p class="text-gray-500 text-sm mt-1">{{ $congregazione }}</p>
</div>
@if($inviata)
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800" role="status">
<p class="font-semibold">Richiesta inviata.</p>
<p class="mt-1">Il responsabile dei territori la vedrà e ti farà sapere.</p>
</div>
<div class="mt-5 text-center">
<x-btn variante="secondary" wire:click="nuova">Fai un'altra richiesta</x-btn>
</div>
@else
<form wire:submit="invia" class="space-y-5">
<fieldset>
<legend class="block text-sm font-medium text-gray-700 mb-2">Cosa ti serve? *</legend>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::NUOVO }}" class="h-4 w-4 text-indigo-600">
Chiedo un territorio nuovo
</label>
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::RICONSEGNA }}" class="h-4 w-4 text-indigo-600">
Riconsegno un territorio
</label>
</div>
</fieldset>
<div>
<label for="nome" class="block text-sm font-medium text-gray-700">Nome e cognome *</label>
<input wire:model="nome" type="text" id="nome" autocomplete="name" maxlength="100"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
@error('nome') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
<div>
<label for="numero" class="block text-sm font-medium text-gray-700">N° del territorio *</label>
<input wire:model="numero" type="text" id="numero" inputmode="numeric" maxlength="20" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div>
<label for="note" class="block text-sm font-medium text-gray-700">
{{ $tipo === \App\Models\Richiesta::RICONSEGNA ? 'Note' : 'Esigenze particolari / note' }}
</label>
<textarea wire:model="note" id="note" rows="3" maxlength="1000"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5"></textarea>
@error('note') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Trappola per i programmi che riempiono ogni campo: nascosta a chi
guarda e ai lettori di schermo. --}}
<div class="tm-trappola" aria-hidden="true">
<label for="sito">Lascia vuoto questo campo</label>
<input wire:model="sito" type="text" id="sito" tabindex="-1" autocomplete="off">
</div>
<x-btn variante="primary" type="submit" class="w-full">Invia la richiesta</x-btn>
<p class="text-xs text-gray-500">
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.
</p>
</form>
@endif
</div>

View File

@@ -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

View File

@@ -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();

View File

@@ -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().

View File

@@ -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);

View File

@@ -0,0 +1,393 @@
<?php
namespace Tests\Feature;
use App\Console\Commands\PuliziaRichieste;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Home;
use App\Livewire\Richieste\ModuloRichiesta;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use App\Notifications\RichiestaRicevuta;
use App\Services\RichiesteServizio;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
/**
* Dal documento delle attività per la versione 2 (21 ottobre 2026):
* - il modulo pubblico di richiesta (nuovo territorio o riconsegna), che
* amministratore e assistenti confermano dalla Home portandolo sui moduli
* di assegnazione e di rientro già esistenti;
* - il segno "cartaceo" sull'assegnazione e l'elenco dei cartacei fuori;
* - il modulo S-13 per email agli amministratori l'ultima domenica del mese.
*/
class RichiesteTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->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('<span class="tm-cartaceo">cartaceo</span>');
$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');
}
}