Compare commits

...

14 Commits

Author SHA1 Message Date
Francesco Picone
635982bf90 ++ HANDOFF: PHP 8.3.36 in produzione e sulla demo, variabile provvisoria tolta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:50:17 +02:00
Francesco Picone
726af6d5c3 ++ Update gitignore 2026-10-21 17:55:24 +02:00
Francesco Picone
d198ad0b54 ++ Modulo pubblico: nella riconsegna il territorio si sceglie con il completamento automatico
Elenco con solo numero e zona (territori attivi e quelli ancora fuori),
mostrato solo per la riconsegna; il server accetta solo un numero
dell'elenco. Privacy, README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:50:09 +02:00
Francesco Picone
2e1da51080 ++ HANDOFF: 612 test verdi, RichiesteTest nell'elenco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:37:28 +02:00
Francesco Picone
5b4903d1cb ++ RichiesteTest: sessione svuotata prima di cambiare utente
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:36:32 +02:00
Francesco Picone
1da31ce600 ++ Richieste: ordine dei nomi simili, etichetta cartaceo senza blocco if, dati dei test
- nomiSimili: sortBy con una closure la usa come comparatore, non come
  chiave; ora sort() con confronto esplicito (punteggio, poi alfabetico)
- elenco degli assegnati: l'etichetta "cartaceo" come espressione, senza
  i segnaposto di Livewire attorno a @if (58 byte a riga)
