Compare commits
14 Commits
5fb42b05c7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635982bf90 | ||
|
|
726af6d5c3 | ||
|
|
d198ad0b54 | ||
|
|
2e1da51080 | ||
|
|
5b4903d1cb | ||
|
|
1da31ce600 | ||
|
|
7904c96db8 | ||
|
|
5e40f0fe74 | ||
|
|
18fe914da5 | ||
|
|
2fb9838a0e | ||
|
|
9e473cd7fd | ||
|
|
8b6bc0796a | ||
|
|
8ce3d6d10d | ||
| db5772d7f5 |
@@ -30,6 +30,8 @@ public/build
|
||||
public/storage
|
||||
public/hot
|
||||
|
||||
_ARCHIVE
|
||||
|
||||
db_data
|
||||
redis_data
|
||||
|
||||
|
||||
202
HANDOFF.md
202
HANDOFF.md
@@ -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`,
|
||||
|
||||
91
README.md
91
README.md
@@ -3,7 +3,9 @@
|
||||
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
|
||||
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
|
||||
gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
|
||||
territori) e registro storico completo.
|
||||
territori), un modulo pubblico con cui i proclamatori chiedono un territorio o
|
||||
ne annunciano la riconsegna, l'elenco delle cartoline cartacee fuori, il
|
||||
modulo S-13 per email ogni mese e registro storico completo.
|
||||
|
||||
I dati personali dei proclamatori sono **cifrati nel database**, e ogni
|
||||
operazione è tracciata in un **audit log** consultabile.
|
||||
@@ -50,7 +52,7 @@ eseguito né `composer install` né `npm build` all'avvio.
|
||||
|----------------|------------------------------------------------|---------|
|
||||
| `app` | nginx + php-fpm — l'applicazione | via Traefik |
|
||||
| `queue-worker` | Job in coda (import PDF massivi) | no |
|
||||
| `scheduler` | Task pianificati (pulizia audit log) | no |
|
||||
| `scheduler` | Task pianificati (pulizie, S-13 mensile) | no |
|
||||
| `mariadb` | Database | no |
|
||||
| `redis` | Cache, sessioni, code | no |
|
||||
|
||||
@@ -630,12 +632,21 @@ docker exec -it <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 |
|
||||
|
||||
BIN
_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx
Normal file
BIN
_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx
Normal file
Binary file not shown.
75
app/Console/Commands/InviaModuloS13.php
Normal file
75
app/Console/Commands/InviaModuloS13.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/PuliziaRichieste.php
Normal file
34
app/Console/Commands/PuliziaRichieste.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Livewire/Assegnazioni/Cartacei.php
Normal file
25
app/Livewire/Assegnazioni/Cartacei.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
137
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
137
app/Livewire/Richieste/ModuloRichiesta.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
|
||||
50
app/Mail/ModuloS13Mail.php
Normal file
50
app/Mail/ModuloS13Mail.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
112
app/Models/Richiesta.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Notifications/RichiestaRicevuta.php
Normal file
42
app/Notifications/RichiestaRicevuta.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
155
app/Services/RichiesteServizio.php
Normal file
155
app/Services/RichiesteServizio.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
47
resources/views/livewire/assegnazioni/cartacei.blade.php
Normal file
47
resources/views/livewire/assegnazioni/cartacei.blade.php
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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 --}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
7
resources/views/mail/modulo-s13.blade.php
Normal file
7
resources/views/mail/modulo-s13.blade.php
Normal 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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
428
tests/Feature/RichiesteTest.php
Normal file
428
tests/Feature/RichiesteTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
54
tests/Unit/VersionePhpTest.php
Normal file
54
tests/Unit/VersionePhpTest.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user