- RichiesteTest: created_by sulle assegnazioni di prova

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:35:31 +02:00
Francesco Picone
7904c96db8 ++ 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>
2026-10-21 17:32:09 +02:00
Francesco Picone
5e40f0fe74 ++ HANDOFF: demo costruita con PHP 8.3.35 in via provvisoria, variabile da togliere
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:41:36 +02:00
Francesco Picone
18fe914da5 ++ Utenti: nuova password temporanea per il sorvegliante di gruppo
Sulla riga del sorvegliante, che prima non aveva azioni: "Nuova password
temporanea" (mostrata una volta, da cambiare al primo accesso) e "Apri il
gruppo". La logica sta in GruppiServizio::nuovaPasswordTemporanea(), usata
anche dalla pagina del gruppo; azzera anche il "ricordami".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:33:40 +02:00
Francesco Picone
2fb9838a0e ++ Il mio gruppo: il numero del territorio apre l'anteprima
Il numero diventa un pulsante che apre una finestra con la miniatura e il
collegamento al PDF. La miniatura passa dalla rotta nuova
mio-gruppo.miniatura, con la stessa regola del PDF: solo i territori del
pacchetto del gruppo sorvegliato.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:27:50 +02:00
Francesco Picone
9e473cd7fd ++ HANDOFF: mitigazione applicata ai container di demo e produzione
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:10:39 +02:00
Francesco Picone
8b6bc0796a ++ HANDOFF: in produzione solo la correzione di PHP (scelta dell'utente)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:03:59 +02:00
Francesco Picone
8ce3d6d10d ++ php.ini: sessioni native chiuse, mitigazione di CVE-2026-109618/109619
session.upload_progress.enabled = 0 e session.use_strict_mode = 1, come
indicato da Sansec in attesa della patch; restano anche dopo, perche'
all'applicazione non servono (Laravel ha le sue sessioni, in Redis).
Test di guardia in VersionePhpTest. HANDOFF: stato della mitigazione e
scelta ancora aperta sul rilascio in produzione. README: sicurezza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:01:00 +02:00
db5772d7f5 PHP fissato alla 8.3.36 (CVE-2026-109618, CVE-2026-109619)
- Dockerfile: ARG PHP_VERSION=8.3.36 e FROM php:${PHP_VERSION}-fpm al posto
  del tag mobile php:8.3-fpm, che la cache di Docker poteva risolvere sulla
  8.3.35 vulnerabile. Una build ora prende la versione corretta o fallisce.
- phpredis da GitHub (versione fissata) invece di pecl.php.net, che il
  giorno della patch rispondeva 504 e bloccava la build.
- Test contro il ritorno a un tag mobile o a una versione vulnerabile.
- PHP_VERSION passabile dal compose solo per prove locali.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:30:05 +02:00
47 changed files with 2108 additions and 58 deletions

View File

@@ -30,6 +30,8 @@ public/build
public/storage
public/hot
_ARCHIVE
db_data
redis_data

View File

@@ -4,16 +4,21 @@ 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).
---
## Stato attuale
**✅ 21 ottobre 2026 — PHP 8.3.36 su produzione e demo** (CVE-2026-109618/109619,
esecuzione di codice da remoto), con la mitigazione nel `php.ini`. Vedi
[Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
**Produzione** (`main` → compose `termanager2-core-production`) in esercizio
su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `d3314a9` (proclamatori senza
contatti, il link si copia o si condivide). Backup fatti prima dei rilasci in
`/etc/dokploy/termanager2-backups/` sul server.
su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `1ddb973` del 21/10, ore
18:48 (solo la correzione di PHP sopra il rilascio del 19/10: proclamatori
senza contatti, il link si copia o si condivide). Backup fatti prima dei
rilasci in `/etc/dokploy/termanager2-backups/` sul server.
**Demo** (`dev` → compose `termanager2-core-demo`,
`demo-termanager.pyconetwork.it`): avanti di una ventina di commit, tutti
@@ -28,13 +33,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, `main`
avanti fino a `dev` (fast-forward), deploy (l'autodeploy non parte: vedi
**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 |
|-----------------------------|-------|
@@ -45,8 +55,8 @@ dati da convertire.
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
| Backup / ripristino / azzeramento | ✅ verificati end-to-end |
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
| Test automatici | ✅ **577 test PHP + 13 JavaScript, verdi** (20/10/2026); copertura ancora parziale |
| Prove nel browser | ⚠️ le funzioni del 19-20 ottobre sono verificate dai test e sul server; nel browser le ha usate l'utente sulla demo |
| Test automatici | ✅ **613 test PHP + 13 JavaScript, verdi** (21/10/2026); copertura ancora parziale |
| Prove nel browser | ⚠️ le funzioni del 19-21 ottobre sono verificate dai test e sul server; nel browser le usa l'utente sulla demo (richieste, cartacei e S-13 non ancora provati da lui) |
La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
@@ -56,7 +66,157 @@ 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.
Nella riconsegna il numero si sceglie con `<x-scelta>` (richiesta
dell'utente del 21/10: "il territorio fallo inserire con completamento
automatico"): opzioni con solo numero e zona, valore = numero; i territori
attivi più quelli disattivati ma ancora fuori; l'elenco va nella pagina
solo con *riconsegna* scelta. Il server accetta solo un numero di
quell'elenco (confronto senza maiuscole). Nessun altro dato esistente: né
nomi né stato delle assegnazioni. 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 e in "Il mio
gruppo". Nell'elenco è un'espressione, non un blocco `@if`: Livewire mette
due commenti segnaposto attorno a ogni `@if`, 58 byte a riga anche quando è
falso, e la riga superava i 2,6 KB del test di peso. 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)
Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
Il progetto girava su 8.3.35.
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
un tag mobile o a una versione inferiore.
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
quel giorno pecl.php.net rispondeva 504 e la build falliva.
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
- **Mitigazione in attesa della patch** (indicata da Sansec, approvata
dall'utente): `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1` in `docker/php/php.ini`, da tenere anche dopo
(all'applicazione non servono: Laravel ha le sue sessioni, in Redis).
Nei container già in esecuzione di demo e produzione la stessa cosa va in
`conf.d/zz-mitigazione-cve-2026-109618.ini` con ricarica morbida di
php-fpm (USR2): si perde quando il container viene ricreato, e da lì vale il
`php.ini` dell'immagine. *Stato dell'applicazione ai container: vedi
sotto, aggiornato a ogni passo.*
- **Scelta dell'utente (21/10): in produzione solo la correzione di PHP.** I
due commit di sicurezza sono andati su `main` con un cherry-pick (`eb756ad`
e `1ddb973`, sopra `d3314a9`); gruppi di servizio e lavori dal 20 ottobre
restano sulla demo, da rilasciare un altro giorno. Ora `main` e `dev`
divergono: per portare `dev` in produzione servirà un merge (i cherry-pick
sono già in `dev`, conflitti attesi solo in HANDOFF).
- **Rilascio fatto il 21/10.** Su Docker Hub il tag `php:8.3.36-fpm` è
comparso alle 15:38 UTC ma vuoto: l'immagine per amd64 (il server) è
arrivata alle 16:41 UTC. Una build prima di quell'ora fallisce con "no
match for platform in manifest": per sapere se si può costruire va
controllata l'architettura, non solo il nome del tag. Poi, nell'ordine:
1. test della versione di produzione su 8.3.36: 524 PHP + 13 JS verdi;
2. backup della produzione (`prod-20261021-164333`: database, 78 MB di
`storage/app`, conteggi);
3. demo: tolta la variabile provvisoria `PHP_VERSION=8.3.35` (vedi sotto) e
ricostruita: PHP 8.3.36, così si è visto che senza variabile la build
prende la versione del Dockerfile;
4. `main` = `1ddb973`, deploy della produzione alle 16:48 UTC (uno solo:
l'autodeploy non è partito, lanciato a mano).
Verifica in produzione: PHP 8.3.36, `session.upload_progress.enabled` Off e
`session.use_strict_mode` On (dal `php.ini` dell'immagine), nessuna
migrazione in sospeso, conteggi identici al backup (274 territori, 1130
assegnazioni di cui 77 aperte, 111 proclamatori, 4 utenti, 1251 righe di
log, 35 migrazioni), login 200, container sani, nessun errore nei log.
- Il passaggio di consegne di quella sessione (`HANDOFF-chat.md`) sta nella
cartella del progetto ma non nel repository (escluso in `.git/info/exclude`):
contiene dettagli dell'infrastruttura.
**Demo costruita per qualche ora con PHP 8.3.35 (21/10, dalle 14:40 alle
16:44 UTC).** L'utente aveva chiesto il deploy della demo prima che uscisse
`php:8.3.36-fpm`: nelle variabili d'ambiente del compose della demo in
Dokploy era stata messa `PHP_VERSION=8.3.35`, che il `docker-compose.yml`
passa al Dockerfile. **Tolta alle 16:44 UTC** e demo ricostruita su 8.3.36;
nell'env della demo e della produzione non c'è più nessuna `PHP_VERSION`. Se
servisse di nuovo, va tolta subito dopo, altrimenti la demo resta indietro.
Prima della patch (21/10, 11:00 UTC circa) la mitigazione era stata
**applicata anche ai container in esecuzione** di demo e produzione, ancora
su 8.3.35 (`conf.d/zz-mitigazione-cve-2026-109618.ini`, php-fpm ricaricato).
Con la ricostruzione quei file sono spariti, come previsto: ora vale il
`php.ini` dell'immagine, con gli stessi valori.
### Password del sorvegliante dal pannello Utenti (21 ottobre 2026, solo su dev)
Richiesta dell'utente: poter reimpostare la password del sorvegliante dal
pannello di amministrazione. Prima si poteva solo dalla pagina del gruppo; in
*Utenti* la sua riga non aveva azioni. Ora ha "Nuova password temporanea" (con
conferma) e "Apri il gruppo"; la password compare una volta sola in cima
all'elenco, con l'email, fino a "Ho annotato la password". La logica è una
sola, `GruppiServizio::nuovaPasswordTemporanea()`, usata anche da
`GruppoShow`; rifiuta gli utenti che non sono sorveglianti (per loro c'è il
modulo di modifica). Test in `GruppiServizioTest`.
### Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev)
Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del
territorio, con un popup al clic del numero. Il numero diventa un pulsante
(stesso aspetto, `button.tm-assegnato-numero` in `app.css`) che apre una
finestra (`tm-modale`, stato Alpine `anteprima` sulla radice della vista) con
la miniatura e "Apri la piantina (PDF)". La miniatura passa da una rotta
nuova, `mio-gruppo.miniatura` (`TerritorioFileController::miniaturaGruppo`,
`Territorio::miniaturaGruppoUrl()`), con la stessa regola di
`mio-gruppo.pdf`: solo i territori del pacchetto del gruppo che l'utente
sorveglia. La rotta normale delle miniature resta chiusa al sorvegliante.
Senza miniatura né PDF il numero resta testo.
### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
@@ -121,7 +281,12 @@ qui sotto **com'è oggi**, in fondo la storia.
e dal profilo l'email non si cambia. Ruolo `sorvegliante`, con il solo
permesso `gruppo.gestisci`; non compare nella pagina *Utenti*, dove le
utenze legate a un proclamatore non si modificano né si eliminano.
"Nuova password temporanea" dalla pagina del gruppo.
"Nuova password temporanea" dalla pagina del gruppo o dalla riga del
sorvegliante nel pannello *Utenti* (stessa logica,
`GruppiServizio::nuovaPasswordTemporanea()`: password provvisoria, cambio
obbligato, "ricordami" azzerato, log `password_reimpostata`; le sessioni
aperte si chiudono perché cambia l'hash). In *Utenti* è l'unica azione su
quell'utenza, con "Apri il gruppo".
- **Cosa vede**: `LimitaSorvegliante` (gruppo `web`) lascia a chi ha **solo**
quel ruolo `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo:
`Rientra::autorizza`), profilo, cambio password e le azioni Livewire —
@@ -144,7 +309,8 @@ qui sotto **com'è oggi**, in fondo la storia.
(nell'indirizzo). Il sorvegliante assegna, manda il link (copia o
condividi: trait `Concerns\InviaLink` + partial `assegnazioni/_invio-link`,
gli stessi di *Assegna*), registra i rientri, apre la piantina, rimanda in
reparto quelli non dati.
reparto quelli non dati. Il numero del territorio apre l'anteprima (finestra
Alpine nella vista, miniatura e collegamento al PDF).
- **Assegnazione dal pacchetto**: un'**assegnazione normale**, con
`gruppo_servizio_id` e `gruppo_nome` ("via gruppo …" in registro e schede).
`GruppiServizio::assegna()` rilegge territorio e membro e ricontrolla il
@@ -1605,7 +1771,7 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
docker compose --profile test run --build --rm test
```
**Coperto oggi — 577 test PHP e 13 JavaScript, tutti verdi (20/10/2026).**
**Coperto oggi — 613 test PHP e 13 JavaScript, tutti verdi (21/10/2026).**
Senza Docker in locale si fanno girare sul server: vedi [Dove gira
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
@@ -1626,8 +1792,10 @@ davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
Arrivati dopo (ottobre 2026), uno per funzione: `GruppiServizioTest` (gruppi
di servizio, compresa la rotta vera delle azioni Livewire del sorvegliante,
priorità e filtri), `InvioLinkTest` (invio del link, pulsante Rientro, Assegna
dalla Home), `StoricoAssegnazioniTest`, `SceltaRicercaTest` e
priorità e filtri), `RichiesteTest` (modulo pubblico e anti-spam, nomi
simili, Home, richiesta evasa da Assegna e Rientra, pulizia, cartacei, S-13
per email e la regola dell'ultima domenica), `InvioLinkTest` (invio del link,
pulsante Rientro, Assegna dalla Home), `StoricoAssegnazioniTest`, `SceltaRicercaTest` e
`tests/js/scelta.test.mjs` (scelta con ricerca), `RitornoTest`,
`AssegnaPrestazioniTest` (peso della pagina Assegna e delle righe
dell'elenco), `AssegnazioneSovrapposizioneTest`, `CampagnaCalcoliTest`,

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,37 @@ 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*, una nota. Nella riconsegna il territorio
si sceglie con il **completamento automatico**: si scrive il numero o la
zona e compaiono i territori (attivi, o disattivati ma ancora fuori), con
numero e zona soltanto; si accetta solo un territorio dell'elenco. A parte
questo elenco il modulo non mostra nessun dato già presente: né nomi, né a
chi è assegnato un territorio o se lo è
- **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,
@@ -759,7 +803,10 @@ docker compose --profile dev up -d --build
"Password temporanea", non "Ripristino da backup"). L'email non si
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
dalla pagina del gruppo
temporanea dalla pagina del gruppo o dal pannello *Utenti* ("Nuova password
temporanea" sulla riga del sorvegliante): si vede una volta sola, va
cambiata al primo accesso, e le sessioni aperte con quella vecchia si
chiudono
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
**in ordine di priorità** come "Da assegnare" nella Home (prima i
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
@@ -774,7 +821,8 @@ docker compose --profile dev up -d --build
pacchetto è in ordine di priorità (prima i da dare, prioritari in testa,
poi quelli già dati) e si filtra per stato, zona, tipologia e "solo
prioritari"; sotto il nome del gruppo compaiono nome e cognome del
sorvegliante
sorvegliante. Toccando il numero di un territorio si apre l'**anteprima**
(la miniatura della piantina) con il collegamento al PDF
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
gruppo**, che non resta nello storico: i territori solo nel pacchetto
@@ -811,6 +859,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
@@ -851,13 +903,15 @@ 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`).
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è
un gruppo di servizio e sparisce sciogliendolo. Della sua utenza, in *Utenti*,
si può solo generare una nuova password temporanea (e aprire il gruppo). Chi ha solo quel ruolo è
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
dei territori del gruppo e al profilo; le sue azioni ricontrollano che
territorio e assegnazione siano del suo gruppo.
@@ -876,7 +930,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
@@ -885,6 +940,13 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
restituisce 404
- **Versione di PHP fissata alla patch** (`ARG PHP_VERSION` nel Dockerfile,
oggi 8.3.36 per CVE-2026-109618/109619): una build o prende quella versione
o fallisce, mai una base vecchia rimasta in cache. Va alzata a ogni rilascio
di sicurezza del ramo 8.3
- **Sessioni native di PHP chiuse**: `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1` in `php.ini` (l'applicazione usa le sessioni
di Laravel; è la mitigazione indicata per le stesse CVE)
### GDPR
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
@@ -902,6 +964,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
@@ -921,7 +991,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**
@@ -997,7 +1068,7 @@ sempre.
| Componente | Tecnologia |
|-------------------|--------------------------------------------|
| Framework | Laravel 11 |
| PHP | 8.3 (FPM) |
| PHP | 8.3 (FPM), versione fissata nel Dockerfile |
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
| Database | MariaDB 11 |
| Cache/Sessioni/Code| Redis 7 |

Binary file not shown.

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

@@ -31,8 +31,7 @@ class TerritorioFileController extends Controller
*/
public function pdfGruppo(Territorio $territorio): Response
{
$gruppo = auth()->user()->gruppoSorvegliato();
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
$this->delPacchetto($territorio);
return $this->pdf($territorio);
}
@@ -42,6 +41,21 @@ class TerritorioFileController extends Controller
return $this->servi($territorio->thumbnail_path, null, null);
}
/** La miniatura per l'anteprima in "Il mio gruppo", con la stessa regola del PDF. */
public function miniaturaGruppo(Territorio $territorio): Response
{
$this->delPacchetto($territorio);
return $this->miniatura($territorio);
}
/** Il territorio deve stare nel pacchetto del gruppo che l'utente sorveglia. */
private function delPacchetto(Territorio $territorio): void
{
$gruppo = auth()->user()->gruppoSorvegliato();
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
}
public function versione(TerritorioPdfVersione $versione): Response
{
$numero = $versione->territorio?->numero ?? 'senza-territorio';

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

@@ -157,11 +157,7 @@ class GruppoShow extends Component
return;
}
$password = Str::password(12, symbols: false);
$utente->forceFill(['password' => $password, 'password_change_required' => true, 'password_temporanea' => true])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
$this->passwordTemporanea = $password;
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
}
/**

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,137 @@
<?php
namespace App\Livewire\Richieste;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Services\RichiesteServizio;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
* mostra è l'elenco dei territori nella riconsegna, per il completamento
* automatico del numero: numero e zona, mai a chi è assegnato né se lo è.
* Nomi e altri dati non si vedono.
*
* 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',
function ($attributo, $valore, $errore) {
if ($this->tipo === Richiesta::RICONSEGNA && ! self::territori()->whereRaw('LOWER(numero) = ?', [Str::lower(trim((string) $valore))])->exists()) {
$errore("Scegli il territorio dall'elenco.");
}
}],
'note' => 'nullable|string|max:1000',
], [
'nome.required' => 'Scrivi nome e cognome.',
'nome.min' => 'Scrivi nome e cognome.',
'numero.required_if' => 'Scegli il 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;
}
/**
* I territori che si possono riconsegnare: quelli attivi e quelli ancora
* fuori anche se nel frattempo disattivati.
*/
private static function territori(): Builder
{
return Territorio::query()->where(fn ($q) => $q->where('attivo', true)
->orWhereHas('assegnazioni', fn ($a) => $a->whereNull('returned_at')));
}
/**
* Le proposte del campo con ricerca (<x-scelta>): solo numero e zona. Il
* valore è il numero, come lo salva la richiesta.
*/
private static function opzioniTerritori(): array
{
return self::territori()->with('zona')->get()
->sortBy('numero', SORT_NATURAL)
->map(fn (Territorio $t) => array_filter([
'valore' => (string) $t->numero,
'etichetta' => "N° {$t->numero}",
'dettaglio' => $t->zona?->nome,
'chiavi' => [(string) $t->numero, (string) $t->zona?->nome],
], fn ($v) => $v !== null))
->values()
->all();
}
public function render()
{
return view('livewire.richieste.modulo-richiesta', [
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
// Solo quando serve: chi chiede un territorio nuovo non vede l'elenco.
'opzioniTerritori' => $this->tipo === Richiesta::RICONSEGNA && ! $this->inviata ? self::opzioniTerritori() : [],
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
}
}

View File

@@ -3,8 +3,11 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\GruppoServizio;
use App\Models\User;
use App\Services\GruppiServizio;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Permission\Models\Role;
@@ -22,6 +25,17 @@ class UsersIndex extends Component
#[Locked]
public array $availableRoles = [];
/**
* Nuova password temporanea di un sorvegliante di gruppo, mostrata una
* volta sola (come nella pagina del gruppo). Locked: le scrive solo
* nuovaPasswordSorvegliante().
*/
#[Locked]
public ?int $passwordPerUtenteId = null;
#[Locked]
public ?string $passwordTemporanea = null;
public ?int $editingUserId = null;
public string $editName = '';
public string $editEmail = '';
@@ -238,10 +252,39 @@ class UsersIndex extends Component
$this->avvisa('Utente cancellato. I log sono stati preservati.');
}
/**
* Il sorvegliante ha perso la password: una temporanea nuova, da cambiare
* al primo accesso. È l'unica cosa che di quell'utenza si fa da qui; il
* resto passa dal gruppo (gestitaDaiGruppi).
*/
public function nuovaPasswordSorvegliante(int $userId): void
{
$this->authorize('settings.manage');
$user = User::query()->findOrFail($userId);
try {
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($user);
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->passwordPerUtenteId = $user->id;
}
public function chiudiPassword(): void
{
$this->reset(['passwordPerUtenteId', 'passwordTemporanea']);
}
public function render()
{
return view('livewire.settings.users-index', [
'users' => User::query()->with('roles')->orderBy('name')->get(),
// Per ogni sorvegliante il suo gruppo, per il collegamento.
'gruppiDeiSorveglianti' => GruppoServizio::pluck('id', 'sorvegliante_id'),
'utentePassword' => $this->passwordPerUtenteId ? User::find($this->passwordPerUtenteId) : null,
]);
}
}

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

@@ -328,4 +328,12 @@ class Territorio extends Model
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
/** La miniatura per il sorvegliante di gruppo ("Il mio gruppo"): solo del suo pacchetto. */
public function miniaturaGruppoUrl(): ?string
{
return $this->thumbnail_path
? route('mio-gruppo.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
}

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

@@ -80,6 +80,32 @@ class GruppiServizio
return [$gruppo, $password];
}
/**
* Una password temporanea nuova per il sorvegliante che l'ha persa: da
* cambiare al primo accesso ("Password temporanea"). Le sessioni aperte
* con quella vecchia si chiudono alla richiesta successiva
* (AuthenticateSession: è cambiato l'hash), il "ricordami" si azzera.
* Restituisce la password, da mostrare una volta sola.
*/
public function nuovaPasswordTemporanea(User $utente): string
{
if (! $utente->proclamatore_id || ! $utente->isSorvegliante()) {
throw new InvalidArgumentException("{$utente->name} non è il sorvegliante di un gruppo.");
}
$password = Str::password(12, symbols: false);
$utente->forceFill([
'password' => $password,
'password_change_required' => true,
'password_temporanea' => true,
'remember_token' => null,
])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
return $password;
}
/**
* L'email fittizia del sorvegliante, con cui entra: iniziale del nome e
* cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio
@@ -189,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);
@@ -212,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.";
@@ -233,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,155 @@
<?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)
// Prima il punteggio più alto, a pari punti in ordine alfabetico.
->sort(fn ($a, $b) => [$b[1], mb_strtolower($a[0]->nome_completo)] <=> [$a[1], mb_strtolower($b[0]->nome_completo)])
->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

@@ -18,6 +18,10 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped
env_file: .env
environment:
@@ -46,6 +50,10 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped
user: www-data
env_file: .env
@@ -89,6 +97,10 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped
user: www-data
env_file: .env
@@ -207,6 +219,10 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
# Senza questo si costruirebbe l'immagine di produzione, che essendo
# installata senza dipendenze di sviluppo non contiene phpunit.
target: test

View File

@@ -1,5 +1,21 @@
# syntax=docker/dockerfile:1
# Versione di PHP, fissata alla patch. Va dichiarata prima del primo FROM per
# poterla usare nel FROM dello stage `base`.
#
# Fissata e non "8.3": quel tag si sposta, ma Docker tiene in cache la base già
# scaricata, e una ricostruzione senza --pull (anche su Dokploy) poteva uscire
# con un PHP vecchio senza che nessuno se ne accorgesse. Con la patch scritta
# qui, una build o prende questa versione o fallisce.
#
# 8.3.36: corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di codice da
# remoto nel core di PHP, CVSS 9.2, rilascio del 21/10/2026). Non scendere
# sotto. Aggiornala a ogni rilascio di sicurezza del ramo 8.3.
#
# Solo per provare in locale prima che l'immagine corretta sia su Docker Hub:
# PHP_VERSION=8.3.35 docker compose ... (mai per un deploy).
ARG PHP_VERSION=8.3.36
# =============================================================================
# Stage 1 — PHP dependencies (no dev, production autoloader)
# =============================================================================
@@ -65,7 +81,7 @@ RUN npm run build
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
# non puo' passare grazie a un ambiente diverso da quello vero.
# =============================================================================
FROM php:8.3-fpm AS base
FROM php:${PHP_VERSION}-fpm AS base
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
# would need its own copy of public/ plus the storage volume just to serve
@@ -76,6 +92,11 @@ FROM php:8.3-fpm AS base
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
# purging means pinning package names that break whenever the php base image
# moves to a new Debian. A few tens of MB is worth not having that breakage.
#
# phpredis si scarica da GitHub, versione fissata, e non con `pecl install`:
# il 21/10/2026, giorno della patch di sicurezza di PHP, pecl.php.net
# rispondeva 504 e la build falliva proprio quando bisognava aggiornare.
ARG PHPREDIS_VERSION=6.3.0
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
netcat-openbsd \
@@ -101,8 +122,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
intl \
zip \
opcache \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& mkdir -p /usr/src/php/ext/redis \
&& curl -fsSL --retry 5 --retry-delay 5 \
"https://github.com/phpredis/phpredis/archive/refs/tags/${PHPREDIS_VERSION}.tar.gz" \
| tar xz --strip-components=1 -C /usr/src/php/ext/redis \
&& echo redis >> /usr/src/php-available-exts \
&& docker-php-ext-install -j"$(nproc)" redis \
&& docker-php-source delete \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -8,6 +8,16 @@ memory_limit = 256M
max_execution_time = 120
max_input_vars = 3000
[Session]
; Mitigazione di CVE-2026-109618/109619 (RCE nel core di PHP, 21/10/2026),
; come indicato da Sansec in attesa della patch, e da tenere anche dopo:
; l'avanzamento degli upload nelle sessioni native di PHP non serve
; all'applicazione (Laravel ha le sue sessioni, in Redis, e Livewire non lo
; usa) ed è il punto da cui passano questi attacchi. Lo strict mode vale solo
; per le sessioni native, che qui non si usano: costa niente.
session.upload_progress.enabled = 0
session.use_strict_mode = 1
[opcache]
opcache.enable = 1
opcache.memory_consumption = 128

View File

@@ -1805,10 +1805,29 @@ 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;
font-family: inherit; line-height: inherit;
}
button.tm-assegnato-numero:focus-visible {
outline: 2px solid var(--tm-accento); outline-offset: 2px; border-radius: 2px;
}
.tm-assegnato-zona {
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

@@ -42,10 +42,13 @@
il metodo condiviso copia(): sono ~80 righe, ogni byte si
moltiplica. I pulsanti restano <x-btn> (StileUnificatoTest).
"Rientro" porta alla registrazione del rientro e torna qui
(data-ritorno); indirizzo relativo e niente a capo: pesa meno. --}}
(data-ritorno); indirizzo relativo e niente a capo: pesa meno.
L'etichetta "cartaceo" è un'espressione e non un blocco if:
Livewire circonda ogni if con due commenti segnaposto (58 byte
a riga, anche quando è falso). Il testo è fisso, niente dati. --}}
<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{!! $a->cartaceo ? ' <span class="tm-cartaceo">cartaceo</span>' : '' !!}</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

@@ -1,8 +1,11 @@
<div>
<div x-data="{ anteprima: null }">
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
Pensata prima per il telefono: schede una sotto l'altra, pulsanti
grandi. Copia, Condividi e ricerca vengono da elencoCercabile
(resources/js/app.js), l'invio del link dal partial _invio-link. --}}
(resources/js/app.js), l'invio del link dal partial _invio-link.
Il numero del territorio apre l'anteprima (stato Alpine `anteprima`,
finestra in fondo alla vista): miniatura e PDF dalle rotte
mio-gruppo.*, che rispondono solo per il pacchetto del gruppo. --}}
@if(! $gruppo)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600">
Non sei il sorvegliante di un gruppo di servizio attivo.
@@ -81,8 +84,12 @@
@endphp
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa">
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @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>
<span>@if($t->thumbnail_path || $t->pdf_path)<button type="button" class="tm-assegnato-numero" title="Anteprima del territorio"
data-titolo="Territorio N° {{ $t->numero }}{{ $t->zona?->nome ? ' — ' . $t->zona->nome : '' }}"
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 @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
@@ -119,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>
@@ -156,5 +167,27 @@
@endforeach
</ul>
</section>
{{-- Anteprima del territorio: si apre dal numero, si chiude con la X,
con Esc o toccando fuori. --}}
<div class="tm-modale-sfondo" x-show="anteprima" x-cloak x-transition.opacity
x-on:click.self="anteprima = null" x-on:keydown.escape.window="anteprima = null">
<div class="tm-modale" role="dialog" aria-modal="true" aria-labelledby="anteprima-titolo">
<header class="tm-modale-testata">
<h2 id="anteprima-titolo" x-text="anteprima?.titolo"></h2>
<button type="button" class="tm-editor-chiudi" x-on:click="anteprima = null" aria-label="Chiudi l'anteprima">&times;</button>
</header>
<div class="tm-modale-corpo">
<template x-if="anteprima?.miniatura">
<img x-bind:src="anteprima.miniatura" x-bind:alt="'Anteprima: ' + anteprima.titolo"
class="mx-auto max-w-full rounded-lg border border-gray-200 bg-gray-50">
</template>
<p x-show="! anteprima?.miniatura" class="text-sm text-gray-500">Per questo territorio non c'è ancora un'anteprima.</p>
</div>
<footer class="tm-modale-piede" x-show="anteprima?.pdf">
<a x-bind:href="anteprima?.pdf" target="_blank" rel="noopener" class="tm-collegamento">Apri la piantina (PDF)</a>
</footer>
</div>
</div>
@endif
</div>

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 dati personali: chi annuncia una riconsegna sceglie il territorio da un elenco con solo numero e zona, senza sapere a chi è assegnato né se lo è. 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, tranne l'elenco dei territori (numero e zona) per scegliere
quello da riconsegnare. --}}
<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">Territorio che riconsegni *</label>
<x-scelta id="numero" wire:model="numero" :opzioni="$opzioniTerritori" segnaposto="Scrivi il numero o la zona" class="mt-1" />
@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

@@ -57,6 +57,19 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
@if($passwordTemporanea && $utentePassword)
<div class="mb-4 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p>Nuova password temporanea per <strong>{{ $utentePassword->name }}</strong>, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $utentePassword->email }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all text-base">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3">
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
</div>
</div>
@endif
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
@@ -130,10 +143,16 @@
</td>
<td class="px-4 py-2 cell-actions">
@if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}}
<span class="text-xs text-gray-500">
Sorvegliante: si gestisce da Gruppi di servizio
</span>
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo;
da qui solo la password, se l'ha persa. --}}
<div class="flex flex-wrap items-center gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="nuovaPasswordSorvegliante({{ $user->id }})"
wire:confirm="Generare una nuova password temporanea per {{ $user->name }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
@if(isset($gruppiDeiSorveglianti[$user->proclamatore_id]))
<x-btn data-ritorno variante="ghost" dimensione="sm" :href="route('gruppi.show', $gruppiDeiSorveglianti[$user->proclamatore_id])">Apri il gruppo</x-btn>
@endif
</div>
@else
<div class="flex gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>

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().
@@ -178,6 +186,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('mio-gruppo', MioGruppo::class)->name('mio-gruppo');
Route::get('mio-gruppo/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdfGruppo'])
->name('mio-gruppo.pdf');
Route::get('mio-gruppo/territori/{territorio}/miniatura', [TerritorioFileController::class, 'miniaturaGruppo'])
->name('mio-gruppo.miniatura');
});
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i

View File

@@ -155,6 +155,33 @@ class GruppiServizioTest extends TestCase
$this->assertNotNull($sorvegliante->fresh());
}
public function test_dal_pannello_utenti_si_da_al_sorvegliante_una_nuova_password_temporanea(): void
{
['sorvegliante' => $s, 'gruppo' => $gruppo] = $this->gruppo();
$pagina = Livewire::test(UsersIndex::class)
->assertSee('Nuova password temporanea')
->assertSee(route('gruppi.show', $gruppo), false)
->call('nuovaPasswordSorvegliante', $s->id);
// Si vede una volta, ed è quella vera: temporanea, da cambiare.
$password = $pagina->get('passwordTemporanea');
$this->assertNotEmpty($password);
$pagina->assertSee($password)->assertSee('mrossi@termanager.it');
$s->refresh();
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $s->password));
$this->assertTrue($s->password_change_required);
$this->assertTrue($s->password_temporanea);
$this->assertSame(1, \Spatie\Activitylog\Models\Activity::where('description', 'password_reimpostata')->count());
$pagina->call('chiudiPassword')->assertDontSee($password);
// Solo per i sorveglianti: gli altri utenti hanno il loro modulo.
$pagina->call('nuovaPasswordSorvegliante', $this->admin->id)
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore');
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('password-di-prova', $this->admin->fresh()->password));
}
// ─── Pacchetto ──────────────────────────────────────────────
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void
@@ -279,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
@@ -352,6 +396,29 @@ class GruppiServizioTest extends TestCase
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
}
public function test_il_sorvegliante_apre_l_anteprima_dal_numero_del_territorio(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
$fuori = $this->territorio('88');
foreach ([$territori[0], $fuori] as $t) {
Storage::disk('public')->put("territori-thumbnails/t{$t->numero}.png", 'PNG');
$t->forceFill(['thumbnail_path' => "territori-thumbnails/t{$t->numero}.png"])->save();
}
$dodici = $territori[0]->fresh();
$this->flushSession();
// Il numero è un pulsante che porta miniatura e PDF del pacchetto.
$this->actingAs($s)->get(route('mio-gruppo'))->assertOk()
->assertSee('data-miniatura="' . $dodici->miniaturaGruppoUrl() . '"', false)
->assertSee('data-pdf="' . route('mio-gruppo.pdf', $dodici) . '"', false)
->assertSee('Apri la piantina (PDF)');
// La miniatura si apre solo per il proprio pacchetto, e solo da qui.
$this->actingAs($s)->get($dodici->miniaturaGruppoUrl())->assertOk();
$this->actingAs($s)->get(route('mio-gruppo.miniatura', $fuori))->assertForbidden();
$this->actingAs($s)->get($dodici->miniaturaUrl())->assertForbidden();
}
public function test_la_piantina_del_pacchetto_si_apre_solo_per_il_proprio_gruppo(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
@@ -733,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,428 @@
<?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\Models\Zona;
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,
'created_by' => $this->admin->id,
'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();
$this->territorio('12');
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_nella_riconsegna_il_territorio_si_sceglie_dall_elenco(): void
{
$zona = Zona::create(['nome' => 'Centro storico']);
Territorio::create(['numero' => '12', 'zona_id' => $zona->id, 'attivo' => true]);
Territorio::create(['numero' => '3B', 'attivo' => true]);
$fuori = Territorio::create(['numero' => '40', 'attivo' => true]);
$this->assegna($fuori, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$fuori->update(['attivo' => false]);
Territorio::create(['numero' => '77', 'attivo' => false]);
auth()->logout();
// L'elenco c'è solo nella riconsegna: numero e zona, nient'altro.
$modulo = Livewire::test(ModuloRichiesta::class)
->assertViewHas('opzioniTerritori', [])
->set('tipo', Richiesta::RICONSEGNA)
->assertSee('Centro storico')
->assertDontSee('Verdi')
->assertDontSee('assegnat');
$this->assertSame(['3B', '12', '40'], array_column($modulo->viewData('opzioniTerritori'), 'valore'),
'In ordine di numero; c\'è anche quello disattivato ma ancora fuori, non quello disattivato e rientrato');
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '99'])->assertHasErrors('numero');
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '77'])->assertHasErrors('numero');
$this->assertSame(0, Richiesta::count());
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '3b'])->assertHasNoErrors();
$this->assertSame('3b', Richiesta::sole()->numero_territorio);
}
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>');
// Cambio di utente nello stesso test: la sessione dell'admin va
// svuotata, o la protezione della sessione lo manda al login (302).
$this->flushSession();
$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');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
/**
* L'immagine deve partire da una versione di PHP fissata alla patch, e mai
* sotto quella che corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di
* codice da remoto nel core di PHP, 21/10/2026). Con un tag che si sposta
* ("8.3-fpm") la cache di Docker poteva riusare una base vulnerabile.
*/
class VersionePhpTest extends TestCase
{
private const MINIMA = '8.3.36';
private function dockerfile(): string
{
return (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/Dockerfile');
}
public function test_la_versione_di_php_e_fissata_e_corretta(): void
{
// \s* prima di $: nella copia di lavoro su Windows il file ha CRLF.
$this->assertMatchesRegularExpression('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile());
preg_match('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile(), $trovata);
$this->assertTrue(
version_compare($trovata[1], self::MINIMA, '>='),
"PHP {$trovata[1]} è vulnerabile a CVE-2026-109618/109619: serve almeno " . self::MINIMA . '.',
);
}
public function test_la_mitigazione_delle_sessioni_resta_nel_php_ini(): void
{
// Indicata da Sansec per CVE-2026-109618/109619; all'applicazione non
// serve, quindi resta anche dopo la patch.
$ini = (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/php.ini');
$this->assertMatchesRegularExpression('/^session\.upload_progress\.enabled\s*=\s*0\s*$/m', $ini);
$this->assertMatchesRegularExpression('/^session\.use_strict_mode\s*=\s*1\s*$/m', $ini);
}
public function test_l_immagine_usa_la_versione_fissata_e_non_un_tag_mobile(): void
{
preg_match_all('/^FROM php:(\S+)/m', $this->dockerfile(), $basi);
$this->assertNotEmpty($basi[1]);
foreach ($basi[1] as $tag) {
$this->assertStringStartsWith('${PHP_VERSION}', $tag, "FROM php:{$tag}: usa ARG PHP_VERSION, non un tag che si sposta.");
}
}
}