Compare commits

...

72 Commits

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:35:31 +02:00
Francesco Picone
7904c96db8 ++ Richieste dal modulo pubblico, territori cartacei, S-13 mensile per email
- /richiesta: modulo pubblico (nuovo territorio o riconsegna) con campo
  trappola, tempo minimo e limite orario; nome e note cifrati, avviso per
  email a chi ha richieste.gestisci (amministratore, assistente)
- Home: riquadro Richieste con i proclamatori dal nome simile; porta su
  Assegna/Rientra già esistenti, che chiudono la richiesta; Scarta
- Cartaceo sull'assegnazione (Assegna e Il mio gruppo), etichetta
  nell'elenco degli assegnati, pagina Cartacei, passa per l'XML
- registro:invia-s13 agli amministratori l'ultima domenica del mese;
  richieste:pulizia dopo 90 giorni dalla gestione
- Privacy, README e HANDOFF aggiornati; test in RichiesteTest

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:30:05 +02:00
Francesco Picone
5fb42b05c7 ++ HANDOFF e README allineati all'ultima versione delle funzionalita'
HANDOFF: stato attuale vero (produzione dal 19/10, cosa c'e' solo su
dev e come rilasciarlo), sezione dei gruppi di servizio descritta com'e'
oggi con la storia delle decisioni in coda, lavori recenti in ordine,
cronologia di luglio-agosto in "Da dove veniamo", ambienti con
autodeploy che non parte e test sul server prima del push, copertura dei
test (577 + 13), frasi superate su WhatsApp/email/contatti corrette.
README: impostazioni reali della pagina, due liste della Home, soglia di
priorita' in giorni e ordine di priorita', deploy a mano, struttura del
progetto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:48:18 +02:00
Francesco Picone
7d479a2c34 ++ Assegna aperta da "Assegna ->" della Home: senza l'elenco dei territori assegnati
La richiesta era per la pagina Assegna, non per la scheda del territorio:
annullata la modifica di f1f3408 (storico nascosto nella scheda) e ora il
link "Assegna ->" della Home porta ?dalla_home=1, con cui Assegna non
mostra ne' carica l'elenco "Territori Assegnati".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:28:56 +02:00
Francesco Picone
f1f340803a ++ scheda territorio: aperta dalla Home non mostra lo storico delle assegnazioni
I link della Home portano ?dalla_home=1; la scheda nasconde lo storico
(e non lo carica), con "Mostra lo storico delle assegnazioni" per aprirlo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:52:00 +02:00
Francesco Picone
6f88c65d73 ++ storico assegnazioni: una tabella sola con le colonne in linea, dal piu' recente
Nelle schede di territorio e proclamatore ogni anno teocratico era una
tabella a se', con le colonne dimensionate sul proprio contenuto. Ora una
tabella con un'intestazione, colonne fisse, l'anno come riga di
separazione (tm-riga-gruppo, titolo anche su telefono); ordine esplicito
dal piu' recente. Nella scheda del proclamatore Campagna "Si'/No" e badge
"via gruppo".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:43:15 +02:00
Francesco Picone
ab7d26ae41 ++ test: il pulsante Rientro ha l'indirizzo relativo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:34:24 +02:00
Francesco Picone
cd3569a327 ++ elenco assegnati: il pulsante Rientro resta nel peso massimo della riga
Indirizzo relativo e niente a capo nel ciclo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:33:33 +02:00
Francesco Picone
eda3c3f989 ++ elenco dei territori assegnati: pulsante Rientro su ogni riga
Porta alla registrazione del rientro e torna all'elenco (data-ritorno);
solo con il permesso territori.return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:32:07 +02:00
Francesco Picone
699e2ef173 ++ test: numeri dei territori come stringhe nel test della priorita'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:59 +02:00
Francesco Picone
0ce5bd8c89 ++ test: soglia dei prioritari fissata nei test della priorita' del pacchetto
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:05 +02:00
Francesco Picone
ce98165667 ++ gruppi: le azioni del sorvegliante non finiscono piu' in 403; priorita' e filtri nel pacchetto; nome del sorvegliante
- LimitaSorvegliante: la rotta delle azioni di Livewire 3.7 si chiama
  "default.livewire.update"; con 'livewire.*' ogni clic era un 403.
- Territorio::ordinaPerPriorita(): la regola di "Da assegnare" della Home in
  un metodo solo, usato dalla Home, dall'aggiunta al pacchetto e da "Il mio
  gruppo".
- Aggiunta al pacchetto: ricerca, zona, tipologia, solo prioritari,
  "Seleziona tutti quelli mostrati", badge e giorni di fermo.
- Il mio gruppo: da dare in ordine di priorita' poi i gia' dati; filtri
  stato, zona, tipologia, solo prioritari; sotto il titolo nome e cognome
  del sorvegliante.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:58:53 +02:00
Francesco Picone
d2d6b1bdce ++ cambio password obbligato: al primo accesso del sorvegliante dice "Password temporanea", non "Ripristino da backup"
users.password_temporanea distingue la password provvisoria data
dall'amministratore (creazione del gruppo, "Nuova password temporanea")
dal ripristino da backup. Migrazione 000040; si spegne a password
cambiata, il ripristino e l'import XML la spengono per tutti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:52:35 +02:00
Francesco Picone
ec2e7bd7e9 ++ gruppi: email fittizia iniziale+cognome per il sorvegliante; sciolto il gruppo l'utenza si elimina
- L'utenza del sorvegliante ha un'email fittizia creata dall'app:
  iniziale del nome e cognome @termanager.it (ffontana@termanager.it),
  come gli altri utenti. Via il nome utente: login di nuovo per email.
- Sciolto il gruppo l'utenza si elimina, il sorvegliante torna un
  proclamatore come gli altri: assegnazioni a chi scioglie, registro a nome
  "Sorvegliante del gruppo ...". Via le utenze disattivate.
- User::eliminaConservandoStorico(): l'eliminazione della pagina Utenti in
  un metodo solo, usato anche dallo scioglimento.
- Migrazione 000030: elimina le utenze gia' disattivate, toglie
  users.username e users.disattivato_at, email di nuovo obbligatoria.
- XML, informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:09:15 +02:00
Francesco Picone
69250ecb8c ++ HANDOFF: confermata la traccia "via gruppo" sulle assegnazioni; produzione in attesa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:56:46 +02:00
Francesco Picone
aaffeb7de7 ++ gruppi: il gruppo sciolto si elimina; il sorvegliante entra con un nome utente dal nome del gruppo
- Sciolto, il gruppo non resta nello storico: si elimina (via sciolto_at;
  la migrazione elimina anche quelli gia' sciolti). I territori solo nel
  pacchetto tornano in reparto; quelli gia' dati restano ai membri, e
  l'assegnazione conserva il nome del gruppo (assegnazioni.gruppo_nome).
- Creando il gruppo non si chiede l'email: users.username dal nome del
  gruppo ("Gruppo Zisa" -> gruppo.zisa), users.email facoltativa. Login con
  "Email o nome utente". Nome del gruppo unico.
- Sciolto il gruppo il nome utente si libera (l'utenza disattivata prende
  ex.sorvegliante.<id>). XML: nome utente e gruppo_nome.
- Profilo, testata, cambio password e Utenti mostrano il nome utente dove
  manca l'email. Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:45:00 +02:00
Francesco Picone
a93539242a ++ gruppi: chi fa parte di un gruppo di servizio e' segnalato
Badge "Gruppo ..." / "Sorvegliante del gruppo ..." nell'elenco dei
proclamatori (con il filtro "In un gruppo di servizio") e in testa alla
scheda; nella scelta del proclamatore (Assegna, Registro) la nota
"gruppo ..." accanto al nome, che resta sceglibile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:07 +02:00
Francesco Picone
7a54c15852 ++ test: profilo dell'amministratore in una sessione nuova
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:56:23 +02:00
Francesco Picone
4d9e445cba ++ gruppi: sorvegliante non piu' attivo scioglie il gruppo; dettagli del gruppo nella scheda e nel profilo
- Proclamatore::booted: reso non attivo, spostato nel cestino o
  anonimizzato, il sorvegliante scioglie il gruppo (territori non dati in
  reparto, membri liberi, utenza disattivata); il motivo va nel registro.
  L'elenco chiede conferma, la modifica avvisa sotto "Attivo". Un membro
  nel cestino esce dal gruppo.
- Scheda del proclamatore: gruppo guidato con membri, pacchetto, da dare,
  assegnazioni dal gruppo, utenza e ultimo accesso; oppure gruppo di cui
  e' membro; gruppi sciolti guidati in passato.
- Profilo del sorvegliante: il suo gruppo con gli stessi numeri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:48:37 +02:00
Francesco Picone
9999a3b031 ++ gruppi: l'assegnazione dal pacchetto rilegge territorio e membro, e ricontrolla il pacchetto sotto lock
HANDOFF: trappola di Blade sul @php in linea, test sul server prima del push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:00:45 +02:00
Francesco Picone
93bc9cf2fe ++ gruppi: vista del sorvegliante compilabile, azzeramento completo, test sistemati
Nella vista Il mio gruppo un @php(...) in linea prima di un blocco
@php ... @endphp mandava in errore il compilatore di Blade. L'azzeramento
dell'applicazione ora cancella anche i gruppi di servizio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:59:31 +02:00
Francesco Picone
6489fb627d ++ gruppo: i pulsanti per aggiungere territori e membri non restano disabilitati
Il conteggio delle spunte arrivava al server solo al clic: il pulsante
restava disabilitato. Ora e' sempre attivo e senza spunte avvisa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:54 +02:00
Francesco Picone
f48d0e96c0 ++ gruppi di servizio: utenza dell'ex sorvegliante riattivabile, cestino e anonimizzazione, registro senza nomi
Un ex sorvegliante puo' guidare un gruppo nuovo: si riattiva la sua
utenza. Il sorvegliante di un gruppo attivo non si cestina ne' si
anonimizza; anonimizzando un ex sorvegliante si anonimizza anche
l'utenza. Nel registro delle attivita' nome ed email dell'utenza del
sorvegliante diventano "proclamatore #id". La voce "Il mio gruppo"
dipende dal ruolo: l'amministratore passa ogni @can.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:55:38 +02:00
Francesco Picone
96084499f5 ++ gruppi di servizio: sorvegliante con utenza limitata, pacchetto di territori, assegnazione ai membri
Il gruppo ha un sorvegliante (proclamatore con utenza: email e password
temporanea, senza conferma dell'email) e dei membri; l'amministratore
mette in blocco dei territori nel pacchetto del gruppo. Il pacchetto non
e' un'assegnazione e non tocca le statistiche; quando il sorvegliante da'
un territorio a un membro nasce un'assegnazione normale con
gruppo_servizio_id. Al rientro il territorio torna in reparto. Sciogliere
il gruppo libera pacchetto e membri e disattiva l'utenza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:51:37 +02:00
Francesco Picone
5d0ae06eca ++ Update gitignore 2026-10-19 17:11:30 +02:00
Francesco Picone
d3314a9d17 ++ HANDOFF: rilascio in produzione dei proclamatori senza contatti
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:07:47 +02:00
Francesco Picone
7384a4b39d ++ proclamatori senza email e telefono; il link si copia o si condivide
- Migrazione: tolte le colonne email e telefono dei proclamatori (vuote
  in produzione). Tolti da form, scheda, elenco, XML (un file vecchio si
  importa ignorandoli).
- Invio del link: solo "Copia link" e "Condividi…" (menu di condivisione
  del telefono, navigator.share): il destinatario lo sceglie chi usa
  l'app. Via WhatsApp diretto, canale email, notifica LinkTerritorio,
  componente x-whatsapp.
- Elenco assegnati: "Condividi" al posto di WhatsApp; a condivisione
  fatta il link risulta inviato.
- Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:23 +02:00
Francesco Picone
3000ca9939 ++ HANDOFF: cookie di sessione secure in produzione, nota sull'autodeploy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:19:07 +02:00
Francesco Picone
3451663cfb ++ HANDOFF: rilascio in produzione del 19 ottobre 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:14:43 +02:00
Francesco Picone
e4549f8ff8 ++ tolto S-12_I.pdf dalla radice (copia non usata di resources/pdf/S-12_I.pdf)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:10:16 +02:00
Francesco Picone
d8c519542b Merge di main in dev: allineamento prima del passaggio in produzione
Da main arrivano S-12_I.pdf e una riga di .gitignore. Nei 5 conflitti
(README, CampagnaShow, Registro, Assegnazione, vista campagna) tenuta la
versione di dev, che contiene già le correzioni sulle campagne fatte su
main. HANDOFF: analisi d'impatto e procedura per prod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:07:19 +02:00
Francesco Picone
034329471b ++ informativa privacy aggiornata, valori dalle impostazioni
- Dati trattati attuali: email e telefono cifrati, link alla piantina,
  stato dell'invio, conferma email, log senza nomi.
- Valori letti al momento: congregazione, conservazione del log, scadenza
  del link, download del PDF, server di posta, cifratura, durata della
  sessione, indirizzo/HTTPS, ruoli con i permessi veri, conteggi dei dati.
- Servizi esterni: WhatsApp, posta, OpenStreetMap.
- Dichiarato che backup ed export XML contengono i dati in chiaro.
README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:58:35 +02:00
Francesco Picone
840fbe4c16 ++ elenco assegnati: pulsanti di nuovo con <x-btn>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:53:43 +02:00
Francesco Picone
a10d06aa6c ++ elenco assegnati: righe compatte (classi brevi, copia condivisa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:51:57 +02:00
Francesco Picone
81a8f2ba33 ++ pagina Assegna più leggera; "Vedi tutti" in testata nella Home
Assegna pesava 387 KB e Livewire li rimandava a ogni interazione.
- Elenco dei territori assegnati in un componente lazy a sé
  (ElencoAssegnati): il form compare subito e non lo ridisegna.
- Ricerca nell'elenco e anteprima della miniatura nel browser: nessuna
  richiesta al server mentre si scrive o si sceglie il territorio.
- Icone SVG come simboli richiamati con <use>.
- Assegnazione::giorni: cast a int (Carbon 3 restituisce un float).

Home: "Vedi tutti" diventa un pulsante nella testata dei riquadri.
README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:49:08 +02:00
Francesco Picone
fda428f242 ++ scelta di territori e proclamatori con ricerca
Al posto delle tendine (Assegna, Registro, editor delle piantine) un
campo in cui si scrive e compaiono le proposte più vicine: uguale, inizia
con, contiene, errore di battitura ("forse cercavi"); parole in qualsiasi
ordine, senza badare a maiuscole e accenti. Campo vuoto: la tendina
completa. Fatto anche per il telefono (16px, voci da 44px, scelta al tocco).

- Logica pura in resources/js/scelta.js con test node (tests/js).
- Opzioni dal server: Territorio/Proclamatore::opzioneScelta(); i
  territori già assegnati si trovano ("già assegnato a …") ma non si
  scelgono. Nomi cifrati ordinati e cercati decifrati, mai in SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:35:56 +02:00
Francesco Picone
2873424c3c ++ Update gitignore 2026-10-19 15:25:56 +02:00
Francesco Picone
bc465f53f5 ++ dopo un'operazione si torna alla pagina di partenza, con i suoi filtri
- Gli elenchi tengono ricerca, filtri e ordinamento nell'indirizzo
  (#[Url]); la pagina c'era già.
- I link verso schede e operazioni (data-ritorno) portano ?da= con la
  pagina di partenza, o il da già ricevuto: si risale all'origine
  (elenco -> scheda -> Rientra -> elenco filtrato).
- Ritorno::valida accetta solo percorsi interni: niente rimandi esterni.
- Trait RicordaRitorno: redirect dopo il salvataggio, Annulla e
  "Torna indietro" usano la pagina di partenza. Senza da, tutto come prima.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:04:42 +02:00
Francesco Picone
0c530b65b3 ++ fix test: flash subito prima del redirect, apostrofo nel testo statico
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:39:56 +02:00
Francesco Picone
f8539b58bd ++ invio del link dopo l'assegnazione; nuovo territorio dal designer
- Dopo l'assegnazione si apre una finestra con i canali (copia link
  sempre; WhatsApp ed email solo se il proclamatore ha numero /
  indirizzo, l'email solo con la posta configurata) e la domanda "Hai
  inviato il link?". Si va avanti solo dopo la risposta.
- L'email la manda l'app con notifyNow, mai in coda: l'indirizzo è
  cifrato nel database e non deve finire in chiaro nei job.
- Procedura interrotta o "non ancora": assegnazioni.link_da_inviare
  resta vero e "Link ancora da inviare" compare nell'elenco Assegna e
  nella scheda territorio, con "Invia link" per riprendere.
- Colonne link_da_inviare e link_canale (anche nell'XML); log con
  canale e proclamatore_id, senza dati personali.
- "Nuovo territorio" apre il designer delle piantine già in disegno, con
  la prima piantina pronta a creare il territorio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:36:44 +02:00
Francesco Picone
bde7a4a8a8 ++ update gitignore 2026-10-19 12:33:20 +02:00
Francesco Picone
f8911afe64 Revert "++ amministratori esenti dalla conferma email"
Annullata su richiesta: anche gli amministratori devono confermare
l'email. Torna valido il comportamento di fc8623f.

This reverts commit ac78703.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:28:58 +02:00
Francesco Picone
ac787039c3 ++ amministratori esenti dalla conferma email
Chi ha il ruolo amministratore non deve confermare l'indirizzo
(User::esenteDaConfermaEmail): non riceve l'email di conferma né alla
creazione né al cambio d'indirizzo, in Utenti compare "esente". Se perde
il ruolo torna a valere email_verified_at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:19:29 +02:00
Francesco Picone
2f42cf937c ++ rientro: avviso tempo di percorrenza elevato nel banner campagna
Se il territorio è rimasto fuori oltre la soglia "giorni_per_smarrito",
il banner della campagna mostra un avviso (non bloccante). Giorni e
avviso vengono salvati anche nell'evento "campagna_percorso".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:43 +02:00
Francesco Picone
1708890d48 ++ audit log: evento "campagna_percorso" per la scelta sul conteggio in campagna
Registrato al rientro quando viene proposta una campagna (Sì/No) e nel
Registro quando l'amministratore imposta o modifica il conteggio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:43 +02:00
Francesco Picone
097aa4ff30 ++ campagne: "In corso" solo per territori assegnati nel periodo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:08 +02:00
Francesco Picone
6ef301cb14 ++ fix statistiche campagne
- Dettaglio campagna: mostra tutti i territori conteggiati (stessa base
  della percentuale), non solo quelli assegnati dopo l'inizio.
- Rientro: se più campagne corrispondono propone la più recente invece
  della prima creata; testo del banner chiarito.
- Dettaglio campagna: nuova sezione "In corso" con i territori fuori
  durante la campagna e non ancora rientrati.
- README: regola di conteggio documentata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:07 +02:00
Francesco Picone
358cbfc81c ++ rientro: avviso tempo di percorrenza elevato nel banner campagna
Se il territorio è rimasto fuori oltre la soglia "giorni_per_smarrito",
il banner della campagna mostra un avviso (non bloccante). Giorni e
avviso vengono salvati anche nell'evento "campagna_percorso".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:54:07 +02:00
Francesco Picone
40b4ca0b0b ++ audit log: evento "campagna_percorso" per la scelta sul conteggio in campagna
Registrato al rientro quando viene proposta una campagna (Sì/No) e nel
Registro quando l'amministratore imposta o modifica il conteggio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:37:37 +02:00
Francesco Picone
2f0a24bca4 ++ campagne: "In corso" solo per territori assegnati nel periodo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:33:07 +02:00
Francesco Picone
223d44300c ++ fix statistiche campagne
- Dettaglio campagna: mostra tutti i territori conteggiati (stessa base
  della percentuale), non solo quelli assegnati dopo l'inizio.
- Rientro: se più campagne corrispondono propone la più recente invece
  della prima creata; testo del banner chiarito.
- Dettaglio campagna: nuova sezione "In corso" con i territori fuori
  durante la campagna e non ancora rientrati.
- README: regola di conteggio documentata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:18:09 +02:00
fc8623f7db Posta configurabile dall'app, contatti dei proclamatori, conferma email
- Server di posta in Impostazioni (password cifrata, email di prova): se
  compilato prende il posto del .env; non si esporta e l'import XML non lo
  cancella. MAIL_SCHEME=tls nel .env.example non era valido per Laravel 11.
- Proclamatori con email e telefono facoltativi, cifrati, nell'XML e
  cancellati dall'anonimizzazione; il numero apre WhatsApp.
- Dopo l'assegnazione un riquadro in evidenza con il link del PDF,
  "Invia su WhatsApp" con il messaggio pronto e la scelta "inviato /
  non ancora", che resta finché non si sceglie.
- Conferma dell'email obbligatoria per tutti gli utenti, anche esistenti:
  email in italiano, link valido senza login, nuova conferma a ogni cambio
  di indirizzo, "Reinvia conferma" in Utenti. Senza conferma restano
  raggiungibili profilo, cambio password e, per l'admin, il server di
  posta; comando utenti:conferma-email per l'emergenza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:12:31 +02:00
e7148c7b32 Piantine pubblicate anche dopo un ripristino; posto unico senza gare
- Lo stato "Pubblicata" non dipende più dall'id del territorio, che il
  ripristino ricrea: l'impronta usa il numero, e la piantina ricorda il
  percorso del PDF pubblicato (pdf_pubblicato, con migrazione che riconosce
  le piantine già pubblicate).
- BackupOperationState: corrente() in sola lettura e liberaCorrente() sotto
  lo stesso mutex di occupa(), così una lettura non cancella il puntatore
  di un'operazione appena avviata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:33:07 +02:00
426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:01:41 +02:00
056d95cfcb Editor piantine: bozza automatica e pubblicazione con anteprima
- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:08:34 +02:00
d433c9392d S-13 sul modulo ufficiale e correzioni all'integrità dei dati
Modulo S-13
- Il PDF scrive sopra il modulo ufficiale (FPDI, matrice convertita in PDF 1.4)
  invece di ricostruirlo in HTML: 20 righe per foglio come l'originale.
- Una riga per ogni territorio attivo; riporto dell'ultimo rientro prima
  dell'anno; assegnazioni a cavallo d'anno su entrambi i fogli; scelta per
  data e non per anno registrato; nomi come "Cognome I.".
- Logica spostata in App\Services\Registro\ModuloS13.

Integrità dei dati
- Export/import XML (e quindi backup/ripristino): le date non slittano più di
  un giorno; i file vecchi in UTC tornano al giorno giusto.
- Import XML: rifiuta file estranei o incompleti senza toccare i dati, errore
  con riga e motivo, territori senza zona importati con il loro storico,
  caratteri di controllo rimossi in export, chi importa conserva password e
  ruolo di amministratore.
- Assegnazioni: assegnazioneCorrente corretta (filtro dentro ofMany), periodi
  sovrapposti rifiutati in Assegna e Registro sotto lock, anno di servizio
  ricavato dalla data, niente date future nel Registro.
- Utenti: non si declassa sé stessi né l'ultimo amministratore.
- Entrypoint: non sovrascrive più la copia persistente di APP_KEY con un
  valore diverso; serve APP_KEY_ROTATE=1 per un cambio voluto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:35:51 +02:00
754adf9878 ++ Add S-12 Template 2026-08-31 12:28:53 +02:00
Francesco Picone
76ea127d5b Merge branch 'main' of https://git.pyconetwork.it/francesco/termanager2
# Conflicts:
#	HANDOFF.md
2026-07-26 00:16:13 +02:00
Francesco Picone
3ec2422fbc ++ update HANDOFF.md 2026-07-25 19:53:12 +02:00
Francesco Picone
6b11ab7028 ++ fix permessi assegnazioni 2026-07-25 19:43:29 +02:00
199 changed files with 18556 additions and 2458 deletions

View File

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

View File

@@ -34,6 +34,10 @@ APP_URL=http://192.0.2.10:8080
# I dati personali dei proclamatori sono cifrati con questa chiave: se la
# perdi non sono piu' leggibili nemmeno avendo il backup del database.
# Conservala in un password manager.
# Una copia sta nel volume dello storage: se questo valore cambia e non
# coincide con la copia, il container si rifiuta di partire invece di rendere
# illeggibili i dati. Per un cambio voluto aggiungi APP_KEY_ROTATE=1 (la
# chiave vecchia viene conservata accanto alla copia), poi toglila.
APP_KEY=
LOG_CHANNEL=stderr
@@ -122,15 +126,24 @@ SESSION_SECURE_COOKIE=false
# -----------------------------------------------------------------------------
# Posta
# -----------------------------------------------------------------------------
# Il server di posta si configura di solito dall'applicazione (Impostazioni →
# Server di posta): se lì è compilato, questi valori vengono ignorati.
# Servono le email di conferma: senza posta gli utenti non possono confermare
# il loro indirizzo e il loro account non funziona.
#
# In produzione: SMTP esterno.
# In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025
# Per disattivare del tutto l'invio: MAIL_MAILER=log
#
# MAIL_SCHEME: vuoto (sceglie da solo: STARTTLS sulla 587, SSL sulla 465),
# oppure smtp / smtps. "tls" non è un valore valido con Laravel 11 e fa
# fallire ogni invio.
MAIL_MAILER=smtp
MAIL_HOST=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_SCHEME=tls
MAIL_SCHEME=
MAIL_FROM_ADDRESS=noreply@example.org
MAIL_FROM_NAME="${APP_NAME}"

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Gli script finiscono in container Linux: con CRLF (core.autocrlf su
# Windows) il kernel non trova l'interprete e il container non parte.
*.sh text eol=lf

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@
docker-compose.override.yml
db_data/
redis_data/
.claude

File diff suppressed because it is too large Load Diff

338
README.md
View File

@@ -1,8 +1,11 @@
# TerManager2
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali
e registro storico completo.
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
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.
@@ -49,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 |
@@ -338,6 +341,16 @@ Cosa serve sapere:
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
provato sullo stack vero, non qui.
La logica JavaScript senza DOM (la ricerca nei campi di scelta,
`resources/js/scelta.js`) ha i suoi test in `tests/js/`, con il test runner di
Node:
```bash
npm run test:js
# oppure, senza Node installato:
docker run --rm -v "$PWD":/app -w /app node:20-alpine node --test tests/js/
```
---
## Variabili d'ambiente
@@ -400,24 +413,27 @@ Redis, quindi puntarli su un driver diverso interrompe l'elaborazione dei job.
Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
non da variabili d'ambiente:
| Impostazione | Descrizione |
|--------------------------------|------------------------------------------------------|
| Nome congregazione | Mostrato nell'intestazione e nei PDF |
| Logo | Immagine nell'intestazione |
| URL pubblico base | Base dei link PDF condivisibili |
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare |
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario |
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare |
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide |
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
| Download PDF visibile | Mostra o nasconde il pulsante di download |
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati |
| Impostazione | Descrizione |
|------------------------------------|------------------------------------------------------|
| Nome congregazione | Mostrato nell'intestazione, nei PDF e nell'informativa |
| Soglia da assegnare (giorni) | Dopo quanti giorni dal rientro un territorio è "da assegnare" |
| Soglia priorità (giorni) | Dopo quanti giorni dal rientro un territorio diventa prioritario |
| Soglia rientro (giorni) | Oltre questa un'assegnazione è "da rientrare"; è anche la durata del link alla piantina |
| Limite liste Home | Quanti territori mostrare nelle due liste della Home |
| "Scarica PDF" nel viewer | Mostra o nasconde il pulsante di download a chi apre il link |
| Conservazione audit (giorni) | Dopo quanti giorni le voci del registro delle attività si cancellano |
Il server di posta ha una pagina sua (*Impostazioni → Server di posta*). Il
logo nell'intestazione, se c'è, arriva da un backup: la pagina non lo carica.
---
## Aggiornamenti
Push su `main` → **Deploy** su Dokploy. Ad ogni avvio il container:
Push su `main` → **Deploy** su Dokploy. Se l'autodeploy non parte entro un
paio di minuti (succede: vedi HANDOFF, *Dove gira davvero*), il deploy si
avvia a mano dall'interfaccia o con `compose.deploy`. Ad ogni avvio il
container:
1. ricrea le directory di storage e risolve `APP_KEY`
2. attende il database
@@ -616,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).
---
@@ -677,14 +702,24 @@ docker compose --profile dev up -d --build
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
- Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
**da rientrare**
- Due liste rapide con azione diretta: **da assegnare** (prima i prioritari,
con il badge ★, poi quelli fermi da più tempo) e **da rientrare**; quando
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
- Filtri per zona, tipologia e stato
- **"Nuovo territorio"** apre il designer delle piantine già in disegno: il
territorio si crea dal confine disegnato (per chi non è amministratore resta
il form)
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
- Badge di stato colorati e indicatore di priorità (manuale o automatica)
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico:
una tabella sola con le colonne in linea, l'anno come riga di separazione,
dal più recente (lo stesso nella scheda del proclamatore)
- Link PDF pubblico condivisibile, a scadenza configurabile
- Soft delete con cestino (ripristino o eliminazione definitiva)
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background
@@ -696,12 +731,124 @@ docker compose --profile dev up -d --build
- Soft delete con cestino
### Assegnazione e rientro
- Selezione territorio disponibile e proclamatore attivo
- **Scelta con ricerca** di territorio e proclamatore (anche nel Registro e nel
designer delle piantine): si scrive e compaiono le proposte più vicine —
uguale, inizia con, contiene, errore di battitura ("forse cercavi") — con
le parole in qualsiasi ordine, senza badare a maiuscole e accenti. Campo
vuoto: l'elenco completo. Pensata anche per il telefono
- I territori già assegnati si trovano cercando ("già assegnato a …") ma non
si possono scegliere; l'anteprima della miniatura segue la scelta
- Anno teocratico rilevato automaticamente dalla data
- Validazione: territorio non già assegnato, entrambi attivi
- **Invio del link dopo l'assegnazione**: una finestra chiede il canale —
*Copia link*, oppure *Condividi…* (il menu di condivisione del telefono:
WhatsApp, Telegram, SMS…, dove il browser lo offre) — e se il link è stato
inviato. Il destinatario lo sceglie chi usa l'app dai propri contatti: i
proclamatori non hanno numero né email. Solo dopo la risposta si va avanti;
altrimenti l'assegnazione resta segnalata **"Link ancora da inviare"**, con
"Invia link" per riprendere. Anche dall'elenco dei territori assegnati si
può copiare o condividere il link
- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra
nel browser, senza attese; ogni territorio ha il pulsante **Rientro**, che
porta alla registrazione del rientro e poi torna all'elenco. Aperta da
"Assegna →" della Home, la pagina mostra solo il modulo, senza l'elenco
- Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene
chiesto se conteggiarlo
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
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,
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
è già presa, un numero in coda), e una password temporanea. Si vedono una
volta sola; la password va cambiata al primo accesso (la pagina dice
"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
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,
tipologia e "solo prioritari", e "Seleziona tutti quelli mostrati".
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati. Il suo
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. 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
tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
elimina** (anche una sessione aperta si chiude): torna un proclamatore come
gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
gruppo; nel registro delle attività le sue voci restano a nome
"Sorvegliante del gruppo …". I territori già dati restano ai membri come
assegnazioni qualsiasi, che del gruppo conservano solo il nome ("via
gruppo …"). Per ripartire si crea un gruppo nuovo
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
se lo si sposta nel cestino o lo si anonimizza. Prima di disattivarlo o
cestinarlo dall'elenco si chiede conferma; riattivarlo non ricostruisce il
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
cestino esce dal gruppo
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
proclamatore (Assegna, Registro) la nota "gruppo …" accanto al nome, che
resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, email per entrare, stato della password e ultimo accesso) o membro; il
sorvegliante ritrova gli stessi numeri nel suo
*Profilo*
### Campagne
- CRUD con date di inizio e fine
@@ -712,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
@@ -738,24 +889,33 @@ docker compose --profile dev up -d --build
## Ruoli e permessi
Tre ruoli con permessi granulari (spatie/laravel-permission):
Quattro ruoli con permessi granulari (spatie/laravel-permission):
| Permesso | Amministratore | Assistente | Operatore |
|-----------------------|:--------------:|:----------:|:---------:|
| `settings.manage` | ✅ | | |
| `territori.manage` | ✅ | | |
| `proclamatori.manage` | ✅ | ✅ | |
| `campagne.manage` | ✅ | ✅ | |
| `registro.view` | ✅ | ✅ | |
| `registro.export` | ✅ | | |
| `territori.assign` | ✅ | ✅ | ✅ |
| `territori.return` | ✅ | ✅ | ✅ |
| `audit.view` | ✅ | | |
| `audit.export` | ✅ | | |
| Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|-----------------------|:--------------:|:----------:|:---------:|:------------:|
| `settings.manage` | ✅ | | | |
| `territori.manage` | ✅ | | | |
| `proclamatori.manage` | ✅ | ✅ | | |
| `campagne.manage` | ✅ | ✅ | | |
| `registro.view` | ✅ | ✅ | | |
| `registro.export` | ✅ | | | |
| `territori.assign` | ✅ | ✅ | ✅ | |
| `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. 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.
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
@@ -764,13 +924,14 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
## Sicurezza e GDPR
### Sicurezza
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con AES-256-CBC
tramite `APP_KEY`
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
AES-256-CBC tramite `APP_KEY`
- **Password**: hash bcrypt
- **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
@@ -779,15 +940,41 @@ 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
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e stato
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
cambiare — congregazione, conservazione del log, scadenza dei link,
download del PDF, server di posta, durata della sessione, indirizzo e HTTPS,
ruoli con i loro permessi, quanti dati sono conservati — si leggono al
momento da impostazioni e configurazione (`App\Livewire\Privacy`)
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e
stato. Niente telefono né email: il link lo manda chi assegna, scegliendo il
destinatario dai propri contatti, e l'app non vede quei contatti
- **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB
- **Audit trail**: registro completo delle operazioni
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
in chiaro
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
---
@@ -795,15 +982,17 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova (`password_temporanea` dice se il motivo è una password provvisoria data dall'amministratore invece di un ripristino); `proclamatore_id` lega l'utenza del sorvegliante (email fittizia) al suo proclamatore; sciolto il gruppo l'utenza si elimina |
| `settings` | Configurazione singleton |
| `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
| `gruppi_servizio` | Gruppi di servizio attivi: nome (unico), sorvegliante. Sciolto, il gruppo si elimina |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
| `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna |
| `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**
@@ -830,20 +1019,45 @@ Calcolato dinamicamente, non memorizzato:
| Stato | Condizione |
|----------------|-----------------------------------------------------|
| `in_reparto` | Attivo, nessuna assegnazione aperta |
| `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi |
| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro |
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
| `inattivo` | Contrassegnato come non attivo |
### Priorità
Un territorio è prioritario se lo è per **flag manuale** o perché non viene
assegnato da più mesi della soglia configurata. La soglia automatica **vince
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
quando la soglia è scattata.
Un territorio è prioritario se lo è per **flag manuale** o perché è fermo
(dall'ultimo rientro, o dalla creazione se mai assegnato) da più giorni della
soglia configurata. La soglia automatica **vince sempre** sul flag:
l'amministratore può aggiungere priorità, non toglierla quando la soglia è
scattata. Assegnare il territorio azzera il flag.
Dove si sceglie un territorio da dare — "Da assegnare" nella Home, l'aggiunta
al pacchetto di un gruppo, "Il mio gruppo" — l'ordine è lo stesso: prima i
prioritari, poi quelli fermi da più tempo, poi il numero
(`Territorio::ordinaPerPriorita()`).
### Percentuale di percorrenza delle campagne
- **Denominatore**: tutti i territori assegnati nel periodo della campagna
- **Numeratore**: le assegnazioni contrassegnate come conteggiate
- **Denominatore**: tutti i territori attivi
- **Numeratore**: i territori attivi distinti con almeno un'assegnazione
collegata alla campagna e contrassegnata come conteggiata al rientro (un
territorio percorso due volte conta una volta sola)
Al rientro la campagna viene proposta se il territorio era fuori almeno in
parte del periodo: assegnato durante la campagna, rientrato durante la
campagna, oppure fuori per tutta la sua durata. Se sia stato davvero percorso
lo decide l'operatore con la spunta. Se più campagne corrispondono viene
proposta la più recente. I territori assegnati durante la campagna e non
ancora rientrati compaiono nel dettaglio come "in corso".
### Gruppi di servizio e statistiche
Mettere un territorio nel pacchetto di un gruppo **non** è un'assegnazione:
non crea righe in `assegnazioni`, quindi non tocca medie, durate, percorrenza
delle campagne né il registro. Conta solo quando il sorvegliante lo dà a un
membro: da lì è un'assegnazione come le altre (con `gruppo_servizio_id` per
sapere da dove viene). Il tempo passato nel pacchetto prima di essere dato non
viene contato. La priorità automatica guarda l'ultima assegnazione, come
sempre.
---
@@ -854,7 +1068,7 @@ quando la soglia è scattata.
| 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 |
@@ -869,14 +1083,16 @@ quando la soglia è scattata.
```
TerManager2/
├── app/
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin,
│ │ # utenti:conferma-email, territori:file-orfani
│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
│ ├── Http/Middleware/ # RequirePasswordChange
│ ├── Http/Middleware/ # RequirePasswordChange, LimitaSorvegliante
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
│ ├── Livewire/ # Tutti i componenti di interfaccia
│ ├── Models/ # 9 modelli Eloquent
│ ├── Livewire/ # Tutti i componenti di interfaccia (Gruppi/: gruppi di servizio)
│ ├── Models/ # 12 modelli Eloquent
│ ├── Providers/ # AppServiceProvider
│ └── Services/ # Miniature PDF, stato import, archivio backup
│ └── Services/ # Miniature PDF, import, archivio backup, posta,
│ # piantine, modulo S-13, gruppi di servizio
├── bootstrap/app.php # Middleware, proxy e host fidati
├── config/ # Configurazione Laravel
├── database/
@@ -898,7 +1114,7 @@ TerManager2/
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
│ └── livewire/ # Una vista per componente
├── routes/web.php # Rotte con i relativi permessi
├── tests/ # Suite PHPUnit (profilo compose "test")
├── tests/ # Suite PHPUnit (profilo compose "test") e tests/js (Node)
├── phpunit.xml # Configurazione e ambiente dei test
├── docker-compose.yml # Unico, produzione e sviluppo
├── .env.example # Tutta la configurazione

Binary file not shown.

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Conferma a mano l'email di un utente, dalla riga di comando.
*
* Per l'emergenza: la posta non funziona e nessun amministratore confermato
* può sistemarla (per esempio subito dopo l'aggiornamento che ha reso
* obbligatoria la conferma). Chi ha accesso al server è già fidato.
*/
class ConfermaEmailUtente extends Command
{
protected $signature = 'utenti:conferma-email {email : Indirizzo dell\'utente da confermare}';
protected $description = 'Segna come confermata l\'email di un utente (emergenza: posta non funzionante)';
public function handle(): int
{
$user = User::where('email', $this->argument('email'))->first();
if (! $user) {
$this->error('Nessun utente con questo indirizzo.');
return self::FAILURE;
}
if ($user->hasVerifiedEmail()) {
$this->info("{$user->email} è già confermato.");
return self::SUCCESS;
}
$user->markEmailAsVerified();
activity()->performedOn($user)->log('email_confermata_da_riga_di_comando');
$this->info("Email di {$user->email} confermata.");
return self::SUCCESS;
}
}

View File

@@ -95,6 +95,11 @@ class CreateInitialAdmin extends Command
'password' => Hash::make($password),
]);
// Confermato da subito: su un'installazione nuova il server di
// posta non c'è ancora, e chi ha accesso alla riga di comando del
// server non ha bisogno di dimostrare di chi è l'indirizzo. Senza,
// il primo amministratore arriverebbe solo alla pagina della posta.
$user->markEmailAsVerified();
$user->assignRole('amministratore');
return $user;

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use App\Services\Registro\ModuloS13;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
/**
* Il modulo S-13 dell'anno di servizio in corso, per email agli
* amministratori (l'indirizzo della loro utenza, se confermato). Lo lancia lo
* scheduler l'ultima domenica di ogni mese (routes/console.php); a mano si
* può scegliere l'anno con --anno=2025-2026.
*
* Parte subito, non in coda: il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro. Senza un server di posta funzionante non manda
* niente e lo dice.
*/
class InviaModuloS13 extends Command
{
protected $signature = 'registro:invia-s13 {--anno= : etichetta dell\'anno di servizio, es. 2025-2026 (predefinito: quello in corso)}';
protected $description = 'Manda per email agli amministratori il modulo S-13 dell\'anno di servizio';
public function handle(ModuloS13 $modulo, ConfigurazionePosta $posta): int
{
if (! $posta->funzionante()) {
$this->warn('Nessun server di posta funzionante: modulo S-13 non inviato.');
return self::SUCCESS;
}
$anno = $this->option('anno')
? AnnoTeocratico::where('label', $this->option('anno'))->first()
: AnnoTeocratico::corrente();
if (! $anno) {
$this->error("Anno di servizio {$this->option('anno')} non trovato.");
return self::FAILURE;
}
$destinatari = User::role('amministratore')->get()
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
->values();
if ($destinatari->isEmpty()) {
$this->warn('Nessun amministratore con un\'email confermata: modulo S-13 non inviato.');
return self::SUCCESS;
}
$pdf = $modulo->genera($anno);
// Uno per uno: un indirizzo rifiutato non ferma gli altri.
$inviati = 0;
foreach ($destinatari as $utente) {
try {
Mail::to($utente->email)->send(new ModuloS13Mail($utente, $anno, $pdf));
$inviati++;
} catch (\Throwable $e) {
report($e);
$this->error("Invio a {$utente->email} non riuscito: {$e->getMessage()}");
}
}
// Nel registro quanti, non chi: gli indirizzi stanno nelle utenze.
activity()->withProperties(['anno' => $anno->label, 'destinatari' => $inviati])->log('s13_inviato');
$this->info("Modulo S-13 {$anno->label} inviato a {$inviati} amministratori su {$destinatari->count()}.");
return $inviati === $destinatari->count() ? self::SUCCESS : self::FAILURE;
}
}

View File

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

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Models\TerritorioMappa;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Elenca (e su richiesta cancella) i file dei territori che nessuna riga usa.
*
* Fino all'ottobre 2026 l'import XML ricreava i territori con id nuovi senza
* toccare i loro file: PDF, miniature e versioni dei territori di prima
* restavano sul disco, e passavano da un backup all'altro perché l'archivio
* copia tutto il disco pubblico. Oggi l'import li elimina da sé; questo
* comando serve a ripulire quelli accumulati prima.
*
* Di default non cancella niente: elenca. Con --elimina cancella.
*/
class TerritoriFileOrfani extends Command
{
protected $signature = 'territori:file-orfani {--elimina : Cancella i file trovati invece di elencarli}';
protected $description = 'Elenca (o cancella con --elimina) i file dei territori che nessuna riga usa più';
private const CARTELLE = ['territori-pdf', 'territori-thumbnails', 'territori-pdf-versioni', 'territori-mappe'];
public function handle(): int
{
$usati = array_flip(array_merge(
XmlExchange::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$orfani = [];
$byte = 0;
foreach (self::CARTELLE as $cartella) {
foreach ($disco->files($cartella) as $file) {
if (! isset($usati[$file])) {
$orfani[] = $file;
$byte += $disco->size($file);
}
}
}
$peso = number_format($byte / 1048576, 1, ',', '.') . ' MB';
if ($orfani === []) {
$this->info('Nessun file orfano.');
return self::SUCCESS;
}
if (! $this->option('elimina')) {
foreach ($orfani as $file) {
$this->line($file);
}
$this->info(count($orfani) . " file orfani ({$peso}). Rilancia con --elimina per cancellarli.");
return self::SUCCESS;
}
$cancellati = 0;
foreach ($orfani as $file) {
if ($disco->delete($file)) {
$cancellati++;
}
}
$this->info("{$cancellati} file orfani cancellati ({$peso}).");
return self::SUCCESS;
}
}

View File

@@ -44,6 +44,11 @@ class LoginController extends Controller
$request->session()->regenerate();
activity()->causedBy(auth()->user())->log('login');
// Il sorvegliante di gruppo va dritto al suo gruppo.
if (Auth::user()->isSorvegliante() && Auth::user()->roles->count() === 1) {
return redirect()->route('mio-gruppo');
}
return redirect()->intended(route('dashboard'));
}
}

View File

@@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rules\Password;
/**
* Cambio password obbligatorio, imposto dopo il ripristino di un backup.
* Cambio password obbligatorio, imposto dopo il ripristino di un backup o al
* primo accesso con una password temporanea data dall'amministratore
* (sorvegliante di gruppo): `password_temporanea` dice quale dei due.
*
* Volutamente NON e' un componente Livewire: la pagina deve restare
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
@@ -27,6 +29,8 @@ class PasswordChangeController extends Controller
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
// chi e' stato dirottato dal middleware no.
'forced' => (bool) $user->password_change_required,
// Perché: una password temporanea, oppure un ripristino da backup.
'temporanea' => (bool) $user->password_temporanea,
// Il ripristino gira in coda e non ha una sessione in cui lasciare
// un flash; deposita l'esito in cache a nome di chi l'ha avviato e
// lo si legge qui, che e' la prima pagina che quella persona vede.
@@ -43,32 +47,43 @@ class PasswordChangeController extends Controller
public function store(Request $request)
{
// La password attuale NON viene richiesta, ed e' una scelta.
$user = $request->user();
// La password attuale si chiede solo a chi arriva qui di sua
// iniziativa, come fa il profilo.
//
// Il ripristino sovrascrive gli hash con quelli del backup: subito dopo,
// la "password attuale" di un account e' quella che aveva all'epoca del
// backup, che l'utente puo' benissimo non ricordare — l'amministratore
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori
// proprio le persone che questa schermata deve sbloccare. La garanzia
// qui e' la sessione: si arriva a questa pagina gia' autenticati.
$validated = $request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
], [
// Dopo un ripristino no: gli hash sono quelli del backup, e la
// "password attuale" di un account e' quella che aveva all'epoca, che
// l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
// proprio le persone che questa schermata deve sbloccare; la garanzia li'
// e' la sessione. Fuori da quel caso, invece, bastava una sessione
// lasciata aperta per cambiare la password a qualcun altro e chiuderlo
// fuori dal suo account.
$regole = ['password' => ['required', 'confirmed', Password::min(8)]];
if (! $user->password_change_required) {
$regole['current_password'] = ['required', 'current_password'];
}
$validated = $request->validate($regole, [
'password.required' => 'Inserisci la nuova password.',
'password.confirmed' => 'Le due password non coincidono.',
'current_password.required' => 'Inserisci la password attuale.',
'current_password.current_password' => 'La password attuale non è corretta.',
]);
$user = $request->user();
// Il cast `hashed` sul modello cifra il valore al salvataggio.
$user->update([
'password' => $validated['password'],
'password_change_required' => false,
'password_temporanea' => false,
]);
// La sessione corrente sopravvive al cambio, le altre no: dopo un
// ripristino le vecchie credenziali sono da considerarsi compromesse e
// una sessione aperta altrove lo e' altrettanto.
// una sessione aperta altrove lo e' altrettanto. Le altre le chiude
// AuthenticateSession (bootstrap/app.php) alla loro prossima richiesta,
// perche' l'hash della password e' cambiato; qui si cambia l'id di
// questa e si invalida il "ricordami".
$request->session()->regenerate();
$user->setRememberToken(null);
$user->save();

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Conferma dell'indirizzo email degli utenti.
*
* Finché l'indirizzo non è confermato l'account non funziona: il middleware
* `verified` porta qui. Il link della mail funziona anche senza essere
* entrati: lo si apre spesso dal telefono, dove la sessione non c'è, e la
* firma dell'indirizzo (con scadenza) basta a garantire che venga dalla mail.
*/
class VerificaEmailController extends Controller
{
public function avviso(Request $request, ConfigurazionePosta $posta)
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->route('dashboard');
}
return view('auth.verifica-email', [
'user' => $request->user(),
'postaFunzionante' => $posta->funzionante(),
]);
}
public function invia(Request $request)
{
$user = $request->user();
if ($user->hasVerifiedEmail()) {
return redirect()->route('dashboard');
}
try {
$user->sendEmailVerificationNotification();
} catch (\Throwable $e) {
Log::warning('Email di conferma non inviata.', ['user' => $user->id, 'exception' => $e]);
return back()->with('error', 'L\'email non è partita: il server di posta non risponde o rifiuta l\'invio. Avvisa un amministratore.');
}
return back()->with('success', "Email inviata a {$user->email}. Apri il link che contiene (controlla anche lo spam).");
}
public function conferma(Request $request, int $id, string $hash)
{
$user = User::find($id);
// La firma garantisce id e scadenza; l'hash lega il link all'indirizzo
// di allora, così un link vecchio non conferma un'email cambiata dopo.
if (! $user || ! hash_equals(sha1($user->getEmailForVerification()), $hash)) {
abort(403, 'Link di conferma non valido.');
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
activity()->causedBy($user)->log('email_confermata');
}
$messaggio = 'Indirizzo email confermato.';
if ($request->user()?->is($user)) {
return redirect()->route('dashboard')->with('success', $messaggio);
}
return redirect()->route('login')->with('success', $messaggio . ' Ora puoi accedere.');
}
}

View File

@@ -110,30 +110,45 @@ class BackupController extends Controller
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
]);
$operationId = (string) Str::uuid();
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
// una porta d'ingresso separata: un ripristino avviato mentre gira un
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
if ($this->stato->corrente() !== null) {
// Il posto si prende subito e in un passo solo (occupa()): controllarlo
// e prenderlo solo dopo aver salvato il file lasciava una finestra in cui
// un import o un secondo ripristino passavano entrambi.
$inCorso = $this->stato->occupa($operationId, BackupOperationState::TIPO_RESTORE, 'In coda');
if ($inCorso !== null) {
return response()->json([
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.',
'message' => $this->stato->messaggioOccupato($inCorso),
], 409);
}
$operationId = (string) Str::uuid();
try {
// Il file dell'upload vive in una cartella temporanea che sparisce a
// fine richiesta: il job lo troverebbe gia' cancellato.
$percorso = $request->file('backupZip')->storeAs(
'backup-restore-in',
$operationId . '.zip',
'local'
);
} catch (\Throwable $e) {
// Il posto e' gia' preso: senza chiuderlo resterebbe occupato da un
// ripristino che non partira' mai, per l'ora intera del TTL.
$this->stato->markFailed($operationId, 'Archivio non salvato sul server: ' . $e->getMessage());
// Il file dell'upload vive in una cartella temporanea che sparisce a
// fine richiesta: il job lo troverebbe gia' cancellato.
$percorso = $request->file('backupZip')->storeAs(
'backup-restore-in',
$operationId . '.zip',
'local'
);
throw $e;
}
if ($percorso === false) {
$this->stato->markFailed($operationId, 'Archivio non salvato sul server.');
return response()->json([
'message' => 'Il server non è riuscito a salvare il pacchetto ricevuto (spazio o permessi sul disco).',
], 500);
}
$this->stato->initialize(
$operationId,
BackupOperationState::TIPO_RESTORE,
'In coda'
);
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
RestoreBackupArchive::dispatch(

View File

@@ -3,37 +3,30 @@
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\TerritorioMappa;
use App\Services\Piantine\PiantinaPdfService;
use App\Services\Piantine\PubblicazionePiantina;
use Symfony\Component\HttpFoundation\Response;
/**
* Anteprima della cartolina, senza sostituire niente.
* Il PDF dell'anteprima di una pubblicazione, da guardare prima di confermare.
*
* Il PDF viene composto al volo e servito da leggere: non tocca `pdf_path` del
* territorio, non archivia versioni, non lascia file sul disco. Serve a
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un
* confine sbagliato quando è già nello storico.
* Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
* soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
* e scade con l'anteprima; la rotta resta comunque riservata
* all'amministratore come il resto delle piantine.
*/
class PiantinaAnteprimaController extends Controller
{
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response
public function __invoke(string $token, PubblicazionePiantina $pubblicazione): Response
{
$mappa->load('territorio.zona');
$percorso = $pubblicazione->pdfAnteprima($token);
abort_unless($mappa->collegata(), 404);
abort_if($percorso === null, 404);
$pdf = $pdfService->genera($mappa->territorio, $mappa);
return response($pdf, 200, [
return response()->file($percorso, [
'Content-Type' => 'application/pdf',
// `inline`: si apre nel visualizzatore del browser invece di
// finire fra i download, che di un'anteprima non se ne fa niente.
'Content-Disposition' => 'inline; filename="anteprima-territorio-'
. $mappa->territorio->numero . '.pdf"',
// Un'anteprima rispecchia lo stato di adesso: se la cache la
// congela, si continua a guardare il confine di prima.
'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
'Cache-Control' => 'no-store, max-age=0',
]);
}

View File

@@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange;
use App\Services\BackupOperationState;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class XmlExchangeUploadController extends Controller
{
/**
* Quanto resta occupato il posto unico se la richiesta di import muore senza
* liberarlo (timeout, errore fatale: casi in cui il `finally` non gira).
* L'import e' sincrono e PHP lo ferma comunque dopo max_execution_time
* (120 s): dieci minuti bastano a coprirlo, invece dell'ora intera pensata
* per i job in coda.
*/
private const TTL_POSTO_IMPORT_XML = 600;
public function convertSqlToXml(Request $request)
{
$request->validate([
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
}
$exchange = new XmlExchange();
$result = $exchange->importXmlFromContent($content);
// L'import svuota e riscrive le tabelle: non puo' girare insieme a un
// ripristino (che fa lo stesso), a un azzeramento, a un backup che le sta
// fotografando o a un import PDF che sta aggiornando i territori. Il
// ripristino chiama importXmlFromContent() per conto suo, gia' dentro il
// proprio posto: per questo il blocco sta qui, sulla porta d'ingresso, e
// non dentro l'import.
$stato = app(BackupOperationState::class);
$operationId = (string) Str::uuid();
$inCorso = $stato->occupa($operationId, BackupOperationState::TIPO_IMPORT_XML, 'Import XML', self::TTL_POSTO_IMPORT_XML);
if ($inCorso !== null) {
return back()->withErrors(['xmlImport' => $stato->messaggioOccupato($inCorso) . ' Nessun dato è stato modificato.']);
}
$stato->markRunning($operationId);
try {
$exchange = new XmlExchange();
$fileDiPrima = XmlExchange::fileDeiTerritori();
$result = $exchange->importXmlFromContent($content);
if (! isset($result['error'])) {
$result['stats']['file_rimossi'] = XmlExchange::eliminaFileNonPiuUsati($fileDiPrima);
}
} catch (\Throwable $e) {
$stato->markFailed($operationId, $e->getMessage());
throw $e;
}
if (isset($result['error'])) {
$stato->markFailed($operationId, $result['error']);
return back()->withErrors(['xmlImport' => $result['error']]);
}
$stato->markCompleted($operationId);
$stats = $result['stats'];
$issues = $result['issues'];
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
}
if (($stats['file_rimossi'] ?? 0) > 0) {
$message .= ' Eliminati ' . $stats['file_rimossi'] . ' file (PDF e miniature) dei territori sostituiti, che i dati importati non usano più.';
}
return redirect()->route('xml.exchange')
->with('success', $message)

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF, miniature e versioni archiviate dei territori, solo per chi è dentro.
*
* I file stanno sul disco `public`, ma nginx non serve più le cartelle
* `territori-*` (docker/nginx/default.conf): prima chiunque scaricava
* /storage/territori-pdf/territorio-12-34.pdf senza login, con nomi facili da
* indovinare, e Cloudflare li teneva in cache pubblica per una settimana. I
* proclamatori continuano a riceverli dal link con codice e scadenza
* (AssignmentPdfController); da qui passano solo gli utenti autenticati, con i
* permessi della pagina che li mostra.
*/
class TerritorioFileController extends Controller
{
public function pdf(Territorio $territorio): Response
{
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
}
/**
* Il PDF di un territorio del pacchetto, per il sorvegliante di gruppo:
* solo se il territorio è nel pacchetto del gruppo che sorveglia.
*/
public function pdfGruppo(Territorio $territorio): Response
{
$this->delPacchetto($territorio);
return $this->pdf($territorio);
}
public function miniatura(Territorio $territorio): Response
{
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';
return $this->servi($versione->pdf_path, 'application/pdf', "territorio-{$numero}-versione-{$versione->id}.pdf");
}
private function servi(?string $percorso, ?string $tipo, ?string $nome): Response
{
$disco = Storage::disk('public');
abort_unless($percorso && $disco->exists($percorso), 404);
$intestazioni = [];
if ($tipo) {
$intestazioni['Content-Type'] = $tipo;
}
if ($nome) {
$intestazioni['Content-Disposition'] = 'inline; filename="' . $nome . '"';
}
// Privata: niente cache intermedie (Cloudflare). Va impostata dopo,
// perché BinaryFileResponse si dichiara pubblica da sola e
// sovrascriverebbe un Cache-Control passato fra le intestazioni.
// L'indirizzo porta l'impronta del percorso (?v=), che cambia col file.
return response()->file($disco->path($percorso), $intestazioni)
->setPrivate()
->setMaxAge(86400);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
* componenti che può aprire controllano da sé che territori e assegnazioni
* siano del suo gruppo, e gli altri non può caricarli.
*
* Vale solo per chi ha il ruolo sorvegliante e nessun altro: un utente con
* anche un altro ruolo resta regolato dai permessi di quel ruolo. Sciolto il
* gruppo l'utenza si elimina: una sessione ancora aperta non ha più utente.
*/
class LimitaSorvegliante
{
private const ROTTE_AMMESSE = [
'mio-gruppo',
'mio-gruppo.*',
'assegnazioni.rientra',
'profile',
'password.change',
'password.change.store',
'logout',
'verification.*',
// Le azioni Livewire: la rotta si chiama "default.livewire.update"
// (Livewire 3.7), non "livewire.update". Con 'livewire.*' ogni clic
// del sorvegliante finiva in 403.
'*livewire.*',
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if (! $user->isSorvegliante() || $user->roles->count() > 1) {
return $next($request);
}
if ($request->routeIs(self::ROTTE_AMMESSE)) {
return $next($request);
}
if ($request->routeIs('dashboard')) {
return redirect()->route('mio-gruppo');
}
abort(403, 'Il sorvegliante di gruppo può usare solo la pagina del proprio gruppo.');
}
}

View File

@@ -26,6 +26,9 @@ class RequirePasswordChange
'password.change',
'password.change.store',
'logout',
// Il link della mail di conferma: aperto in quel momento andrebbe
// perso, e la persona dovrebbe chiederne un altro.
'verification.verify',
];
public function handle(Request $request, Closure $next): Response

View File

@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Livewire\Settings\XmlExchange;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -44,7 +45,7 @@ class CreateBackupArchive implements ShouldQueue
) {
}
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
{
$stato->markRunning($this->operationId);
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
$this->rimuoviArchiviVecchi($archivio);
$xml = $exchange->currentXmlBackup();
$datiPiantine = $piantine->esporta();
$stato->fase($this->operationId, 'Compressione dei file');
$archivio->create(
$xml,
$datiPiantine,
$destinazione,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
);

View File

@@ -4,6 +4,8 @@ namespace App\Jobs;
use App\Models\Territorio;
use App\Models\User;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantinaPdfService;
use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService;
use Illuminate\Bus\Queueable;
@@ -35,14 +37,21 @@ class ImportTerritoryPdfFolder implements ShouldQueue
) {
}
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService, BackupOperationState $postoUnico, PiantinaPdfService $pdfService): void
{
$stateService->markRunning($this->importId);
$postoUnico->markRunning($this->importId);
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
// Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si
// tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede
// e scarta il file come ambiguo invece di sceglierne uno a caso.
$territoriMap = [];
foreach (Territorio::withTrashed()->get() as $territorio) {
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
$chiave = $this->chiaveTerritorio((string) $territorio->numero);
if ($chiave !== '') {
$territoriMap[$chiave][] = $territorio;
}
}
$actor = User::find($this->actorId);
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
continue;
}
$normalizedNumber = $territoryMatch['normalized_number'];
if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') {
$codici = implode(', ', $territoryMatch['codes']);
$esistenti = implode(', ', $territoryMatch['matched_numbers']);
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'unknown-suffix',
'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.',
'matched_numbers' => $territoryMatch['matched_numbers'],
]);
continue;
}
$matchedNumber = $territoryMatch['matched_number'];
$territorio = $territoryMatch['territorio'];
// Per id, non per numero: due file che nominano lo stesso
// territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono
// comunque un doppione.
$normalizedNumber = $territorio->id;
if (isset($seenNumbers[$normalizedNumber])) {
$stateService->increment($this->importId, 'skipped');
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
}
try {
if ($territorio->pdf_path) {
Storage::disk('public')->delete($territorio->pdf_path);
}
if ($territorio->thumbnail_path) {
$thumbnailService->delete($territorio->thumbnail_path);
}
// The territory id is part of the filename because the slug
// alone is not unique: "1 A" and "1-A" are distinct
// territory numbers that both slug to "territorio-1-a".
// Without it the second import overwrites the first's PDF
// and both rows point at one file — then removing the PDF
// from either territory breaks the other.
//
// Il nuovo file si scrive per primo, con un nome che non
// coincide con quello in vigore; solo dopo il vecchio va
// nello storico, come per le piantine. Prima si cancellava
// il vecchio e poi si scriveva: con una scrittura fallita il
// territorio restava senza PDF, e un import sbagliato non si
// poteva annullare.
$storedFilename = Str::slug('territorio-' . $territorio->numero)
. '-' . $territorio->id . '.pdf';
. '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
$publicPath = 'territori-pdf/' . $storedFilename;
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) {
throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.');
}
$pdfService->archiviaCorrente($territorio, 'import');
$thumbnailPath = $thumbnailService->generate($publicPath);
$territorio->update([
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
$stateService->appendLog($this->importId, 'Import completato.');
$stateService->markCompleted($this->importId);
$postoUnico->markCompleted($this->importId);
} catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
$stateService->markFailed($this->importId);
$postoUnico->markFailed($this->importId, $exception->getMessage());
throw $exception;
} finally {
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
* Without this the import state is stranded in "running" — the UI polls it
* until the 24h cache entry expires — and the uploaded temp directory is
* never reclaimed, since handle()'s finally never executes.
*
* Libera anche il posto unico: senza, un import ucciso dal timeout
* lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera
* del TTL, con un'operazione che non avanza piu'.
*/
public function failed(?\Throwable $exception): void
{
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
);
$stateService->markFailed($this->importId);
app(BackupOperationState::class)->markFailed(
$this->importId,
'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
);
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
}
/**
* Abbina il nome di un file al territorio, oppure spiega perche' non lo fa.
*
* La regola: nel nome si cercano i "numeri di territorio", cioe' cifre
* seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un
* trattino, un trattino basso o un punto ("12", "012", "12B", "12 B",
* "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo
* stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero
* deve stare da solo — "T12" o "12AB" non contano — e quelli che non
* corrispondono a nessun territorio (un anno, un CAP) vengono ignorati.
*
* Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A"
* conteneva "1", e in assenza di un territorio 1A il PDF finiva sul
* territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera
* dopo il numero fa parte del numero, e se quel territorio non esiste il
* file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si
* tocca.
*
* Il file si scarta anche quando l'abbinamento non e' univoco: due numeri
* di territori esistenti nello stesso nome, o due territori che si
* scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire
* la mappa sbagliata e' peggio che non sostituirne nessuna: un file
* scartato si ricarica, un PDF sovrascritto non torna.
*
* I territori con numeri di altra forma (lettere davanti, piu' lettere in
* fondo) non si abbinano da nome file: il PDF va caricato dalla loro
* scheda.
*/
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
{
if ($territoriMap === []) {
return null;
}
$trovati = [];
$suffissiSconosciuti = [];
$basename = pathinfo($filename, PATHINFO_FILENAME);
$normalizedBasename = mb_strtoupper($basename);
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
foreach ($this->numeriNelNomeFile($filename) as $codice) {
if (isset($territoriMap[$codice])) {
foreach ($territoriMap[$codice] as $territorio) {
$trovati[$territorio->id] = $territorio;
}
$territoryKeys = array_keys($territoriMap);
usort($territoryKeys, function (string $left, string $right) {
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
if ($lengthComparison !== 0) {
return $lengthComparison;
}
return strnatcasecmp($left, $right);
});
$matches = [];
foreach ($territoryKeys as $normalizedNumber) {
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
continue;
}
$matches[] = [
'normalized_number' => $normalizedNumber,
'matched_number' => $territoriMap[$normalizedNumber]->numero,
'territorio' => $territoriMap[$normalizedNumber],
// "1A" che non esiste, mentre 1 si': e' il caso che prima
// sovrascriveva il territorio 1. Lo si annota per spiegarlo.
$base = preg_replace('/\p{L}$/u', '', $codice);
if ($base !== $codice && isset($territoriMap[$base])) {
$suffissiSconosciuti[$codice] = $territoriMap[$base];
}
}
$numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori)));
if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) {
return [
'type' => 'ambiguous',
'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))),
];
}
if ($matches === []) {
if ($trovati === [] && $suffissiSconosciuti !== []) {
return [
'type' => 'unknown-suffix',
'codes' => array_keys($suffissiSconosciuti),
'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))),
];
}
if ($trovati === []) {
return null;
}
if (count($matches) > 1) {
return [
'type' => 'ambiguous',
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
];
}
$territorio = reset($trovati);
return $matches[0];
return [
'type' => 'single',
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
'matched_number' => $territorio->numero,
'territorio' => $territorio,
];
}
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
/**
* I numeri di territorio presenti nel nome del file, gia' nella forma di
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
*
* @return array<int, string>
*/
protected function numeriNelNomeFile(string $filename): array
{
$escapedNumber = preg_quote($normalizedNumber, '/');
$nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME));
if (preg_match('/^\d+$/', $normalizedNumber)) {
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
// Il lookahead finale e' quello che separa "12 B" (numero 12B) da
// "12 Via Roma" (numero 12): la lettera vale come parte del numero solo
// se dopo di lei la parola finisce.
preg_match_all('/(?<![\p{L}\p{N}])(\d+)(?:[ _.\-]?(\p{L}))?(?![\p{L}\p{N}])/u', $nome, $trovati, PREG_SET_ORDER);
$codici = [];
foreach ($trovati as $trovato) {
$codici[] = (ltrim($trovato[1], '0') ?: '0') . ($trovato[2] ?? '');
}
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
return array_values(array_unique($codici));
}
protected function normalizeTerritoryNumber(string $number): string
/**
* Forma di confronto di un numero di territorio: maiuscolo, senza
* separatori, senza zeri iniziali. "12 B", "12-b" e "012B" diventano tutti
* "12B"; due territori con la stessa chiave rendono ambiguo ogni file che
* la nomina.
*/
protected function chiaveTerritorio(string $numero): string
{
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
$chiave = preg_replace('/[^\p{L}\p{N}]+/u', '', mb_strtoupper($numero));
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
return ltrim($normalized, '0') ?: '0';
}
return $normalized;
return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Services\Piantine\PubblicazionePiantina;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Prepara l'anteprima di una pubblicazione (immagine HD + PDF) fuori dalla
* richiesta web. Non modifica niente: lo fa solo la conferma.
*/
class PreparaAnteprimaPiantina implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Ripetere non serve: un errore va mostrato, e si riprova dal pulsante. */
public int $tries = 1;
/** Il rendering più il margine per comporre il PDF. */
public int $timeout;
public function __construct(public string $token)
{
$this->timeout = (int) config('piantine.renderer_timeout') + 60;
}
public function handle(PubblicazionePiantina $pubblicazione): void
{
$pubblicazione->prepara($this->token);
}
/** Job ucciso (timeout, worker fermato): l'anteprima non resta "in corso" per sempre. */
public function failed(?\Throwable $e): void
{
app(PubblicazionePiantina::class)->segnaErrore(
$this->token,
'La preparazione dell\'anteprima si è interrotta. Riprova.'
);
}
}

View File

@@ -5,9 +5,13 @@ namespace App\Jobs;
use App\Models\AnnoTeocratico;
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;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
@@ -21,6 +25,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
@@ -72,13 +77,21 @@ 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', 8);
$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
// chiave a null on delete, quindi sopravvivevano scollegate
// e con l'immagine che puntava a un file appena cancellato.
'Piantine' => fn () => TerritorioMappa::query()->delete(),
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
'Tipologie' => fn () => Tipologia::query()->delete(),
'Zone' => fn () => Zona::query()->delete(),
@@ -100,6 +113,13 @@ class ResetApplication implements ShouldQueue
// ricreare la riga.
Setting::flushCache();
// Area della città e anteprime delle pubblicazioni stanno sul disco
// privato, che svuotaDiscoPubblico() non tocca: restavano lì e
// l'editor ripartiva con la città di prima.
Storage::disk('local')->deleteDirectory('piantine');
Storage::disk('local')->deleteDirectory('piantine-anteprime');
$stato->log($this->operationId, 'Area della città e anteprime delle piantine rimosse.');
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
$utentiRimossi = $this->rimuoviAltriUtenti($stato);

View File

@@ -3,8 +3,10 @@
namespace App\Jobs;
use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Spatie\Permission\PermissionRegistrar;
/**
@@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue
) {
}
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
/**
* Il ripristino e' tutto o niente. Tre tempi:
*
* 1. estrazione e verifica dell'archivio nello staging, piantine.json
* compreso: un archivio rotto si ferma qui, a installazione intatta;
* 2. un'unica transazione con l'import XML, le tabelle delle piantine, il
* blocco degli account e — per ultimo, ancora dentro — lo scambio dei
* file. Se lo scambio fallisce si disfa da solo e la transazione si
* annulla; se fallisce il commit, i file si rimettono a posto qui;
* 3. a commit avvenuto, si buttano i file precedenti.
*
* Prima import, blocco account e file erano tre passi separati: un errore
* sui file lasciava il database gia' riscritto con il disco a meta', e un
* errore sul blocco lasciava attive le password del backup.
*
* Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit
* implicito, chiudendo la transazione a meta'. L'import XML e
* PiantineBackup cancellano con DELETE.
*/
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
{
$stato->markRunning($this->operationId);
$staging = null;
$scambio = null;
$confermato = false;
try {
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
$stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
$estratto = $archivio->extract(
$this->zipPath,
@@ -56,33 +80,46 @@ class RestoreBackupArchive implements ShouldQueue
);
$staging = $estratto['staging'];
$piantine->valida($estratto['piantine']);
$stato->fase($this->operationId, 'Riscrittura del database', 1);
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
// primo id disponibile invece che su chi ha avviato il ripristino.
//
// E' anche cio' che tiene dentro chi ripristina: l'import riconosce
// l'attore e ne conserva hash della password, remember_token e
// ruolo di amministratore. Con AuthenticateSession attivo, un hash
// cambiato chiuderebbe la sua sessione e lo costringerebbe a
// rientrare con la password dell'epoca del backup, che puo' non
// ricordare. Se il suo account nel backup non c'e', l'import (che
// fa upsert per email, senza cancellare nessuno) non lo tocca
// affatto: resta con password e ruoli attuali.
Auth::onceUsingId($this->actorId);
$result = $exchange->importXmlFromContent($estratto['xml']);
DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) {
// La transazione dell'import diventa un savepoint di questa:
// il suo commit non e' piu' definitivo.
$result = $exchange->importXmlFromContent($estratto['xml']);
if (isset($result['error'])) {
$stato->markFailed($this->operationId, $result['error']);
if (isset($result['error'])) {
throw new RuntimeException($result['error']);
}
return;
}
$esitoPiantine = $piantine->ripristina($estratto['piantine']);
$stato->avanza($this->operationId, 1, 1);
$utenti = $this->richiediCambioPasswordATutti();
$stato->fase($this->operationId, 'Ripristino dei file');
$stato->avanza($this->operationId, 1, 1);
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
$fileRipristinati = $archivio->restoreFiles(
$staging,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
);
$scambio = $archivio->scambiaFile($estratto);
});
$stato->fase($this->operationId, 'Blocco degli account', 1);
$utenti = $this->richiediCambioPasswordATutti();
$confermato = true;
$archivio->confermaScambio($scambio);
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
$stato->avanza($this->operationId, 1, 1);
} catch (\Throwable $e) {
$messaggio = $e->getMessage();
// Lo scambio era riuscito ma il commit no: il database e' tornato
// indietro, i file devono seguirlo.
if ($scambio !== null && ! $confermato) {
try {
$archivio->annullaScambio($scambio, $e);
} catch (\Throwable $annullamento) {
$messaggio = $annullamento->getMessage();
}
}
// La transazione annullata non svuota le cache in memoria del
// processo: Setting terrebbe l'istanza creata dall'import (una
// riga che non esiste piu') e Spatie i ruoli letti durante l'import.
Setting::flushCache();
app(PermissionRegistrar::class)->forgetCachedPermissions();
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
$stato->markFailed($this->operationId, $e->getMessage());
$stato->markFailed($this->operationId, $messaggio);
return;
} finally {
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
}
$stats = $result['stats'];
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
$fileRipristinati = $scambio['file'];
$messaggio = sprintf(
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. '
. 'Per sicurezza %s impostare una nuova password.',
$stats['territori_importati'] ?? 0,
$stats['proclamatori_importati'] ?? 0,
$stats['assegnazioni_importate'] ?? 0,
$esitoPiantine['mappe'],
$fileRipristinati,
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
);
// Un archivio di formato 1 non conteneva le piantine: va detto, o
// l'amministratore le cercherebbe senza sapere perche' sono sparite.
if ($estratto['piantine'] === null) {
$stato->log(
$this->operationId,
'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. '
. 'L\'area KML in uso non e\' stata toccata.'
);
}
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
// il middleware dirotta l'amministratore sul cambio password. Lo si
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
$stato->log($this->operationId, $messaggio);
$stato->markCompleted($this->operationId, [
'stats' => $stats,
'issues' => array_slice($result['issues'], 0, 50),
'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
'file_ripristinati' => $fileRipristinati,
'messaggio' => $messaggio,
]);
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
* muore per cause che handle() non intercetta, cosi' il posto unico si
* libera subito invece di restare occupato fino alla scadenza.
*
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi
* con database e file disallineati, e chi legge deve sapere che la strada
* e' rilanciare il ripristino, non ignorare l'errore.
* Qui il messaggio dice anche in che stato si e' rimasti. Un processo
* ucciso a meta' non arriva al commit, e il database annulla da se' la
* transazione rimasta aperta: i dati restano quelli di prima. L'unica
* finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei
* file e il commit — in quel caso i file precedenti sono ancora in
* storage/app/backup-precedenti/. La strada resta rilanciare il
* ripristino, non ignorare l'errore.
*/
public function failed(?\Throwable $e): void
{
app(BackupOperationState::class)->markFailed(
$this->operationId,
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
. ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.'
. ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. '
. 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.'
);
@unlink($this->zipPath);
@@ -177,6 +250,9 @@ class RestoreBackupArchive implements ShouldQueue
{
DB::table('users')->update([
'password_change_required' => true,
// Il motivo ora è il ripristino, anche per chi aveva una
// password temporanea (pagina del cambio password).
'password_temporanea' => false,
'remember_token' => null,
]);

View File

@@ -2,30 +2,70 @@
namespace App\Livewire\Assegnazioni;
use App\Livewire\Concerns\InviaLink;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use Livewire\Attributes\Computed;
use App\Models\Territorio;
use App\Models\Proclamatore;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
use Illuminate\Support\Facades\Storage;
use App\Models\Richiesta;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
class Assegna extends Component
{
use RicordaRitorno;
use InviaLink;
public ?int $territorio_id = null;
public ?int $proclamatore_id = null;
public string $assigned_at = '';
public string $territorioSearch = '';
public string $linkSearch = '';
/** 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;
/**
* Aperta da "Assegna →" della Home (?dalla_home=1, lo mette solo la Home)
* la pagina serve ad assegnare quel territorio: l'elenco dei territori
* assegnati sotto il modulo non si mostra (né si carica).
*/
public bool $elencoNascosto = false;
public function mount(?int $territorioId = null)
{
$this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId;
$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')) {
$this->apriInvio($invia);
}
}
protected function rules(): array
@@ -43,118 +83,133 @@ class Assegna extends Component
$territorio = Territorio::findOrFail($this->territorio_id);
// Check territory is available (not currently assigned)
// Gli errori vanno sul campo e non in sessione: il messaggio flash
// sta nel telaio della pagina, che Livewire non ridisegna, e il
// rifiuto passerebbe inosservato.
if ($territorio->assegnazioneCorrente) {
session()->flash('error', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
$this->addError('territorio_id', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
return;
}
// Check territory is active
if (!$territorio->attivo) {
session()->flash('error', "Il territorio {$territorio->numero} è inattivo.");
$this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
return;
}
// Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante.
if ($territorio->gruppo_servizio_id) {
$this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto.");
return;
}
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
if (!$proclamatore->attivo) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
return;
}
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
$assegnazione = Assegnazione::create([
'territorio_id' => $this->territorio_id,
'proclamatore_id' => $this->proclamatore_id,
'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate,
'created_by' => auth()->id(),
]);
// Controllo e inserimento sotto lo stesso lock sulla riga del
// territorio: un doppio clic o due schede aperte non possono più
// superare entrambi il controllo e creare due assegnazioni aperte.
$esito = DB::transaction(function () use ($territorio, $assignedDate, $annoTeocratico) {
Territorio::whereKey($territorio->id)->lockForUpdate()->first();
$conflitto = Assegnazione::sovrappostaA($territorio->id, $assignedDate);
if ($conflitto) {
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}: "
. ($conflitto->returned_at
? 'la data di assegnazione non può essere precedente al suo rientro.'
: 'registrane prima il rientro.');
}
return Assegnazione::create([
'territorio_id' => $this->territorio_id,
'proclamatore_id' => $this->proclamatore_id,
'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate,
'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,
]);
});
if (is_string($esito)) {
$this->addError('assigned_at', $esito);
return;
}
$assegnazione = $esito;
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
// L'id, non il nome: nome e cognome sono cifrati nel database, e
// copiati qui in chiaro restavano leggibili anche dopo
// l'anonimizzazione. Il registro mostra il nome di oggi.
->withProperties([
'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo,
'proclamatore_id' => $proclamatore->id,
])
->log('assigned');
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
return $this->redirect(route('territori.show', $territorio), navigate: true);
}
public function toggleLinkSent(int $assegnazioneId): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
}
#[Computed]
public function selectedThumbnailUrl(): ?string
{
if (!$this->territorio_id) {
return null;
// Partita da una richiesta del modulo pubblico: ora è evasa.
if ($this->richiestaId) {
Richiesta::find($this->richiestaId)?->evadi($assegnazione, auth()->user());
$this->richiestaId = null;
}
$t = Territorio::find($this->territorio_id);
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
$this->cartaceo = false;
// Non si va avanti: si apre l'invio del link (vedi in cima).
$this->apriInvio($assegnazione->id);
}
// ─── Invio del link (trait InviaLink) ──────────────────────
protected function puoInviare(Assegnazione $assegnazione): bool
{
return auth()->user()->can('territori.assign');
}
protected function paginaDopoInvio(): string
{
return $this->destinazione(route('assegnazioni.assegna'));
}
public function render()
{
$territoriQuery = Territorio::where('attivo', true)
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
->with(['zona', 'tipologia']);
if (trim($this->territorioSearch) !== '') {
$term = trim($this->territorioSearch);
$territoriQuery->where(function ($q) use ($term) {
$q->where('numero', 'like', "%{$term}%")
->orWhereHas('zona', fn($z) => $z->where('nome', 'like', "%{$term}%"))
->orWhereHas('tipologia', fn($t) => $t->where('nome', 'like', "%{$term}%"));
});
}
$territoriDisponibili = $territoriQuery
->orderBy('numero')
->get();
$proclamatoriAttivi = Proclamatore::attivi()
// Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
// i territori già assegnati, che non si possono scegliere: cercandone
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
// serve all'anteprima, che si aggiorna nel browser.
// Quelli nel pacchetto di un gruppo di servizio li assegna il
// sorvegliante: qui si trovano ma non si scelgono.
$territori = Territorio::attivi()
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
->get()
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
->sortBy('numero', SORT_NATURAL);
// All currently assigned territories with links
$assegnazioniAperte = Assegnazione::aperte()
->with(['territorio.zona', 'proclamatore'])
->get()
->sortBy(fn($a) => (int) $a->territorio?->numero);
$opzioniTerritori = $territori
->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
(bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
(bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
default => null,
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
->values()
->all();
// Ricerca rapida nella lista dei link: numero territorio, zona,
// proclamatore in un solo campo. In memoria perché nome/cognome del
// proclamatore sono cifrati e non filtrabili via SQL.
$assegnazioniAperteVisibili = $assegnazioniAperte;
if (trim($this->linkSearch) !== '') {
$term = mb_strtolower(trim($this->linkSearch));
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) {
$haystack = mb_strtolower(
($a->territorio?->numero ?? '') . ' ' .
($a->territorio?->zona?->nome ?? '') . ' ' .
($a->proclamatore?->nome_completo ?? '')
);
return str_contains($haystack, $term);
})->values();
}
// Il link scade alla soglia rientro (giorni_per_smarrito).
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
// L'elenco dei territori assegnati sta in ElencoAssegnati.
return view('livewire.assegnazioni.assegna', [
'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi,
'assegnazioniAperte' => $assegnazioniAperte,
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
'sogliaRientro' => $sogliaRientro,
'invio' => $this->invioCorrente(),
'opzioniTerritori' => $opzioniTerritori,
'opzioniProclamatori' => $opzioniProclamatori,
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
]);
}
}

View File

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

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Assegnazioni;
use App\Models\Assegnazione;
use App\Models\Setting;
use Illuminate\Support\Str;
use Livewire\Attributes\Lazy;
use Livewire\Component;
/**
* L'elenco dei territori assegnati della pagina Assegna, separato dal form.
*
* Prima stava nello stesso componente: con ~80 righe la pagina pesava quasi
* 400 KB e Livewire li rimandava tutti a ogni interazione (scegliere un
* territorio, scrivere nella ricerca, aprire l'invio del link). Ora:
* - è un componente a sé, caricato subito dopo la pagina (#[Lazy]): il form
* compare subito e le sue azioni non ridisegnano l'elenco;
* - la ricerca filtra le righe nel browser (data-cerca), senza richieste;
* - le icone sono simboli SVG definiti una volta e richiamati con <use>.
*/
#[Lazy]
class ElencoAssegnati extends Component
{
public function toggleLinkSent(int $assegnazioneId): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
}
/** "Condividi" dall'elenco andato a buon fine: il link risulta inviato. */
public function segnaCondiviso(int $assegnazioneId): void
{
$this->authorize('territori.assign');
Assegnazione::aperte()->find($assegnazioneId)?->registraInvioLink(true, 'condividi');
}
public function placeholder()
{
return view('livewire.assegnazioni.elenco-assegnati-attesa');
}
public function render()
{
$assegnazioni = Assegnazione::aperte()
->with(['territorio.zona', 'proclamatore'])
->get()
->sortBy(fn ($a) => (string) $a->territorio?->numero, SORT_NATURAL)
->values();
// Il testo su cui filtra la ricerca nel browser: numero, zona e nome
// (decifrato qui: in SQL non si potrebbe cercare), senza accenti.
$cerca = $assegnazioni->mapWithKeys(fn ($a) => [$a->id => Str::lower(Str::ascii(implode(' ', array_filter([
$a->territorio?->numero,
$a->territorio?->zona?->nome,
$a->proclamatore?->nome_completo,
]))))]);
return view('livewire.assegnazioni.elenco-assegnati', [
'assegnazioni' => $assegnazioni,
'cerca' => $cerca,
// Il link scade alla soglia rientro (giorni_per_smarrito).
'sogliaRientro' => max(1, (int) Setting::getValue('giorni_per_smarrito', 120)),
]);
}
}

View File

@@ -3,12 +3,16 @@
namespace App\Livewire\Assegnazioni;
use Livewire\Attributes\Locked;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Richiesta;
class Rientra extends Component
{
use RicordaRitorno;
public Assegnazione $assegnazione;
public string $returned_at = '';
public bool $showCampaignPrompt = false;
@@ -22,14 +26,27 @@ 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);
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
if ($assegnazione->returned_at) {
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();
}
@@ -66,8 +83,28 @@ class Rientra extends Component
];
}
/**
* Chi ha territori.return registra qualsiasi rientro. Il sorvegliante di
* gruppo (gruppo.gestisci) solo quelli del suo gruppo: assegnazioni nate
* dal gruppo o territori del suo pacchetto.
*/
private function autorizza(Assegnazione $assegnazione): void
{
$utente = auth()->user();
if ($utente->can('territori.return')) {
return;
}
$gruppo = $utente->can('gruppo.gestisci') ? $utente->gruppoSorvegliato() : null;
abort_unless(
$gruppo && ($assegnazione->gruppo_servizio_id === $gruppo->id || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id),
403
);
}
public function save()
{
$this->autorizza($this->assegnazione);
$this->validate();
// mount() rejects an already-returned assignment, but the component
@@ -92,22 +129,60 @@ class Rientra extends Component
->performedOn($this->assegnazione)
->withProperties([
'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo,
'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
'giorni' => $this->assegnazione->giorni,
'campagna' => $this->counted_in_campaign,
])
->log('returned');
// The operator answered the campaign prompt: record the choice
// (counted or not) as its own audit entry.
if ($this->showCampaignPrompt && $this->campagna_id) {
$this->assegnazione->logCampagnaPercorso(
Campagna::find($this->campagna_id),
$this->counted_in_campaign,
'Rientro'
);
}
// 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(route('territori.show', $territorio), navigate: true);
return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true);
}
/**
* Dove si torna senza una pagina di partenza: la scheda del territorio,
* o "Il mio gruppo" per il sorvegliante, che la scheda non la vede.
*/
public function paginaPredefinita(): string
{
return auth()->user()->can('territori.assign')
? route('territori.show', $this->assegnazione->territorio_id)
: route('mio-gruppo');
}
public function render()
{
$campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null;
// Warning shown inside the campaign prompt only: holding time up to
// the selected return date above the "da rientrare" threshold.
$giorniPercorrenza = null;
$sogliaPercorrenza = null;
if ($campagna && $this->returned_at) {
$giorniPercorrenza = $this->assegnazione->giorniPercorrenza(\Carbon\Carbon::parse($this->returned_at));
$sogliaPercorrenza = Assegnazione::sogliaPercorrenzaElevata();
}
return view('livewire.assegnazioni.rientra', [
'campagna' => $campagna,
'giorniPercorrenza' => $giorniPercorrenza,
'sogliaPercorrenza' => $sogliaPercorrenza,
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
]);
}
}

View File

@@ -48,8 +48,22 @@ class AuditLog extends Component
$events = Activity::select('description')->distinct()->pluck('description');
$users = \App\Models\User::orderBy('name')->get();
$activities = $query->paginate(30);
// Il log conserva solo l'id del proclamatore: il nome si legge adesso,
// così chi è stato anonimizzato compare come tale anche nello storico.
$idProclamatori = $activities->getCollection()
->map(fn ($a) => data_get($a->properties, 'proclamatore_id'))
->filter()
->unique();
$nomiProclamatori = \App\Models\Proclamatore::withTrashed()
->whereIn('id', $idProclamatori)
->get()
->mapWithKeys(fn ($p) => [$p->id => $p->nome_completo]);
return view('livewire.audit-log', [
'activities' => $query->paginate(30),
'activities' => $activities,
'nomiProclamatori' => $nomiProclamatori,
'events' => $events,
'users' => $users,
]);

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Campagna;
class CampagnaCreate extends Component
{
use RicordaRitorno;
public string $descrizione = '';
public string $start_date = '';
public string $end_date = '';
@@ -34,7 +37,7 @@ class CampagnaCreate extends Component
]);
session()->flash('success', "Campagna '{$campagna->descrizione}' creata.");
return $this->redirect(route('campagne.index'), navigate: true);
return $this->redirect($this->destinazione(route('campagne.index')), navigate: true);
}
public function render()

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Campagna;
class CampagnaEdit extends Component
{
use RicordaRitorno;
public Campagna $campagna;
public string $descrizione = '';
public string $start_date = '';
@@ -47,7 +50,7 @@ class CampagnaEdit extends Component
]);
session()->flash('success', "Campagna aggiornata.");
return $this->redirect(route('campagne.index'), navigate: true);
return $this->redirect($this->destinazione(route('campagne.index')), navigate: true);
}
public function render()

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Campagne;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Campagna;
class CampagnaIndex extends Component
{
use Avvisa;
use WithPagination;
public function deleteCampagna(int $id)
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
$campagna = Campagna::findOrFail($id);
$campagna->delete();
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
$this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
}
public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Campagna;
use App\Models\Assegnazione;
@@ -9,6 +10,8 @@ use App\Models\Territorio;
class CampagnaShow extends Component
{
use RicordaRitorno;
public Campagna $campagna;
public function mount(Campagna $campagna)
@@ -18,16 +21,28 @@ class CampagnaShow extends Component
public function render()
{
// Assignments counted for this campaign:
// - assigned on or after campaign start
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
->where('counted_in_campaign', true)
->where('assigned_at', '>=', $this->campagna->start_date)
// Le assegnazioni contate per la campagna, cioè quelle per cui al
// rientro si è risposto sì (campagnaApplicabile propone anche quelle
// uscite poco prima dell'inizio e rientrate durante). Le stesse della
// percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
// e l'elenco era più corto di quanto la percentuale contasse.
$conteggiate = $this->campagna->assegnazioniConteggiate()
->with(['territorio', 'proclamatore'])
->orderBy('returned_at')
->get();
// Open assignments can still be counted for this campaign only while
// no later campaign has started: on return the most recent one is
// offered (see Assegnazione::campagnaApplicabile()).
$superata = Campagna::where('start_date', '>', $this->campagna->start_date)
->where('start_date', '<=', now()->toDateString())
->exists();
$inCorso = $superata ? null : $this->campagna->assegnazioniInCorso()
->with(['territorio', 'proclamatore'])
->orderBy('assigned_at')
->get();
// All assignments that were active during this campaign range
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
->where(function ($q) {
@@ -38,6 +53,7 @@ class CampagnaShow extends Component
return view('livewire.campagne.campagna-show', [
'conteggiate' => $conteggiate,
'inCorso' => $inCorso,
'assegnateNelRange' => $assegnateNelRange,
]);
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Concerns;
/**
* Avvisi a comparsa che funzionano dopo un'azione Livewire.
*
* session()->flash() non basta: il messaggio sta nel telaio della pagina, e
* dopo un'azione Livewire ridisegna solo il componente. L'avviso compariva
* solo al caricamento completo successivo, fuori contesto, e un salvataggio
* rifiutato sembrava riuscito. Qui l'avviso parte come evento del browser e
* lo raccoglie <x-avvisi> nel telaio, che resta sempre in pagina.
*/
trait Avvisa
{
protected function avvisa(string $testo, string $tipo = 'ok'): void
{
$this->dispatch('avviso', tipo: $tipo === 'errore' ? 'errore' : 'ok', testo: $testo);
}
protected function avvisaErrore(string $testo): void
{
$this->avvisa($testo, 'errore');
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Assegnazione;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
/**
* Invio del link della piantina dopo un'assegnazione, condiviso dalla pagina
* Assegna e da "Il mio gruppo" del sorvegliante. La vista è il partial
* livewire/assegnazioni/_invio-link.
*
* Dopo l'assegnazione non si va avanti: si sceglie il canale (copia link o
* Condividi: il destinatario lo sceglie chi usa l'app dai suoi contatti, i
* proclamatori non hanno numero né email), si dice se il link è partito, e
* solo allora si va avanti. Se la procedura si interrompe, l'assegnazione
* resta con link_da_inviare e lo si segnala dove compare.
*
* Chi usa il trait dice chi può mandare il link di un'assegnazione
* (puoInviare) e dove si va dopo (paginaDopoInvio).
*/
trait InviaLink
{
/** L'assegnazione di cui si sta mandando il link. */
#[Locked]
public ?int $invioId = null;
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
#[Locked]
public ?string $canaleUsato = null;
public string $erroreInvio = '';
abstract protected function puoInviare(Assegnazione $assegnazione): bool;
abstract protected function paginaDopoInvio(): string;
/**
* Apre l'invio: subito dopo l'assegnazione, o da un'assegnazione rimasta
* in sospeso ("Invia link" nell'elenco, che lo chiede con apri-invio).
*/
#[On('apri-invio')]
public function apriInvio(int $assegnazioneId): void
{
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
if (! $assegnazione) {
return;
}
abort_unless($this->puoInviare($assegnazione), 403);
$this->invioId = $assegnazione->id;
$this->canaleUsato = null;
$this->erroreInvio = '';
}
/** Copia link o Condividi: l'invio vero lo fa chi usa l'app, qui si annota il canale. */
public function canale(string $canale): void
{
$assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) {
return;
}
$this->canaleUsato = $canale;
$this->erroreInvio = '';
}
/**
* La risposta a "Hai inviato il link?": la si registra e solo adesso si
* va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare.
*/
public function concludiInvio(bool $inviato)
{
$assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione) {
$this->invioId = null;
return null;
}
if ($inviato && $this->canaleUsato === null) {
$this->erroreInvio = 'Scegli prima come mandare il link.';
return null;
}
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
// Nel log il canale e l'id del proclamatore, mai il nome.
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
->withProperties([
'territorio' => $assegnazione->territorio?->numero,
'proclamatore_id' => $assegnazione->proclamatore_id,
'canale' => $inviato ? $this->canaleUsato : null,
])
->log($inviato ? 'link_inviato' : 'link_non_inviato');
$numero = $assegnazione->territorio?->numero;
$nome = $assegnazione->proclamatore?->nome_completo;
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
session()->flash('success', $inviato
? "Territorio {$numero} assegnato a {$nome}: link inviato."
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
return $this->redirect($this->paginaDopoInvio(), navigate: true);
}
/** L'invio in corso, per la vista; null se l'assegnazione nel frattempo è rientrata o sparita. */
protected function invioCorrente(): ?Assegnazione
{
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
if ($this->invioId && ! $invio) {
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
}
return $invio;
}
private function assegnazioneInInvio(): ?Assegnazione
{
if (! $this->invioId) {
return null;
}
$assegnazione = Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
return $assegnazione;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Concerns;
use App\Support\Ritorno;
use Livewire\Attributes\Locked;
/**
* Per le pagine che si aprono da un'altra (schede e operazioni): ricorda da
* dove si è partiti (?da=…, vedi App\Support\Ritorno) e ci torna dopo la
* conferma. Annulla e "Torna…" nella vista usano $ritorno allo stesso modo.
*
* Aperta senza `da` (menu laterale, link salvato) la pagina si comporta come
* prima: ogni componente passa la sua destinazione predefinita.
*/
trait RicordaRitorno
{
/** URL della pagina di partenza; null se aperta direttamente. */
#[Locked]
public ?string $ritorno = null;
/** Hook di Livewire: gira al mount di ogni componente che usa il trait. */
public function mountRicordaRitorno(): void
{
$this->ritorno = Ritorno::daRichiesta();
}
/** Dove andare dopo la conferma: la pagina di partenza, o quella predefinita. */
protected function destinazione(string $predefinita): string
{
return $this->ritorno ?? $predefinita;
}
}

View File

@@ -174,22 +174,46 @@ trait SegueOperazioneBackup
return true;
}
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
// premuto il pulsante vuole comunque vedere a che punto e'.
$this->operationId = $inCorso;
$this->logSpediti = 0;
$this->aggiorna();
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
$this->rifiutaPerOccupato($inCorso);
return false;
}
protected function nuovaOperazione(string $tipo): string
/**
* Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
* premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio
* dice quale operazione occupa il posto, perche' puo' essere un import
* avviato da un'altra zona della pagina, o da un altro amministratore.
*/
private function rifiutaPerOccupato(string $inCorso): void
{
$this->operationId = $inCorso;
$this->logSpediti = 0;
$this->aggiorna();
$this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso);
}
/**
* Prende il posto e crea l'operazione; null se il posto e' occupato.
*
* postoLibero() resta il controllo rapido prima di validare il resto, ma fra
* quel controllo e questo passo un'altra richiesta puo' arrivare prima — una
* seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex
* di BackupOperationState::occupa(), che si decide davvero.
*/
protected function nuovaOperazione(string $tipo): ?string
{
$operationId = (string) Str::uuid();
$stato = app(BackupOperationState::class);
$stato->initialize($operationId, $tipo, 'In coda');
$inCorso = $stato->occupa($operationId, $tipo, 'In coda');
if ($inCorso !== null) {
$this->rifiutaPerOccupato($inCorso);
return null;
}
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
$this->logSpediti = 0;

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Services\GruppiServizio;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
* gruppo si sceglie il sorvegliante, che riceve un'utenza con un'email
* fittizia ricavata dal suo nome (GruppiServizio::emailFittizia) e una
* password temporanea, mostrate qui una volta sola. Membri e pacchetto si
* gestiscono nella pagina del gruppo (GruppoShow).
*/
class GruppiIndex extends Component
{
use Avvisa;
public string $nome = '';
public ?int $sorveglianteId = null;
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
#[Locked]
public ?int $creatoId = null;
#[Locked]
public ?string $passwordTemporanea = null;
public function crea(): void
{
$this->authorize('settings.manage');
$this->validate([
'nome' => 'required|string|max:100|unique:gruppi_servizio,nome',
'sorveglianteId' => 'required|integer|exists:proclamatori,id',
], [
'nome.unique' => 'Esiste già un gruppo con questo nome.',
'sorveglianteId.required' => 'Scegli il sorvegliante.',
]);
try {
[$gruppo, $password] = app(GruppiServizio::class)->crea(
$this->nome,
Proclamatore::findOrFail($this->sorveglianteId),
);
} catch (InvalidArgumentException $e) {
$this->addError('sorveglianteId', $e->getMessage());
return;
}
$this->reset(['nome', 'sorveglianteId']);
$this->creatoId = $gruppo->id;
$this->passwordTemporanea = $password;
}
public function chiudiPassword(): void
{
$this->reset(['creatoId', 'passwordTemporanea']);
}
public function render()
{
$gruppi = GruppoServizio::with('sorvegliante')
->withCount(['membri', 'territori'])
->orderBy('nome')
->get();
// Possono diventare sorveglianti i proclamatori attivi senza gruppo (e
// quindi senza utenza: sciolto il gruppo, la si elimina).
$candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente')
->get();
return view('livewire.gruppi.gruppi-index', [
'gruppi' => $gruppi,
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
]);
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Livewire\Concerns\RicordaRitorno;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use App\Services\GruppiServizio;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Un gruppo di servizio, lato amministratore: membri (aggiungere/togliere),
* pacchetto (aggiungere in blocco, rimandare in reparto quelli non dati),
* nuova password temporanea per il sorvegliante, scioglimento.
*/
class GruppoShow extends Component
{
use Avvisa;
use RicordaRitorno;
public GruppoServizio $gruppo;
/** @var int[] proclamatori da aggiungere */
public array $nuoviMembri = [];
/** @var int[] territori da aggiungere al pacchetto */
public array $nuoviTerritori = [];
// Filtri dei territori in reparto da aggiungere al pacchetto. L'ordine è
// quello di priorità (Territorio::ordinaPerPriorita), come nella Home.
public string $cercaTerritorio = '';
public string $filtroZona = '';
public string $filtroTipologia = '';
public bool $soloPrioritari = false;
#[Locked]
public ?string $passwordTemporanea = null;
public function mount(GruppoServizio $gruppo): void
{
$this->gruppo = $gruppo;
}
private function servizio(): GruppiServizio
{
$this->authorize('settings.manage');
return app(GruppiServizio::class);
}
private function esegui(callable $azione, string $ok): void
{
try {
$azione();
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->gruppo->refresh();
$this->avvisa($ok);
}
public function aggiungiMembri(): void
{
$ids = array_map('intval', $this->nuoviMembri);
if ($ids === []) {
$this->avvisaErrore('Spunta almeno un proclamatore.');
return;
}
$this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.');
$this->nuoviMembri = [];
}
public function togliMembro(int $proclamatoreId): void
{
$membro = Proclamatore::findOrFail($proclamatoreId);
$this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo.");
}
public function aggiungiTerritori(): void
{
$ids = array_map('intval', $this->nuoviTerritori);
if ($ids === []) {
$this->avvisaErrore('Spunta almeno un territorio.');
return;
}
$this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.');
$this->nuoviTerritori = [];
}
/** Tutti i territori che i filtri mostrano finiscono tra quelli da aggiungere. */
public function selezionaMostrati(): void
{
$this->nuoviTerritori = collect($this->nuoviTerritori)
->map(fn ($id) => (string) $id)
->merge($this->candidatiFiltrati()->pluck('id')->map(fn ($id) => (string) $id))
->unique()
->values()
->all();
}
public function deselezionaTutti(): void
{
$this->nuoviTerritori = [];
}
/** I territori in reparto, in ordine di priorità. */
private function candidati(): \Illuminate\Support\Collection
{
return Territorio::ordinaPerPriorita(
Territorio::inReparto()->with(['zona', 'tipologia', 'ultimaAssegnazione'])->get()
);
}
private function candidatiFiltrati(?\Illuminate\Support\Collection $candidati = null): \Illuminate\Support\Collection
{
$cerca = Str::lower(Str::ascii(trim($this->cercaTerritorio)));
return ($candidati ?? $this->candidati())
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->when($cerca !== '', fn ($c) => $c->filter(fn (Territorio $t) => str_contains(
Str::lower(Str::ascii("{$t->numero} {$t->zona?->nome} {$t->tipologia?->nome}")),
$cerca
)))
->values();
}
public function rimanda(int $territorioId): void
{
$territorio = Territorio::findOrFail($territorioId);
$this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto.");
}
/** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */
public function nuovaPassword(): void
{
$this->servizio();
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
if (! $utente) {
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
return;
}
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
}
/**
* Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
* torna all'elenco dei gruppi.
*/
public function sciogli()
{
$nome = $this->gruppo->nome;
$this->servizio()->sciogli($this->gruppo);
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è stata eliminata.");
return $this->redirect(route('gruppi.index'), navigate: true);
}
public function render()
{
$this->gruppo->load(['sorvegliante.utente']);
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
// I territori in reparto (attivi, non assegnati, fuori da ogni
// pacchetto), in ordine di priorità; zone e tipologie dei filtri sono
// quelle che ci sono tra loro.
$candidati = $this->candidati();
return view('livewire.gruppi.gruppo-show', [
'membri' => $membri,
'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
// ...e i territori in reparto.
'totaleCandidati' => $candidati->count(),
'candidatiTerritori' => $this->candidatiFiltrati($candidati),
'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
]);
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Livewire\Concerns\InviaLink;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Services\GruppiServizio;
use Carbon\Carbon;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* "Il mio gruppo": la sola pagina del sorvegliante di gruppo. Vede il
* pacchetto del suo gruppo e i membri; dà i territori ai membri (o a sé),
* manda il link, registra i rientri (pagina Rientra), apre la piantina e
* rimanda in reparto quelli non ancora dati. Le regole stanno in
* App\Services\GruppiServizio; qui ogni azione ricontrolla che il territorio
* o l'assegnazione siano del suo gruppo.
*/
class MioGruppo extends Component
{
use Avvisa;
use InviaLink;
/** Il territorio del pacchetto che si sta assegnando. */
#[Locked]
public ?int $territorioDaAssegnare = null;
public ?int $membroId = null;
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 = '';
#[Url(except: '')]
public string $filtroTipologia = '';
/** '' tutti, 'da_dare', 'dati' */
#[Url(except: '')]
public string $filtroStato = '';
#[Url(except: false)]
public bool $soloPrioritari = false;
public function mount(): void
{
$this->dataAssegnazione = now()->format('Y-m-d');
}
private function gruppo(): ?GruppoServizio
{
return auth()->user()->gruppoSorvegliato();
}
private function gruppoOFallisci(): GruppoServizio
{
$gruppo = $this->gruppo();
abort_unless($gruppo, 403, 'Non sei il sorvegliante di un gruppo di servizio attivo.');
return $gruppo;
}
private function territorioDelPacchetto(int $id): Territorio
{
$gruppo = $this->gruppoOFallisci();
$territorio = Territorio::find($id);
abort_unless($territorio && $territorio->gruppo_servizio_id === $gruppo->id, 403);
return $territorio;
}
// ─── Assegnare un territorio del pacchetto ──────────────────
public function apriAssegnazione(int $territorioId): void
{
$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', 'cartaceo']);
$this->resetValidation();
}
public function assegna(): void
{
$this->validate([
'membroId' => 'required|integer',
'dataAssegnazione' => 'required|date|before_or_equal:today',
], [
'membroId.required' => 'Scegli a chi dare il territorio.',
'dataAssegnazione.before_or_equal' => 'La data non può essere nel futuro.',
]);
$gruppo = $this->gruppoOFallisci();
$territorio = $this->territorioDelPacchetto((int) $this->territorioDaAssegnare);
$membro = Proclamatore::find($this->membroId);
if (! $membro) {
$this->addError('membroId', 'Scegli un membro del gruppo.');
return;
}
try {
$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', 'cartaceo']);
// Come dalla pagina Assegna: si apre l'invio del link.
$this->apriInvio($assegnazione->id);
}
// ─── Rimandare in reparto ───────────────────────────────────
public function rimanda(int $territorioId): void
{
$territorio = $this->territorioDelPacchetto($territorioId);
try {
app(GruppiServizio::class)->rimandaInReparto($this->gruppoOFallisci(), $territorio);
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->avvisa("Territorio {$territorio->numero} rimandato in reparto.");
}
// ─── Link già inviato / condiviso dall'elenco ───────────────
private function assegnazioneDelGruppo(int $id): Assegnazione
{
$assegnazione = Assegnazione::aperte()->with('territorio')->find($id);
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
return $assegnazione;
}
public function toggleLinkSent(int $assegnazioneId): void
{
$assegnazione = $this->assegnazioneDelGruppo($assegnazioneId);
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
}
/** "Condividi" dall'elenco andato a buon fine (elencoCercabile.condividi). */
public function segnaCondiviso(int $assegnazioneId): void
{
$this->assegnazioneDelGruppo($assegnazioneId)->registraInvioLink(true, 'condividi');
}
// ─── Invio del link (trait InviaLink) ───────────────────────
protected function puoInviare(Assegnazione $assegnazione): bool
{
$gruppo = $this->gruppo();
return $gruppo !== null
&& ($assegnazione->gruppo_servizio_id === $gruppo->id
|| $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id);
}
protected function paginaDopoInvio(): string
{
return route('mio-gruppo');
}
public function render()
{
$gruppo = $this->gruppo()?->load('sorvegliante');
// Prima quelli da dare, in ordine di priorità come "Da assegnare"
// nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
// più vecchio.
$pacchetto = $gruppo
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
: collect();
[$dati, $daDare] = $pacchetto->partition(fn (Territorio $t) => $t->assegnazioneCorrente !== null);
$pacchetto = Territorio::ordinaPerPriorita($daDare)
->concat($dati->sortBy(fn (Territorio $t) => $t->assegnazioneCorrente->assigned_at)->values());
$territori = $pacchetto
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->filtroStato === 'da_dare', fn ($c) => $c->whereNull('assegnazioneCorrente'))
->when($this->filtroStato === 'dati', fn ($c) => $c->whereNotNull('assegnazioneCorrente'))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->values();
$membri = $gruppo
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
: collect();
return view('livewire.gruppi.mio-gruppo', [
'gruppo' => $gruppo,
'pacchetto' => $pacchetto,
'territori' => $territori,
'zone' => $pacchetto->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologie' => $pacchetto->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
'invio' => $this->invioCorrente(),
]);
}
}

View File

@@ -3,50 +3,67 @@
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');
$settings = Setting::instance();
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$territori = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->get()
->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($p !== 0) return $p;
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($g !== 0) return $g;
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->values();
$territori = Territorio::ordinaPerPriorita(
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
);
$pdf = Pdf::loadView('pdf.territori-lista', [
'titolo' => 'Territori da Assegnare',
@@ -90,7 +107,6 @@ class Home extends Component
{
$settings = Setting::instance();
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva();
@@ -149,46 +165,11 @@ class Home extends Component
];
}
// Quick lists
$territoriDaAssegnare = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->get()
->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->take($homeLimit)
->values();
// Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
// (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
$territoriDaAssegnare = Territorio::ordinaPerPriorita(
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
)->take($homeLimit)->values();
$daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore'])
@@ -208,6 +189,7 @@ class Home extends Component
'homeLimit' => $homeLimit,
'territoriDaAssegnare' => $territoriDaAssegnare,
'daRientrare' => $daRientrare,
'richieste' => $this->richiesteAperte(),
]);
}
}

View File

@@ -2,45 +2,54 @@
namespace App\Livewire\Piantine;
use App\Livewire\Concerns\Avvisa;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\Tipologia;
use App\Models\Zona;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Editor delle piantine a schermo intero (solo amministratore).
*
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
* modulo che poi gira di mano in mano.
* Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
* disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
* La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
* (anteprima e conferma, nel componente Pubblicazione). I dati del territorio
* — numero, zona, tipologia, confini — sono invece dati del gestionale e si
* salvano subito, come dalla scheda territorio.
*
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e
* solo dopo si compila la scheda del territorio — che può anche non esistere
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
* Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
* aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
* disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
*
* Con la mappa si parla per eventi, e solo con il necessario: i dati di tutte
* le piantine li chiede la mappa una volta (datiMappa), poi riceve solo la
* piantina cambiata. Prima ogni richiesta rispediva tutte le geometrie e
* l'area KML, fino a una decina di MB per aprire un pannello.
*/
#[Layout('components.layouts.editor')]
class PiantineEditor extends Component
{
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
use Avvisa;
/** Piantina aperta, o null quando non ce n'è nessuna selezionata. */
#[Locked]
public ?int $mappaId = null;
public string $nome = '';
/** Geometria appena disegnata, non ancora salvata. */
public ?string $geometriaInAttesa = null;
/** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
public string $salvatoAlle = '';
public bool $pannelloAperto = false;
// ─── Scheda del territorio ──────────────────────────────────────────────
// ─── Territorio ─────────────────────────────────────────────────────────
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
public string $territorioScelto = '';
@@ -53,15 +62,24 @@ class PiantineEditor extends Component
public string $confini = '';
/** Campi di creazione al volo, aperti solo quando servono. */
public string $nuovaZona = '';
public string $nuovaTipologia = '';
/**
* Arrivati da "Nuovo territorio" (?nuovo=1): la mappa parte già in
* disegno, e la prima piantina disegnata si apre con "nuovo territorio"
* scelto, pronta da compilare e creare.
*/
#[Locked]
public bool $creaTerritorio = false;
public function mount(?int $mappa = null): void
{
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
$this->apri($mappa);
$this->carica(TerritorioMappa::with('territorio')->find($mappa));
} elseif (request()->boolean('nuovo')) {
$this->creaTerritorio = true;
}
}
@@ -73,38 +91,46 @@ class PiantineEditor extends Component
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
if (! $mappa) {
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
return;
}
$this->mappaId = $mappa->id;
$this->nome = (string) $mappa->nome;
$this->geometriaInAttesa = null;
$this->pannelloAperto = true;
$this->caricaScheda($mappa->territorio);
$this->aggiornaMappaJs(inquadra: true);
$this->carica($mappa);
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
}
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
public function nuova(): void
{
$this->svuota();
$this->dispatch('piantina-selezionata', mappaId: null, inquadra: false);
$this->dispatch('editor-disegna');
}
private function carica(TerritorioMappa $mappa): void
{
$this->resetValidation();
$this->mappaId = $mappa->id;
$this->nome = (string) $mappa->nome;
$this->salvatoAlle = '';
$this->nuovaZona = '';
$this->nuovaTipologia = '';
$this->caricaTerritorio($mappa->territorio);
}
private function svuota(): void
{
$this->resetValidation();
$this->reset([
'mappaId', 'nome', 'geometriaInAttesa',
'mappaId', 'nome', 'salvatoAlle',
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
'nuovaZona', 'nuovaTipologia',
]);
$this->resetValidation();
$this->pannelloAperto = true;
$this->aggiornaMappaJs();
}
public function chiudiPannello(): void
{
$this->pannelloAperto = false;
}
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
private function caricaScheda(?Territorio $territorio): void
private function caricaTerritorio(?Territorio $territorio): void
{
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
$this->numero = (string) ($territorio->numero ?? '');
@@ -113,12 +139,123 @@ class PiantineEditor extends Component
$this->confini = (string) ($territorio->confini ?? '');
}
/** Cambiare territorio nella tendina ricarica la sua scheda. */
// ─── Confine (bozza, salvataggio automatico) ────────────────────────────
/**
* Arriva dalla mappa dopo un disegno o lo spostamento di un vertice.
* $nuova: il disegno è una piantina nuova (strumento "Disegna"), non il
* confine ridisegnato di quella aperta.
*/
#[On('geometria-modificata')]
public function salvaGeometria($geometry, bool $nuova = false): void
{
if (! $this->geometriaValida($geometry)) {
$this->avvisaErrore('Il confine disegnato non è valido: ridisegnalo.');
return;
}
$geojson = json_encode($geometry);
$mappa = (! $nuova && $this->mappaId) ? TerritorioMappa::find($this->mappaId) : null;
if ($mappa) {
$mappa->update(['geojson' => $geojson]);
} else {
$mappa = TerritorioMappa::create(['geojson' => $geojson]);
$this->carica($mappa);
activity()->causedBy(auth()->user())->log('piantina_creata');
if ($this->creaTerritorio) {
$this->creaTerritorio = false;
$this->territorioScelto = 'nuovo';
}
}
$this->salvato($mappa, seleziona: true);
}
/**
* Anelli chiusi di almeno quattro punti con coordinate nei limiti: un
* record malformato faceva fallire il disegno di tutte le piantine.
*/
private function geometriaValida($geometry): bool
{
if (! is_array($geometry) || ! is_array($geometry['coordinates'] ?? null)) {
return false;
}
$poligoni = match ($geometry['type'] ?? null) {
'Polygon' => [$geometry['coordinates']],
'MultiPolygon' => $geometry['coordinates'],
default => null,
};
if (! $poligoni) {
return false;
}
foreach ($poligoni as $anelli) {
if (! is_array($anelli) || $anelli === []) {
return false;
}
foreach ($anelli as $anello) {
if (! is_array($anello) || count($anello) < 4) {
return false;
}
foreach ($anello as $punto) {
if (! is_array($punto) || count($punto) < 2
|| ! is_numeric($punto[0]) || ! is_numeric($punto[1])
|| abs($punto[0]) > 180 || abs($punto[1]) > 90) {
return false;
}
}
}
}
return true;
}
/** Riporta il confine a quello pubblicato. */
public function scartaModifiche(): void
{
$mappa = $this->mappaAperta();
if (! $mappa || ! $mappa->bozzaScartabile()) {
return;
}
$mappa->update(['geojson' => $mappa->geojson_pubblicato]);
$this->salvato($mappa, seleziona: true);
$this->avvisa('Confine riportato a quello pubblicato.');
}
public function updatedNome(): void
{
$mappa = $this->mappaAperta();
if (! $mappa) {
return;
}
$nome = trim($this->nome);
$mappa->update(['nome' => $nome !== '' ? mb_substr($nome, 0, 120) : null]);
$this->salvato($mappa);
}
// ─── Collegamento al territorio ─────────────────────────────────────────
public function updatedTerritorioScelto(string $valore): void
{
$this->resetValidation();
$mappa = $this->mappaAperta();
if (! $mappa) {
return;
}
if ($valore === 'nuovo') {
// Si compilano i campi e si crea con il pulsante: un territorio
// non nasce da un salvataggio automatico a metà digitazione.
$this->numero = '';
$this->zonaId = '';
$this->tipologiaId = '';
@@ -127,34 +264,158 @@ class PiantineEditor extends Component
return;
}
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
}
if ($valore === '') {
$numero = $mappa->territorio?->numero;
$mappa->update([
'territorio_id' => null,
// Senza territorio serve un'etichetta, altrimenti la piantina
// finisce in elenco come un anonimo "Piantina #12".
'nome' => $mappa->nome ?: ($numero ? "Ex territorio {$numero}" : null),
]);
$this->nome = (string) $mappa->nome;
$this->caricaTerritorio(null);
$this->salvato($mappa);
// ─── Disegno ────────────────────────────────────────────────────────────
#[On('poligono-disegnato')]
public function poligonoDisegnato($geometry): void
{
if (! is_array($geometry)
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|| ! is_array($geometry['coordinates'] ?? null)) {
return;
}
$this->geometriaInAttesa = json_encode($geometry);
$this->pannelloAperto = true;
$territorio = Territorio::find($valore);
if (! $territorio) {
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
$this->caricaTerritorio($mappa->territorio);
return;
}
$occupata = TerritorioMappa::where('territorio_id', $territorio->id)->whereKeyNot($mappa->id)->exists();
if ($occupata) {
$this->addError('territorioScelto', "Il territorio {$territorio->numero} ha già una piantina.");
$this->caricaTerritorio($mappa->territorio);
return;
}
$mappa->update(['territorio_id' => $territorio->id]);
$this->caricaTerritorio($territorio);
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
$this->salvato($mappa);
}
public function scartaDisegno(): void
public function creaTerritorio(): void
{
$this->geometriaInAttesa = null;
$this->aggiornaMappaJs();
$mappa = $this->mappaAperta();
if (! $mappa || $this->territorioScelto !== 'nuovo') {
return;
}
$this->numero = trim($this->numero);
$this->confini = trim($this->confini);
$this->validate(
[
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
],
$this->messaggiTerritorio(),
);
try {
$territorio = DB::transaction(function () use ($mappa) {
$territorio = Territorio::create($this->valoriTerritorio() + ['attivo' => true]);
$mappa->update(['territorio_id' => $territorio->id]);
return $territorio;
});
} catch (\Illuminate\Database\UniqueConstraintViolationException) {
$this->addError('numero', 'Esiste già un territorio con questo numero.');
return;
}
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
$this->caricaTerritorio($territorio);
$this->salvato($mappa);
$this->avvisa("Territorio {$territorio->numero} creato e collegato.");
}
// ─── Dati del territorio (salvati subito nel gestionale) ────────────────
public function updatedNumero(): void
{
$this->salvaCampoTerritorio('numero');
}
public function updatedZonaId(): void
{
$this->salvaCampoTerritorio('zonaId');
}
public function updatedTipologiaId(): void
{
$this->salvaCampoTerritorio('tipologiaId');
}
public function updatedConfini(): void
{
$this->salvaCampoTerritorio('confini');
}
private function salvaCampoTerritorio(string $campo): void
{
// In creazione i campi si raccolgono e basta: li salva creaTerritorio().
$territorio = $this->territorioCollegato();
if (! $territorio) {
return;
}
$this->numero = trim($this->numero);
$regole = [
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')->ignore($territorio->id)],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
];
$this->validateOnly($campo, $regole, $this->messaggiTerritorio());
// Solo la colonna del campo cambiato: con un altro campo ancora non
// valido a schermo, riscrivere tutto salverebbe anche quel valore.
$colonna = ['numero' => 'numero', 'zonaId' => 'zona_id', 'tipologiaId' => 'tipologia_id', 'confini' => 'confini'][$campo];
$territorio->update([$colonna => $this->valoriTerritorio()[$colonna]]);
$this->salvato($this->mappaAperta());
}
private function valoriTerritorio(): array
{
return [
'numero' => trim($this->numero),
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
];
}
private function messaggiTerritorio(): array
{
return [
'numero.required' => 'Il territorio ha bisogno di un numero.',
'numero.unique' => 'Esiste già un territorio con questo numero.',
'numero.max' => 'Il numero può avere al massimo 20 caratteri.',
'confini.max' => 'I confini possono avere al massimo 5000 caratteri.',
];
}
// ─── Zona e tipologia create sul posto ──────────────────────────────────
public function creaZona()
public function creaZona(): void
{
$this->nuovaZona = trim($this->nuovaZona);
$dati = $this->validate(
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
[
@@ -163,16 +424,17 @@ class PiantineEditor extends Component
],
);
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]);
$zona = Zona::create(['nome' => $dati['nuovaZona'], 'attivo' => true]);
$this->zonaId = (string) $zona->id;
$this->nuovaZona = '';
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
$this->updatedZonaId();
$this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
}
public function creaTipologia()
public function creaTipologia(): void
{
$this->nuovaTipologia = trim($this->nuovaTipologia);
$dati = $this->validate(
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
[
@@ -181,221 +443,170 @@ class PiantineEditor extends Component
],
);
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]);
$tipologia = Tipologia::create(['nome' => $dati['nuovaTipologia'], 'attivo' => true]);
$this->tipologiaId = (string) $tipologia->id;
$this->nuovaTipologia = '';
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
$this->updatedTipologiaId();
$this->avvisa("Tipologia «{$tipologia->nome}» creata e selezionata.");
}
// ─── Salvataggio ────────────────────────────────────────────────────────
// ─── Pubblicazione ed eliminazione ──────────────────────────────────────
public function salva()
public function pubblica(): void
{
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null;
if (! $mappa && ! $this->geometriaInAttesa) {
session()->flash('error', 'Disegna il poligono prima di salvare.');
return;
}
$territorio = $this->salvaScheda($mappa);
// salvaScheda() torna false quando la validazione della scheda non
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
if ($territorio === false) {
return;
}
$valori = [
'territorio_id' => $territorio?->id,
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
];
if ($this->geometriaInAttesa) {
$valori['geojson'] = $this->geometriaInAttesa;
}
if ($mappa) {
$mappa->update($valori);
} else {
$mappa = TerritorioMappa::create($valori);
$this->mappaId = $mappa->id;
}
$this->geometriaInAttesa = null;
activity()->causedBy(auth()->user())->log('piantina_salvata');
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
$this->aggiornaMappaJs();
}
/**
* Crea o aggiorna il territorio della scheda.
*
* @return Territorio|null|false Il territorio collegato, null se la
* piantina resta scollegata, false se la
* scheda non è valida.
*/
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
{
if ($this->territorioScelto === '') {
return null;
}
$esistente = $this->territorioScelto === 'nuovo'
? null
: Territorio::find($this->territorioScelto);
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
session()->flash('error', 'Il territorio scelto non esiste più.');
return false;
}
try {
$this->validate(
[
'numero' => [
'required', 'string', 'max:20',
Rule::unique('territori', 'numero')->ignore($esistente?->id),
],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
],
[
'numero.required' => 'Il territorio ha bisogno di un numero.',
'numero.unique' => 'Esiste già un territorio con questo numero.',
],
['numero' => 'numero'],
);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->pannelloAperto = true;
throw $e;
}
// Un territorio ha una piantina sola.
if ($esistente) {
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
->exists();
if ($occupato) {
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
return false;
}
}
$valori = [
'numero' => trim($this->numero),
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
];
if ($esistente) {
$esistente->update($valori);
return $esistente;
}
$territorio = Territorio::create($valori + ['attivo' => true]);
$this->territorioScelto = (string) $territorio->id;
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
return $territorio;
}
// ─── Immagine ed eliminazione ───────────────────────────────────────────
public function generaImmagine(PiantinaRenderer $renderer)
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
$mappa = $this->mappaAperta();
if (! $mappa) {
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
return;
}
if ($motivo = $mappa->motivoNonPubblicabile()) {
$this->avvisaErrore($motivo);
return;
}
try {
$renderer->render($mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
}
public function elimina()
#[On('piantina-pubblicata')]
public function pubblicata(int $mappaId): void
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
if ($mappa) {
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
}
}
public function elimina(): void
{
$mappa = $this->mappaAperta();
if (! $mappa) {
return;
}
$etichetta = $mappa->etichetta();
$id = $mappa->id;
if ($mappa->image_path) {
Storage::disk('public')->delete($mappa->image_path);
}
$mappa->delete();
$this->nuova();
session()->flash('success', "{$etichetta} eliminata.");
$this->svuota();
$this->dispatch('piantina-rimossa', mappaId: $id);
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
}
// ─── Dati per la mappa ──────────────────────────────────────────────────
public function datiMappa(?PiantineArea $area = null): array
/** Chiamata una volta dalla mappa all'avvio ($wire.datiMappa()). */
public function datiMappa(PiantineArea $area): array
{
$area ??= app(PiantineArea::class);
$piantine = TerritorioMappa::with('territorio')->get()
->filter(fn ($m) => $m->geometria() !== null)
->map(fn ($m) => [
'mappaId' => $m->id,
'etichetta' => $m->etichetta(),
'collegata' => $m->collegata(),
'geometry' => $m->geometria(),
])
->values()
->all();
return [
'area' => $area->area(),
'piantine' => $piantine,
'piantine' => TerritorioMappa::with('territorio.zona')->get()
->filter(fn (TerritorioMappa $m) => $m->geometria() !== null)
->map(fn (TerritorioMappa $m) => $this->datiPiantina($m))
->values()
->all(),
'selezionata' => $this->mappaId,
];
}
private function aggiornaMappaJs(bool $inquadra = false): void
private function datiPiantina(TerritorioMappa $mappa): array
{
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
return [
'mappaId' => $mappa->id,
'etichetta' => $mappa->etichetta(),
'collegata' => $mappa->collegata(),
'stato' => $mappa->stato(),
'statoEtichetta' => $mappa->statoEtichetta(),
'geometry' => $mappa->geometria(),
];
}
public function render(PiantineArea $area)
/** Dopo ogni salvataggio automatico: ora in testata, mappa e indirizzo allineati. */
private function salvato(?TerritorioMappa $mappa, bool $seleziona = false): void
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
if (! $mappa) {
return;
}
$mappa = $mappa->fresh(['territorio.zona']);
$this->salvatoAlle = now()->format('H:i');
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
if ($seleziona) {
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: false);
}
// La piantina aperta resta nell'indirizzo: ricaricando la pagina si
// torna su questa e non su un editor vuoto.
$this->dispatch('editor-indirizzo', url: route('piantine.editor', $mappa->id));
}
private function mappaAperta(): ?TerritorioMappa
{
return $this->mappaId ? TerritorioMappa::with('territorio.zona')->find($this->mappaId) : null;
}
private function territorioCollegato(): ?Territorio
{
if ($this->territorioScelto === '' || $this->territorioScelto === 'nuovo') {
return null;
}
$mappa = $this->mappaAperta();
return $mappa?->territorio;
}
public function render()
{
$mappa = $this->mappaAperta();
// Nella tendina: i territori attivi senza piantina, più quello già
// collegato anche se nel frattempo è stato disattivato o cestinato.
$occupati = TerritorioMappa::whereNotNull('territorio_id')
->when($mappa, fn ($q) => $q->whereKeyNot($mappa->id))
->pluck('territorio_id');
$territori = Territorio::attivi()
->with(['zona:id,nome', 'tipologia:id,nome'])
->whereNotIn('id', $occupati)
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero', 'zona_id', 'tipologia_id', 'attivo', 'deleted_at']);
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
$territori->prepend($mappa->territorio);
}
// Il campo di scelta con ricerca (<x-scelta>): due voci fisse in cima,
// poi i territori, che qui restano sceglibili anche se cestinati o
// disattivati (è il collegamento che già c'era).
$opzioniTerritori = [
['valore' => '', 'etichetta' => '— nessun territorio —', 'speciale' => true],
['valore' => 'nuovo', 'etichetta' => '+ Crea un territorio nuovo', 'speciale' => true],
...$territori->map(function (Territorio $t) {
$opzione = $t->opzioneScelta(nota: $t->trashed() ? 'nel cestino' : (! $t->attivo ? 'disattivato' : null));
$opzione['etichetta'] = str_replace('N° ', 'Territorio ', $opzione['etichetta']);
return $opzione;
})->all(),
];
return view('livewire.piantine.piantine-editor', [
'mappa' => $mappa,
'areaCaricata' => $area->area() !== null,
'datiMappaIniziali' => $this->datiMappa($area),
'territori' => Territorio::attivi()
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero']),
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
'opzioniTerritori' => $opzioniTerritori,
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
'title' => 'Editor delle piantine',
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
]);
}
}

View File

@@ -2,38 +2,60 @@
namespace App\Livewire\Piantine;
use App\Models\Territorio;
use App\Livewire\Concerns\Avvisa;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Services\Piantine\PiantinaPdfService;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
/**
* Pagina di gestione delle piantine (solo amministratore).
*
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su
* una mappa larga un terzo di schermo non si taglia un confine.
* È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
* con modifiche da pubblicare), l'area della città e lo storico dei PDF
* sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
* da qui si apre, si pubblica (con la stessa finestra di anteprima
* dell'editor) e si elimina.
*/
class PiantineManager extends Component
{
use Avvisa;
use WithFileUploads;
use WithPagination;
public const FILTRI = [
'tutte' => 'Tutte',
'bozze' => 'Bozze',
'da-pubblicare' => 'Da pubblicare',
'pubblicate' => 'Pubblicate',
'scollegate' => 'Scollegate',
];
#[Url(as: 'q', except: '')]
public string $cerca = '';
#[Url(except: 'tutte')]
public string $filtro = 'tutte';
public $kmlFile = null;
/** Piantina di cui è aperto il pannello "collega a un territorio". */
public ?int $collegamentoAperto = null;
public ?int $territorioDaCollegare = null;
public function updatedFiltro(): void
{
if (! array_key_exists($this->filtro, self::FILTRI)) {
$this->filtro = 'tutte';
}
}
// ─── Area città (KML) ───────────────────────────────────────────────────
public function caricaKml(PiantineArea $area)
public function caricaKml(PiantineArea $area): void
{
$this->validate(
['kmlFile' => 'required|file|max:10240'],
@@ -62,111 +84,42 @@ class PiantineManager extends Component
$this->kmlFile = null;
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
$this->avvisa("Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
}
// ─── Generazione ────────────────────────────────────────────────────────
// ─── Piantine ───────────────────────────────────────────────────────────
public function generaImmagine(int $mappaId, PiantinaRenderer $renderer)
public function pubblica(int $mappaId): void
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
try {
$renderer->render($mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
if (! $mappa) {
$this->avvisaErrore('La piantina non esiste più.');
return;
}
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
}
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
if (! $mappa->collegata()) {
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
if ($motivo = $mappa->motivoNonPubblicabile()) {
$this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
return;
}
try {
$pdfService->sostituisci($mappa->territorio, $mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
}
/** La finestra di pubblicazione ha finito: basta ridisegnare l'elenco. */
#[On('piantina-pubblicata')]
public function pubblicata(): void {}
public function elimina(int $mappaId): void
{
$mappa = TerritorioMappa::with('territorio')->find($mappaId);
if (! $mappa) {
return;
}
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
}
// ─── Collegamento al territorio ─────────────────────────────────────────
public function apriCollegamento(int $mappaId): void
{
$this->collegamentoAperto = $mappaId;
$this->territorioDaCollegare = TerritorioMappa::find($mappaId)?->territorio_id;
}
public function chiudiCollegamento(): void
{
$this->collegamentoAperto = null;
$this->territorioDaCollegare = null;
}
public function collega(int $mappaId)
{
$mappa = TerritorioMappa::findOrFail($mappaId);
if (! $this->territorioDaCollegare) {
session()->flash('error', 'Scegli il territorio a cui collegare la piantina.');
return;
}
$territorio = Territorio::findOrFail($this->territorioDaCollegare);
// Un territorio ha una piantina sola: se ne aveva già un'altra il
// vincolo di unicità la respingerebbe con un errore di database, che
// non spiega niente a chi sta guardando la pagina.
$occupato = TerritorioMappa::where('territorio_id', $territorio->id)
->where('id', '!=', $mappa->id)
->exists();
if ($occupato) {
session()->flash('error', "Il territorio {$territorio->numero} ha già una piantina: scollegala prima di assegnargli questa.");
return;
}
$mappa->update(['territorio_id' => $territorio->id]);
$this->chiudiCollegamento();
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
}
public function scollega(int $mappaId)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
$numero = $mappa->territorio?->numero ?? '?';
// Senza territorio serve un'etichetta, altrimenti la piantina sparisce
// dall'elenco sotto un anonimo "Piantina #12".
$mappa->update([
'territorio_id' => null,
'nome' => $mappa->nome ?: "Ex territorio {$numero}",
]);
session()->flash('success', "Piantina scollegata dal territorio {$numero}. Il PDF del territorio resta com'è.");
}
public function eliminaPiantina(int $mappaId)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
$etichetta = $mappa->etichetta();
if ($mappa->image_path) {
@@ -174,59 +127,106 @@ class PiantineManager extends Component
}
$mappa->delete();
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
}
// ─── Storico versioni ───────────────────────────────────────────────────
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService)
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService): void
{
$versione = TerritorioPdfVersione::with('territorio')->findOrFail($versioneId);
$versione = TerritorioPdfVersione::with('territorio')->find($versioneId);
if (! $versione) {
return;
}
$numero = $versione->territorio?->numero ?? '?';
try {
$pdfService->ripristina($versione);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
$this->avvisaErrore($e->getMessage());
return;
}
session()->flash('success', "Versione precedente ripristinata per il territorio {$numero}.");
$this->avvisa("Versione precedente ripristinata per il territorio {$numero}. Il PDF che c'era è nello storico.");
}
public function eliminaVersione(int $versioneId)
public function eliminaVersione(int $versioneId): void
{
$versione = TerritorioPdfVersione::findOrFail($versioneId);
$versione = TerritorioPdfVersione::find($versioneId);
if (Storage::disk('public')->exists($versione->pdf_path)) {
Storage::disk('public')->delete($versione->pdf_path);
if (! $versione) {
return;
}
Storage::disk('public')->delete($versione->pdf_path);
$versione->delete();
session()->flash('success', 'Versione eliminata definitivamente.');
$this->avvisa('Versione eliminata definitivamente.');
}
// ─── Dati per la vista ──────────────────────────────────────────────────
/**
* Tutte le piantine con il loro stato, in memoria: sono qualche centinaio
* al massimo, e lo stato dipende dall'impronta, che si calcola in PHP.
*
* @return Collection<int, TerritorioMappa>
*/
private function piantine(): Collection
{
return TerritorioMappa::with('territorio.zona')
->get()
->sortBy(fn (TerritorioMappa $m) => [
$m->territorio_id === null ? 1 : 0,
strlen((string) $m->territorio?->numero),
(string) $m->territorio?->numero,
mb_strtolower((string) $m->nome),
])
->values();
}
private function corrisponde(TerritorioMappa $mappa, string $filtro): bool
{
return match ($filtro) {
'bozze' => $mappa->stato() === TerritorioMappa::STATO_BOZZA,
'da-pubblicare' => $mappa->stato() === TerritorioMappa::STATO_MODIFICATA,
'pubblicate' => $mappa->stato() === TerritorioMappa::STATO_PUBBLICATA,
'scollegate' => ! $mappa->collegata(),
default => true,
};
}
public function render(PiantineArea $area)
{
$piantine = $this->piantine();
$conteggi = collect(self::FILTRI)
->map(fn ($etichetta, $filtro) => $piantine->filter(fn ($m) => $this->corrisponde($m, $filtro))->count());
$cerca = mb_strtolower(trim($this->cerca));
$elenco = $piantine
->filter(fn ($m) => $this->corrisponde($m, $this->filtro))
->filter(fn (TerritorioMappa $m) => $cerca === '' || str_contains(mb_strtolower(implode(' ', [
$m->etichetta(),
$m->territorio?->numero,
$m->nome,
$m->territorio?->zona?->nome,
])), $cerca))
->values();
return view('livewire.piantine.piantine-manager', [
'config' => $area->config(),
'piantine' => TerritorioMappa::with('territorio')
->get()
->sortBy(fn ($m) => [
$m->collegata() ? 0 : 1,
strlen((string) $m->territorio?->numero),
(string) $m->territorio?->numero,
(string) $m->nome,
])
->values(),
'territori' => Territorio::attivi()
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero']),
'elenco' => $elenco,
'totale' => $piantine->count(),
'conteggi' => $conteggi,
'versioni' => TerritorioPdfVersione::with('territorio')
->orderByDesc('sostituito_il')
->limit(30)
->get(),
->orderByDesc('id')
->paginate(15, pageName: 'versioni'),
'title' => 'Piantine dei territori',
]);
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Livewire\Piantine;
use App\Livewire\Concerns\Avvisa;
use App\Models\TerritorioMappa;
use App\Services\Piantine\PubblicazionePiantina;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* La finestra di pubblicazione, uguale nell'editor e nella gestione.
*
* Si apre con l'evento "pubblica-piantina", prepara l'anteprima in coda
* (controllando a intervalli quando è pronta), la mostra, e solo su
* "Conferma" sostituisce il PDF del territorio. Chiuderla butta l'anteprima.
*/
class Pubblicazione extends Component
{
use Avvisa;
#[Locked]
public ?int $mappaId = null;
#[Locked]
public ?string $token = null;
public string $fase = '';
public string $messaggio = '';
public string $etichetta = '';
#[On('pubblica-piantina')]
public function apri(int $mappaId, PubblicazionePiantina $pubblicazione): void
{
$this->chiudi($pubblicazione);
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
if (! $mappa) {
$this->avvisaErrore('La piantina non esiste più.');
return;
}
$this->mappaId = $mappa->id;
$this->etichetta = $mappa->etichetta();
try {
$this->token = $pubblicazione->avvia($mappa);
} catch (\RuntimeException $e) {
$this->fase = PubblicazionePiantina::ERRORE;
$this->messaggio = $e->getMessage();
return;
}
$this->aggiorna($pubblicazione);
}
/** Chiamata a intervalli finché l'anteprima è in preparazione. */
public function aggiorna(PubblicazionePiantina $pubblicazione): void
{
if (! $this->token) {
return;
}
$stato = $pubblicazione->stato($this->token);
if (! $stato) {
$this->fase = PubblicazionePiantina::ERRORE;
$this->messaggio = 'L\'anteprima è scaduta: generala di nuovo.';
return;
}
$this->fase = $stato['stato'];
$this->messaggio = $stato['messaggio'] ?? '';
}
public function conferma(PubblicazionePiantina $pubblicazione): void
{
if (! $this->token) {
return;
}
try {
$mappa = $pubblicazione->conferma($this->token);
} catch (\RuntimeException $e) {
$this->fase = PubblicazionePiantina::ERRORE;
$this->messaggio = $e->getMessage();
return;
}
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
$this->avvisa("{$mappa->etichetta()} pubblicata: il PDF del territorio è stato sostituito, il precedente è nello storico.");
$this->dispatch('piantina-pubblicata', mappaId: $mappa->id);
}
/** Riprova dopo un errore, con lo stato attuale della piantina. */
public function riprova(PubblicazionePiantina $pubblicazione): void
{
if ($this->mappaId) {
$this->apri($this->mappaId, $pubblicazione);
}
}
public function chiudi(PubblicazionePiantina $pubblicazione): void
{
if ($this->token) {
$pubblicazione->annulla($this->token);
}
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
}
public function render()
{
return view('livewire.piantine.pubblicazione');
}
}

View File

@@ -2,18 +2,89 @@
namespace App\Livewire;
use Livewire\Component;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
/**
* Informativa privacy (solo amministratore).
*
* Tutto ciò che può cambiare si legge qui, al momento: impostazioni
* (congregazione, conservazione del log, scadenza dei link, download del
* PDF, server di posta), configurazione (indirizzo, cifratura, durata della
* sessione), ruoli con i loro permessi reali e quanti dati sono conservati.
* Il testo resta nella vista; i numeri non vanno mai scritti a mano.
*/
class Privacy extends Component
{
/** Data dell'ultima revisione del testo dell'informativa. */
public const REVISIONE = '21 ottobre 2026';
/** I permessi spiegati a chi legge l'informativa. */
public const PERMESSI = [
'settings.manage' => 'impostazioni, utenti, backup e import',
'proclamatori.manage' => 'anagrafica dei proclamatori',
'gruppo.gestisci' => 'solo il proprio gruppo di servizio: i territori del pacchetto e i membri del gruppo',
'territori.manage' => 'gestione dei territori e delle piantine',
'campagne.manage' => 'campagne',
'registro.view' => 'consultazione del registro delle assegnazioni',
'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à',
];
public function render()
{
$settings = Setting::instance();
$impostazioni = Setting::instance();
$posta = app(ConfigurazionePosta::class);
$hostPosta = $posta->configurataNellApp()
? (string) $impostazioni->mail_host
: (string) config('mail.mailers.smtp.host');
$indirizzo = (string) config('app.url');
return view('livewire.privacy', [
'congregazione' => $settings->congregazione_nome ?? 'Congregazione',
'auditRetention' => $settings->audit_retention_days ?? 365,
'revisione' => self::REVISIONE,
'congregazione' => $impostazioni->congregazione_nome ?: 'Congregazione',
'indirizzo' => $indirizzo,
'https' => str_starts_with($indirizzo, 'https://'),
'cifratura' => (string) config('app.cipher'),
'durataSessione' => (int) config('session.lifetime'),
'auditRetention' => (int) ($impostazioni->audit_retention_days ?: 365),
'scadenzaLink' => max(1, (int) ($impostazioni->giorni_per_smarrito ?: 120)),
'downloadPdf' => (bool) $impostazioni->pdf_viewer_show_download,
'postaAttiva' => $posta->funzionante(),
'hostPosta' => $hostPosta !== '' ? $hostPosta : null,
'ruoli' => Role::with('permissions')->orderBy('name')->get()->map(fn (Role $r) => [
'nome' => $r->name,
'permessi' => $r->permissions->pluck('name')
->map(fn ($p) => self::PERMESSI[$p] ?? $p)
->all(),
]),
// Quanti dati sono conservati adesso: solo conteggi, contati in
// SQL, senza decifrare né mostrare nomi.
'conteggi' => [
'proclamatori' => Proclamatore::count(),
'proclamatoriAttivi' => Proclamatore::where('attivo', true)->count(),
'anonimizzati' => Proclamatore::onlyTrashed()->count(),
'utenti' => User::count(),
'assegnazioni' => Assegnazione::count(),
'assegnazioniAperte' => Assegnazione::aperte()->count(),
'richieste' => Richiesta::count(),
'richiesteAperte' => Richiesta::aperte()->count(),
'attivita' => Activity::count(),
'attivitaDal' => Activity::min('created_at'),
],
]);
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Proclamatore;
class ProclamatoreCestino extends Component
{
use Avvisa;
use WithPagination;
public function restore(int $id)
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
activity()->causedBy(auth()->user())
->performedOn($proclamatore)
->log('restored');
session()->flash('success', "Proclamatore ripristinato.");
$this->avvisa("Proclamatore ripristinato.");
}
public function forceDelete(int $id)
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
if ($proclamatore->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
$this->avvisaErrore('Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
return;
}
activity()->causedBy(auth()->user())
->log('force_deleted_proclamatore');
$proclamatore->forceDelete();
session()->flash('success', 'Proclamatore eliminato definitivamente.');
$this->avvisa('Proclamatore eliminato definitivamente.');
}
public function render()

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Proclamatore;
class ProclamatoreCreate extends Component
{
use RicordaRitorno;
public string $nome = '';
public string $cognome = '';
public bool $attivo = true;
@@ -37,7 +40,7 @@ class ProclamatoreCreate extends Component
->log('created');
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato.");
return $this->redirect(route('proclamatori.index'), navigate: true);
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
}
public function render()

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Proclamatore;
class ProclamatoreEdit extends Component
{
use RicordaRitorno;
public Proclamatore $proclamatore;
public string $nome = '';
public string $cognome = '';
@@ -35,14 +38,18 @@ class ProclamatoreEdit extends Component
$this->validate();
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
$gruppo = ! $this->attivo ? $this->proclamatore->gruppoSorvegliato() : null;
$this->proclamatore->update([
'nome' => $this->nome,
'cognome' => $this->cognome,
'attivo' => $this->attivo,
]);
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
return $this->redirect(route('proclamatori.index'), navigate: true);
$avviso = "Proclamatore {$this->proclamatore->nome_completo} aggiornato." . ($gruppo ? ' ' . ProclamatoreIndex::gruppoSciolto($gruppo) : '');
session()->flash('success', $avviso);
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
}
public function render()
@@ -50,6 +57,7 @@ class ProclamatoreEdit extends Component
return view('livewire.proclamatori.proclamatore-form', [
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
'btnLabel' => 'Salva Modifiche',
'gruppoSorvegliato' => $this->proclamatore->gruppoSorvegliato(),
]);
}
}

View File

@@ -2,17 +2,26 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
class ProclamatoreIndex extends Component
{
use Avvisa;
use WithPagination;
// Ricerca, filtro e ordinamento nell'indirizzo: si ritrovano tornando.
#[Url(except: '')]
public string $search = '';
#[Url(except: '')]
public string $filtroStato = '';
#[Url(except: 'cognome')]
public string $sortField = 'cognome';
#[Url(except: 'asc')]
public string $sortDirection = 'asc';
protected $queryString = [
@@ -49,7 +58,13 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id);
$gruppo = $proclamatore->attivo ? $proclamatore->gruppoSorvegliato() : null;
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
$proclamatore->update(['attivo' => !$proclamatore->attivo]);
if ($gruppo) {
$this->avvisa("{$proclamatore->nome_completo} non è più attivo. " . self::gruppoSciolto($gruppo));
}
}
public function deleteProclamatore(int $id)
@@ -59,12 +74,15 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id);
if ($proclamatore->assegnazioni()->aperte()->exists()) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return;
}
// Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
// si scioglie (Proclamatore::booted).
$gruppo = $proclamatore->gruppoSorvegliato();
$proclamatore->delete();
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
}
public function anonimizza(int $id)
@@ -72,8 +90,15 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id);
$gruppo = $proclamatore->gruppoSorvegliato();
$proclamatore->anonimizza();
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
}
/** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
public static function gruppoSciolto(GruppoServizio $gruppo): string
{
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è stata eliminata.";
}
public function render()
@@ -81,7 +106,7 @@ class ProclamatoreIndex extends Component
// withCount rather than letting the view call
// ->assegnazioni()->aperte()->count() per row, which issued one
// COUNT(*) per displayed proclamatore on every render.
$query = Proclamatore::query()->withCount([
$query = Proclamatore::query()->with('gruppo')->withCount([
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
]);
@@ -99,6 +124,8 @@ class ProclamatoreIndex extends Component
$filtered = $filtered->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') {
$filtered = $filtered->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
}
// Sort in-memory
@@ -123,6 +150,8 @@ class ProclamatoreIndex extends Component
$query->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') {
$query->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$query->whereNotNull('gruppo_servizio_id');
}
// cognome/nome encrypted, so sort in-memory
@@ -145,6 +174,8 @@ class ProclamatoreIndex extends Component
return view('livewire.proclamatori.proclamatore-index', [
'proclamatori' => $proclamatori,
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
]);
}
}

View File

@@ -2,12 +2,16 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Proclamatore;
use App\Models\Assegnazione;
use Spatie\Activitylog\Models\Activity;
class ProclamatoreShow extends Component
{
use RicordaRitorno;
public Proclamatore $proclamatore;
public function mount(Proclamatore $proclamatore)
@@ -20,8 +24,11 @@ class ProclamatoreShow extends Component
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
->with(['territorio', 'annoTeocratico', 'campagna'])
->orderByDesc('assigned_at')
->orderByDesc('id')
->get()
->groupBy(fn($a) => $a->annoTeocratico->label);
->groupBy(fn($a) => $a->annoTeocratico->label)
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
->sortKeysDesc();
$stats = [
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
@@ -34,9 +41,21 @@ class ProclamatoreShow extends Component
),
];
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
// sua utenza) o membro. I gruppi sciolti non esistono più.
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
return view('livewire.proclamatori.proclamatore-show', [
'assegnazioniPerAnno' => $assegnazioni,
'stats' => $stats,
'gruppoSorvegliato' => $gruppoSorvegliato,
'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(),
'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(),
'utenza' => $utenza,
'ultimoAccesso' => $utenza
? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at')
: null,
]);
}
}

View File

@@ -2,11 +2,13 @@
namespace App\Livewire;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Validation\Rule;
use Livewire\Component;
class Profile extends Component
{
use Avvisa;
public string $name = '';
public string $email = '';
@@ -25,15 +27,30 @@ class Profile extends Component
{
$user = auth()->user();
// Il sorvegliante di gruppo entra con l'email fittizia creata col
// gruppo (GruppiServizio::emailFittizia): dal profilo non la cambia.
$emailFissa = $user->isSorvegliante();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
] + ($emailFissa ? [] : [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]);
]));
$user->update([
'name' => $validated['name'],
'email' => $validated['email'],
]);
$user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['email' => $validated['email']]));
$emailCambiata = $user->isDirty('email');
$user->save();
// L'indirizzo nuovo va confermato (User::booted lo segna da
// confermare): finché non apre il link, l'account resta bloccato.
// Il sorvegliante di gruppo non conferma l'email (User::hasVerifiedEmail).
if ($emailCambiata && ! $user->isSorvegliante()) {
$user->inviaConfermaEmail()
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
return $this->redirect(route('verification.notice'), navigate: false);
}
session()->flash('profile_success', 'Profilo aggiornato con successo.');
@@ -63,17 +80,23 @@ class Profile extends Component
auth()->user()->update([
'password' => $this->password,
'password_change_required' => false,
'password_temporanea' => false,
]);
$this->reset(['current_password', 'password', 'password_confirmation']);
session()->flash('password_success', 'Password aggiornata con successo.');
$this->avvisa('Password aggiornata con successo.');
}
public function render()
{
// Il sorvegliante vede qui i dettagli del suo gruppo di servizio.
$gruppo = auth()->user()->gruppoSorvegliato();
return view('livewire.profile', [
'user' => auth()->user(),
'gruppo' => $gruppo,
'riepilogoGruppo' => $gruppo?->riepilogo(),
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Assegnazione;
@@ -11,24 +12,28 @@ use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
use Barryvdh\DomPDF\Facade\Pdf;
use App\Services\Registro\ModuloS13;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class Registro extends Component
{
use WithPagination;
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */
private const S13_RIGHE_PER_PAGINA = 21;
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
// Ricerca, filtri e ordinamento nell'indirizzo: si ritrovano tornando.
#[Url(except: '')]
public string $search = '';
#[Url(except: '')]
public string $filtroAnno = '';
#[Url(except: '')]
public string $filtroZona = '';
#[Url(except: '')]
public string $filtroTipologia = '';
#[Url(except: '')]
public string $filtroStato = ''; // aperte, chiuse
#[Url(except: 'territorio_numero')]
public string $sortField = 'territorio_numero';
#[Url(except: 'asc')]
public string $sortDirection = 'asc';
// ─── Modal create/edit ──────────────────────────────────────
@@ -37,7 +42,6 @@ class Registro extends Component
public string $form_territorio_id = '';
public string $form_proclamatore_id = '';
public string $form_anno_id = '';
public string $form_assigned_at = '';
public string $form_returned_at = '';
public bool $form_counted_in_campaign = false;
@@ -101,7 +105,6 @@ class Registro extends Component
$this->editingId = null;
$this->form_territorio_id = '';
$this->form_proclamatore_id = '';
$this->form_anno_id = '';
$this->form_assigned_at = now()->format('Y-m-d');
$this->form_returned_at = '';
$this->form_counted_in_campaign = false;
@@ -118,7 +121,6 @@ class Registro extends Component
$this->editingId = $id;
$this->form_territorio_id = (string) $a->territorio_id;
$this->form_proclamatore_id = (string) $a->proclamatore_id;
$this->form_anno_id = (string) $a->anno_teocratico_id;
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
@@ -134,28 +136,69 @@ class Registro extends Component
$this->validate([
'form_territorio_id' => 'required|exists:territori,id',
'form_proclamatore_id' => 'required|exists:proclamatori,id',
'form_anno_id' => 'required|exists:anni_teocratici,id',
'form_assigned_at' => 'required|date',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
'form_assigned_at' => 'required|date|before_or_equal:today',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
'form_campaign_id' => 'nullable|exists:campagne,id',
], [
'form_assigned_at.before_or_equal' => 'La data di assegnazione non può essere futura.',
'form_returned_at.before_or_equal' => 'La data di rientro non può essere futura.',
'form_returned_at.after_or_equal' => 'Il rientro non può precedere l\'assegnazione.',
]);
$uscita = Carbon::parse($this->form_assigned_at);
$rientro = $this->form_returned_at ? Carbon::parse($this->form_returned_at) : null;
// L'anno di servizio non si sceglie: è quello della data di uscita.
// Scelto a mano poteva restare incoerente con la data, e l'assegnazione
// finiva contata nell'anno sbagliato da Home, filtri e modulo S-13.
$data = [
'territorio_id' => $this->form_territorio_id,
'proclamatore_id' => $this->form_proclamatore_id,
'anno_teocratico_id' => $this->form_anno_id,
'assigned_at' => $this->form_assigned_at,
'returned_at' => $this->form_returned_at ?: null,
'counted_in_campaign' => $this->form_counted_in_campaign,
'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
'assigned_at' => $uscita,
'returned_at' => $rientro,
// "Contata nella campagna" senza campagna non ha significato.
'counted_in_campaign' => $this->form_counted_in_campaign && $this->form_campaign_id !== '',
'campaign_id' => $this->form_campaign_id ?: null,
'note' => $this->form_note ?: null,
];
if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data);
} else {
$data['created_by'] = auth()->id();
Assegnazione::create($data);
$assegnazione = null;
$prima = [false, null];
$conflitto = DB::transaction(function () use ($data, $uscita, $rientro, &$assegnazione, &$prima) {
Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first();
$conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
if ($conflitto) {
return $conflitto;
}
if ($this->editingId) {
$assegnazione = Assegnazione::findOrFail($this->editingId);
$prima = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
$assegnazione->update($data);
} else {
$assegnazione = Assegnazione::create($data + ['created_by' => auth()->id()]);
}
return null;
});
if ($conflitto) {
$this->addError('form_assigned_at', "Il periodo si sovrappone all'assegnazione {$conflitto->descrizionePeriodo()}.");
return;
}
// Log the campaign choice when the admin sets or changes it here.
$dopo = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
if ($dopo !== $prima && ($dopo[0] || $prima[0])) {
$assegnazione->logCampagnaPercorso(
$assegnazione->campagna ?? ($prima[1] ? Campagna::find($prima[1]) : null),
$dopo[0],
'Registro'
);
}
$this->showModal = false;
@@ -199,16 +242,15 @@ class Registro extends Component
}
/**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del
* territorio") compilato con le assegnazioni dell'anno di servizio scelto
* nella modale.
* Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
* modale; le regole di compilazione stanno in ModuloS13.
*
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo
* espone in un unico file l'anagrafica completa di chi ha avuto quale
* territorio, dati personali che il ruolo assegnatario non deve poter
* esportare in blocco.
*/
public function downloadS13()
public function downloadS13(ModuloS13 $modulo)
{
abort_if(!auth()->user()->can('settings.manage'), 403);
@@ -224,134 +266,15 @@ class Registro extends Component
$this->showS13Modal = false;
$pdf = Pdf::loadView('pdf.registro-s13', [
'annoLabel' => $anno->label,
'pagine' => $this->impaginaS13($this->righeS13($anno)),
])->setPaper('a4', 'portrait');
$pdf = $modulo->genera($anno);
return response()->streamDownload(
fn () => print($pdf->output()),
'S-13-registro-assegnazioni-' . $anno->label . '.pdf'
fn () => print($pdf),
'S-13-registro-assegnazioni-' . $anno->label . '.pdf',
['Content-Type' => 'application/pdf']
);
}
/**
* Impagina le righe in fogli da 21 blocchi, completando l'ultimo con
* blocchi vuoti: il foglio stampato resta identico al modulo cartaceo e le
* righe libere restano disponibili per la compilazione a mano.
*
* Senza righe da stampare si restituisce comunque un foglio vuoto, così il
* modulo scaricato è un S-13 in bianco anziché un PDF senza pagine.
*
* @param \Illuminate\Support\Collection<int, array> $righe
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array>>
*/
private function impaginaS13(\Illuminate\Support\Collection $righe): \Illuminate\Support\Collection
{
$vuota = [
'numero' => '',
'riporto' => '',
'assegnazioni' => $this->completaCelleS13([]),
];
$pagine = $righe->isEmpty()
? collect([collect()])
: $righe->chunk(self::S13_RIGHE_PER_PAGINA)->map->values()->values();
return $pagine->map(fn ($pagina) => collect(
array_pad($pagina->all(), self::S13_RIGHE_PER_PAGINA, $vuota)
));
}
/**
* Righe del modulo: un blocco per ogni territorio attivo con almeno
* un'assegnazione (chiusa o in corso) nell'anno indicato, in ordine di
* numero crescente.
*
* @return \Illuminate\Support\Collection<int, array>
*/
private function righeS13(AnnoTeocratico $anno): \Illuminate\Support\Collection
{
// territorio() è definita withTrashed(), quindi la condizione sui
// cestinati va scritta a mano: senza, i territori eliminati
// rientrerebbero nel modulo.
$assegnazioni = Assegnazione::with(['proclamatore', 'territorio'])
->where('anno_teocratico_id', $anno->id)
->whereHas('territorio', fn ($q) => $q
->where('attivo', true)
->whereNull('territori.deleted_at'))
->orderBy('assigned_at')
->orderBy('id')
->get()
->groupBy('territorio_id');
return $assegnazioni
->map(function ($gruppo) {
$gruppo = $gruppo->values();
// Il ciclo di registrazione è lungo quanto i gruppi "Assegnato
// a": riempito il quarto si ricomincia da capo, come sul modulo
// cartaceo si aprirebbe una riga nuova per quel territorio.
// Cinque assegnazioni non mostrano quindi le ultime quattro, ma
// solo la quinta, in prima colonna.
$inizioCiclo = intdiv($gruppo->count() - 1, self::S13_ASSEGNAZIONI_PER_RIGA)
* self::S13_ASSEGNAZIONI_PER_RIGA;
$celle = $gruppo
->slice($inizioCiclo, self::S13_ASSEGNAZIONI_PER_RIGA)
->map(fn (Assegnazione $a) => [
'nome' => $a->proclamatore?->nome_completo ?? '',
'assegnato_il' => $a->assigned_at?->format('d/m/Y') ?? '',
'restituito_il' => $a->returned_at?->format('d/m/Y') ?? '',
])->all();
return [
'numero' => $gruppo->first()->territorio?->numero ?? '',
'riporto' => $this->riportoS13($gruppo, $inizioCiclo),
'assegnazioni' => $this->completaCelleS13($celle),
];
})
->sort(fn (array $a, array $b) => strnatcasecmp((string) $a['numero'], (string) $b['numero']))
->values();
}
/**
* Contenuto della colonna "Restituito l'ultima volta il": la data di
* rientro dell'ultima assegnazione *prima* che il ciclo ricominciasse,
* cioè quella che occupava il quarto gruppo del giro precedente.
*
* È il riporto che il modulo cartaceo chiede quando si apre una riga nuova
* per un territorio già registrato: la riga mostra da dove si riparte,
* senza il nome di chi lo aveva (la colonna ha solo la data).
*
* Finché il primo ciclo non è esaurito — quattro assegnazioni o meno
* nell'anno — non c'è niente da riportare e la colonna resta vuota.
*
* @param \Illuminate\Support\Collection<int, Assegnazione> $gruppo
*/
private function riportoS13(\Illuminate\Support\Collection $gruppo, int $inizioCiclo): string
{
if ($inizioCiclo === 0) {
return '';
}
return $gruppo->get($inizioCiclo - 1)?->returned_at?->format('d/m/Y') ?? '';
}
/**
* Porta le celle a quattro riempiendo con voci vuote: le colonne
* inutilizzate restano stampate e a disposizione della compilazione a mano.
*
* @param array<int, array> $celle
* @return array<int, array>
*/
private function completaCelleS13(array $celle): array
{
$vuota = ['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''];
return array_pad($celle, self::S13_ASSEGNAZIONI_PER_RIGA, $vuota);
}
public function mount(): void
{
if ($this->filtroAnno === '') {
@@ -425,8 +348,14 @@ class Registro extends Component
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
'zone' => Zona::attive()->orderBy('nome')->get(),
'tipologie' => Tipologia::orderBy('nome')->get(),
'territori' => Territorio::attivi()->orderBy('numero')->get(),
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
// Campi di scelta con ricerca della finestra di modifica. Ci sono
// anche i disattivati, non sceglibili: modificando una vecchia
// assegnazione il campo mostra comunque il nome giusto.
'opzioniTerritori' => Territorio::with(['zona', 'tipologia'])->get()
->sortBy('numero', SORT_NATURAL)
->map(fn (Territorio $t) => $t->opzioneScelta($t->attivo ? null : 'territorio disattivato'))
->values()->all(),
'opzioniProclamatori' => Proclamatore::opzioniScelta(Proclamatore::all()),
'campagne' => Campagna::orderByDesc('created_at')->get(),
]);
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Livewire\Richieste;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Services\RichiesteServizio;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
* mostra è l'elenco dei territori nella riconsegna, per il completamento
* automatico del numero: numero e zona, mai a chi è assegnato né se lo è.
* Nomi e altri dati non si vedono.
*
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
* - un tempo minimo fra apertura e invio (i programmi inviano subito);
* - al massimo MASSIMO_ORA richieste all'ora per indirizzo.
* Nei primi due casi si risponde come se fosse andata bene, senza salvare:
* chi prova non impara cosa l'ha fermato.
*/
class ModuloRichiesta extends Component
{
public const MASSIMO_ORA = 5;
public const SECONDI_MINIMI = 3;
public string $tipo = Richiesta::NUOVO;
public string $nome = '';
public string $numero = '';
public string $note = '';
/** Trappola: nascosto alle persone, i programmi lo riempiono. */
public string $sito = '';
#[Locked]
public int $apertoAlle = 0;
#[Locked]
public bool $inviata = false;
public function mount(): void
{
$this->apertoAlle = now()->timestamp;
}
public function invia(RichiesteServizio $richieste): void
{
if ($this->sito !== '' || now()->timestamp - $this->apertoAlle < self::SECONDI_MINIMI) {
$this->inviata = true;
return;
}
$chiave = 'richiesta-pubblica:' . sha1((string) request()->ip());
if (RateLimiter::tooManyAttempts($chiave, self::MASSIMO_ORA)) {
$this->addError('nome', "Hai già mandato diverse richieste: riprova fra un po'.");
return;
}
$this->validate([
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
'nome' => 'required|string|min:3|max:100',
'numero' => ['nullable', 'required_if:tipo,' . Richiesta::RICONSEGNA, 'string', 'max:20',
function ($attributo, $valore, $errore) {
if ($this->tipo === Richiesta::RICONSEGNA && ! self::territori()->whereRaw('LOWER(numero) = ?', [Str::lower(trim((string) $valore))])->exists()) {
$errore("Scegli il territorio dall'elenco.");
}
}],
'note' => 'nullable|string|max:1000',
], [
'nome.required' => 'Scrivi nome e cognome.',
'nome.min' => 'Scrivi nome e cognome.',
'numero.required_if' => 'Scegli il territorio che riconsegni.',
]);
RateLimiter::hit($chiave, 3600);
$richieste->registra($this->tipo, $this->nome, $this->numero, $this->note);
$this->reset(['nome', 'numero', 'note']);
$this->inviata = true;
}
public function nuova(): void
{
$this->reset(['tipo', 'nome', 'numero', 'note', 'inviata']);
$this->apertoAlle = now()->timestamp;
}
/**
* I territori che si possono riconsegnare: quelli attivi e quelli ancora
* fuori anche se nel frattempo disattivati.
*/
private static function territori(): Builder
{
return Territorio::query()->where(fn ($q) => $q->where('attivo', true)
->orWhereHas('assegnazioni', fn ($a) => $a->whereNull('returned_at')));
}
/**
* Le proposte del campo con ricerca (<x-scelta>): solo numero e zona. Il
* valore è il numero, come lo salva la richiesta.
*/
private static function opzioniTerritori(): array
{
return self::territori()->with('zona')->get()
->sortBy('numero', SORT_NATURAL)
->map(fn (Territorio $t) => array_filter([
'valore' => (string) $t->numero,
'etichetta' => "N° {$t->numero}",
'dettaglio' => $t->zona?->nome,
'chiavi' => [(string) $t->numero, (string) $t->zona?->nome],
], fn ($v) => $v !== null))
->values()
->all();
}
public function render()
{
return view('livewire.richieste.modulo-richiesta', [
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
// Solo quando serve: chi chiede un territorio nuovo non vede l'elenco.
'opzioniTerritori' => $this->tipo === Richiesta::RICONSEGNA && ! $this->inviata ? self::opzioniTerritori() : [],
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
}
}

View File

@@ -48,7 +48,11 @@ class BackupManager extends Component
return;
}
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
if ($operationId === null) {
return;
}
$this->operationId = $operationId;
CreateBackupArchive::dispatch($this->operationId, false);

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Setting;
use App\Services\ConfigurazionePosta;
use Illuminate\Validation\Rule;
use Livewire\Component;
/**
* Server di posta (solo amministratore).
*
* Serve alle email di conferma degli utenti. Raggiungibile anche da un
* amministratore che non ha ancora confermato la sua email (vedi
* routes/web.php): se la posta non funziona, è l'unica pagina da cui
* sistemarla senza passare dal server.
*/
class PostaElettronica extends Component
{
use Avvisa;
public string $host = '';
public string $porta = '';
public string $cifratura = ConfigurazionePosta::CIFRATURA_AUTOMATICA;
public string $utente = '';
/** Vuota = si tiene quella salvata. Non torna mai al browser. */
public string $password = '';
public string $mittente = '';
public string $nomeMittente = '';
public function mount(): void
{
$impostazioni = Setting::instance();
$this->host = (string) $impostazioni->mail_host;
$this->porta = (string) ($impostazioni->mail_port ?? '');
$this->cifratura = $impostazioni->mail_cifratura ?: ConfigurazionePosta::CIFRATURA_AUTOMATICA;
$this->utente = (string) $impostazioni->mail_username;
$this->mittente = (string) $impostazioni->mail_from_address;
$this->nomeMittente = (string) $impostazioni->mail_from_name;
}
public function salva(): void
{
$this->host = trim($this->host);
$this->mittente = trim($this->mittente);
$this->validate([
'host' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9.\-]+$/'],
'porta' => ['nullable', 'integer', 'min:1', 'max:65535'],
'cifratura' => ['required', Rule::in(array_keys(ConfigurazionePosta::CIFRATURE))],
'utente' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:500'],
'mittente' => ['required', 'email', 'max:255'],
'nomeMittente' => ['nullable', 'string', 'max:255'],
], [
'host.required' => 'Indica il server SMTP (es. smtp.gmail.com).',
'host.regex' => 'Solo il nome del server, senza "smtp://" né porta.',
'mittente.required' => 'Indica l\'indirizzo da cui partono le email.',
'mittente.email' => 'L\'indirizzo del mittente non è valido.',
]);
$valori = [
'mail_host' => $this->host,
'mail_port' => $this->porta !== '' ? (int) $this->porta : null,
'mail_cifratura' => $this->cifratura,
'mail_username' => trim($this->utente) ?: null,
'mail_from_address' => $this->mittente,
'mail_from_name' => trim($this->nomeMittente) ?: null,
];
if ($this->password !== '') {
$valori['mail_password'] = $this->password;
}
Setting::instance()->update($valori);
$this->password = '';
activity()->causedBy(auth()->user())->log('posta_configurata');
$this->avvisa('Server di posta salvato. Prova l\'invio con "Invia email di prova".');
}
public function inviaProva(ConfigurazionePosta $posta): void
{
if (! $posta->configurataNellApp()) {
$this->avvisaErrore('Salva prima il server di posta.');
return;
}
try {
$posta->inviaProva(auth()->user()->email);
} catch (\RuntimeException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->avvisa('Email di prova inviata a ' . auth()->user()->email . '. Controlla anche lo spam.');
}
/** Torna al server del .env. */
public function rimuovi(): void
{
Setting::instance()->update(array_fill_keys(Setting::CAMPI_POSTA, null));
$this->mount();
$this->password = '';
activity()->causedBy(auth()->user())->log('posta_rimossa');
$this->avvisa('Configurazione rimossa: valgono di nuovo le impostazioni del server (.env).');
}
public function render(ConfigurazionePosta $posta)
{
$impostazioni = Setting::instance();
return view('livewire.settings.posta-elettronica', [
'configurataNellApp' => $posta->configurataNellApp(),
'funzionante' => $posta->funzionante(),
'passwordSalvata' => $impostazioni->getRawOriginal('mail_password') !== null,
'cifrature' => ConfigurazionePosta::CIFRATURE,
'nonConfermato' => ! auth()->user()->hasVerifiedEmail(),
'title' => 'Server di posta',
]);
}
}

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use App\Models\Setting;
class SettingsEdit extends Component
{
use Avvisa;
public string $congregazione_nome = '';
public int $giorni_giacenza_da_assegnare = 120;
public int $giorni_giacenza_prioritari = 180;
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
'audit_retention_days' => $this->audit_retention_days,
]);
session()->flash('success', 'Impostazioni aggiornate.');
$this->avvisa('Impostazioni aggiornate.');
}
public function render()

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use App\Models\Tipologia;
class TipologieIndex extends Component
{
use Avvisa;
public string $nuovaTipologia = '';
public ?int $editingId = null;
public string $editingNome = '';
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
$this->nuovaTipologia = '';
session()->flash('success', 'Tipologia aggiunta.');
$this->avvisa('Tipologia aggiunta.');
}
public function startEdit(int $id)
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
$tipo->update(['nome' => $this->editingNome]);
$this->editingId = null;
$this->editingNome = '';
session()->flash('success', 'Tipologia aggiornata.');
$this->avvisa('Tipologia aggiornata.');
}
public function cancelEdit()
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
{
$tipo = Tipologia::findOrFail($id);
if ($tipo->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
$this->avvisaErrore("Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
return;
}
$tipo->delete();
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
$this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
}
public function render()

View File

@@ -2,23 +2,40 @@
namespace App\Livewire\Settings;
use App\Models\Assegnazione;
use App\Livewire\Concerns\Avvisa;
use App\Models\GruppoServizio;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use App\Services\GruppiServizio;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
class UsersIndex extends Component
{
use Avvisa;
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public string $selectedRole = '';
// Locked: è la lista che le regole di validazione usano per accettare un
// ruolo; modificabile dal client, ammetterebbe qualsiasi nome.
#[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 = '';
@@ -28,7 +45,10 @@ class UsersIndex extends Component
public function mount(): void
{
// "sorvegliante" si dà solo creando un gruppo di servizio, con
// l'utenza collegata al proclamatore: da qui non si assegna.
$this->availableRoles = Role::query()
->where('name', '!=', 'sorvegliante')
->orderBy('name')
->pluck('name')
->all();
@@ -79,12 +99,54 @@ class UsersIndex extends Component
if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0];
}
session()->flash('success', 'Utente creato con successo.');
// L'account funziona solo dopo la conferma dell'indirizzo.
if ($user->inviaConfermaEmail()) {
$this->avvisa("Utente creato. Gli è stata inviata un'email a {$user->email}: l'account funziona dopo che conferma l'indirizzo.");
} else {
$this->avvisaErrore("Utente creato, ma l'email di conferma non è partita: controlla il server di posta e usa \"Reinvia conferma\".");
}
}
public function reinviaConferma(int $userId): void
{
$user = User::query()->findOrFail($userId);
if ($user->hasVerifiedEmail()) {
$this->avvisa("{$user->email} è già confermato.");
return;
}
if ($user->inviaConfermaEmail()) {
$this->avvisa("Email di conferma inviata a {$user->email}.");
} else {
$this->avvisaErrore('L\'email non è partita: controlla il server di posta.');
}
}
/**
* Le utenze dei sorveglianti di gruppo si gestiscono
* da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
*/
private function gestitaDaiGruppi(User $user): bool
{
if ($user->proclamatore_id === null) {
return false;
}
$this->avvisaErrore("L'utenza di {$user->name} è di un sorvegliante di gruppo: si gestisce da Gruppi di servizio.");
return true;
}
public function startEdit(int $userId): void
{
$user = User::query()->with('roles')->findOrFail($userId);
if ($this->gestitaDaiGruppi($user)) {
return;
}
$this->editingUserId = $user->id;
$this->editName = $user->name;
@@ -114,6 +176,26 @@ class UsersIndex extends Component
$validated = $this->validate($this->editRules());
$user = User::query()->findOrFail($this->editingUserId);
if ($this->gestitaDaiGruppi($user)) {
return;
}
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
// rimasto, lascia l'installazione senza nessuno che possa gestire
// utenti, impostazioni e backup.
if ($user->hasRole('amministratore') && $validated['editSelectedRole'] !== 'amministratore') {
if ($user->id === auth()->id()) {
$this->addError('editSelectedRole', 'Non puoi togliere a te stesso il ruolo di amministratore: chiedilo a un altro amministratore.');
return;
}
if (User::role('amministratore')->count() <= 1) {
$this->addError('editSelectedRole', "È l'ultimo amministratore: assegna prima il ruolo a un altro utente.");
return;
}
}
$user->name = $validated['editName'];
$user->email = $validated['editEmail'];
@@ -122,71 +204,87 @@ class UsersIndex extends Component
$user->password = $validated['editPassword'];
}
$emailCambiata = $user->isDirty('email');
$user->save();
$user->syncRoles([$validated['editSelectedRole']]);
$this->cancelEdit();
session()->flash('success', 'Utente aggiornato con successo.');
// Indirizzo nuovo: torna da confermare (User::booted) e gli si manda
// il link, altrimenti il suo account smetterebbe di funzionare senza
// che sappia perché.
if ($emailCambiata) {
$inviata = $user->inviaConfermaEmail();
$inviata
? $this->avvisa("Utente aggiornato. Il nuovo indirizzo va confermato: email inviata a {$user->email}.")
: $this->avvisaErrore('Utente aggiornato, ma l\'email di conferma del nuovo indirizzo non è partita: controlla il server di posta.');
return;
}
$this->avvisa('Utente aggiornato con successo.');
}
public function deleteUser(int $userId): void
{
$currentUser = auth()->user();
$user = User::query()->with('roles')->findOrFail($userId);
if ($this->gestitaDaiGruppi($user)) {
return;
}
if (! $currentUser || $currentUser->id === $user->id) {
session()->flash('error', 'Non puoi cancellare il tuo utente.');
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
return;
}
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
$this->avvisaErrore('Non puoi cancellare l\'ultimo amministratore.');
return;
}
DB::transaction(function () use ($user, $currentUser) {
$causerName = $user->name;
$causerEmail = $user->email;
$deletedAt = now()->toDateTimeString();
Activity::query()
->where('causer_type', User::class)
->where('causer_id', $user->id)
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
foreach ($activities as $activity) {
$properties = $activity->properties?->toArray() ?? [];
$properties['causer_name'] = $causerName;
$properties['causer_email'] = $causerEmail;
$properties['causer_deleted_at'] = $deletedAt;
$activity->properties = $properties;
$activity->save();
}
});
Assegnazione::query()
->where('created_by', $user->id)
->update(['created_by' => $currentUser->id]);
Assegnazione::query()
->where('returned_by', $user->id)
->update(['returned_by' => $currentUser->id]);
$user->syncRoles([]);
$user->delete();
});
$user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
if ($this->editingUserId === $userId) {
$this->cancelEdit();
}
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
$this->avvisa('Utente cancellato. I log sono stati preservati.');
}
/**
* Il sorvegliante ha perso la password: una temporanea nuova, da cambiare
* al primo accesso. È l'unica cosa che di quell'utenza si fa da qui; il
* resto passa dal gruppo (gestitaDaiGruppi).
*/
public function nuovaPasswordSorvegliante(int $userId): void
{
$this->authorize('settings.manage');
$user = User::query()->findOrFail($userId);
try {
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($user);
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->passwordPerUtenteId = $user->id;
}
public function chiudiPassword(): void
{
$this->reset(['passwordPerUtenteId', 'passwordTemporanea']);
}
public function render()
{
return view('livewire.settings.users-index', [
'users' => User::query()->with('roles')->orderBy('name')->get(),
// Per ogni sorvegliante il suo gruppo, per il collegamento.
'gruppiDeiSorveglianti' => GruppoServizio::pluck('id', 'sorvegliante_id'),
'utentePassword' => $this->passwordPerUtenteId ? User::find($this->passwordPerUtenteId) : null,
]);
}
}

View File

@@ -2,27 +2,36 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class XmlExchange extends Component
{
use Avvisa;
use WithFileUploads;
public array $importStats = [];
@@ -52,39 +61,149 @@ class XmlExchange extends Component
}
}
public function importTerritoryPdfFolder(): void
/**
* Quanti file viaggiano in una singola richiesta di caricamento della
* cartella.
*
* Il browser non manda la cartella tutta insieme: la spezza in lotti e li
* aggiunge uno dopo l'altro a $pdfFolder. Un'unica richiesta con tutti i file
* si scontrava con `max_file_uploads` di PHP (20 per default), che non
* rifiuta: scarta in silenzio i file in piu', e l'import partiva con i primi
* venti di una cartella da trecento. Alzare il limite non basterebbe: la
* richiesta unica resterebbe soggetta al tetto di dimensione e al timeout di
* 100 secondi di Cloudflare, e un errore a meta' farebbe ricominciare da capo.
* A lotti, ogni richiesta e' piccola e un errore ne ferma una sola.
*
* Si legge il valore vero di PHP, perche' un'installazione con un limite piu'
* basso non perda file in silenzio come prima.
*/
public function getFilePerLottoProperty(): int
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
}
$importId = (string) Str::uuid();
$storedFiles = [];
/**
* Controllo rapido prima di caricare la cartella: senza, si caricherebbero
* centinaia di file per poi sentirsi dire che c'e' un ripristino in corso.
* Non prende il posto — lo prende importTerritoryPdfFolder(), a caricamento
* finito — perche' una scheda chiusa a meta' caricamento lo terrebbe
* occupato per un'ora.
*/
public function cartellaPuoPartire(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
if ($inCorso === null) {
return true;
}
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
$this->avvisaErrore(app(BackupOperationState::class)->messaggioOccupato($inCorso));
return false;
}
/** Il browser azzera i lotti di un caricamento precedente prima di iniziarne uno nuovo. */
public function svuotaCartella(): void
{
foreach ($this->pdfFolder as $file) {
if ($file instanceof TemporaryUploadedFile) {
$file->delete();
}
}
$this->pdfFolder = [];
}
/**
* Mette in coda l'import dei lotti gia' caricati. Restituisce true se
* l'import e' partito: il browser tiene la cartella scelta finche' non lo e',
* cosi' dopo un rifiuto basta ripremere.
*/
public function importTerritoryPdfFolder(): bool
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
]);
$importId = (string) Str::uuid();
$dispatcher = app(TerritorioPdfImportDispatcher::class);
try {
$dispatcher->occupaPosto($importId);
} catch (\RuntimeException $e) {
// Il posto l'ha preso qualcun altro fra il controllo iniziale e la
// fine del caricamento: si avvisa, e non si tocca niente.
$this->avvisaErrore($e->getMessage());
return false;
}
$storedFiles = [];
$ignorati = [];
$scartati = [];
try {
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
// Thumbs.db, .DS_Store, desktop.ini e simili: prima uno solo di
// questi faceva fallire la validazione e con lei l'intero import.
if (TerritorioPdfImportDispatcher::daIgnorare($originalName)) {
$ignorati[] = $originalName;
continue;
}
// Un PDF rotto o troppo grande si scarta da solo, con il suo
// motivo nell'esito: non e' una ragione per buttare gli altri.
$verifica = Validator::make(['file' => $file], ['file' => ['file', 'mimes:pdf', 'max:10240']]);
if ($verifica->fails()) {
$scartati[] = [
'file' => $originalName,
'message' => 'File non valido: ' . $verifica->errors()->first('file'),
];
continue;
}
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
} catch (\Throwable $e) {
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $e;
}
$this->svuotaCartella();
if ($storedFiles === []) {
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
. '.');
return false;
}
$this->dispatchPdfImport(
$importId,
$storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
$ignorati,
$scartati
);
return true;
}
public function refreshPdfImportStatus(): void
@@ -114,8 +233,9 @@ class XmlExchange extends Component
* contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
* non partecipa al posto unico: puo' girare mentre un backup lavora.
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
* Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
* non gira mai insieme a un backup, un ripristino o un azzeramento.
*/
protected function spedisciImportAllaConsole(): void
{
@@ -156,10 +276,10 @@ class XmlExchange extends Component
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
@@ -172,13 +292,13 @@ class XmlExchange extends Component
$this->logPdfSpediti = 0;
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
return;
}
@@ -236,16 +356,99 @@ class XmlExchange extends Component
return $this->datasetToXml($dataset, $source);
}
/**
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
*
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
* file dei territori cancellati sul disco: nessuna schermata li mostra,
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
* PDF per territorio.
*
* @return array<int, string>
*/
public static function fileDeiTerritori(): array
{
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
->filter()
->unique()
->values()
->all();
}
/**
* Elimina i file che prima dell'import appartenevano a un territorio e che
* adesso nessuna riga richiama piu'.
*
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
* XML che riporta gli stessi percorsi (un export di questa stessa
* installazione) non perde niente.
*
* @param array<int, string> $fileDiPrima
*/
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
{
$usati = array_flip(array_merge(
self::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$eliminati = 0;
foreach ($fileDiPrima as $percorso) {
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
continue;
}
if ($disco->delete($percorso)) {
$eliminati++;
}
}
return $eliminati;
}
public function importXmlFromContent(string $content): array
{
$this->importStats = [];
$this->importIssues = [];
$xml = @simplexml_load_string($content);
if (! $xml) {
return ['error' => 'Impossibile leggere il file XML.'];
$xml = $this->leggiXml($content);
if (is_string($xml)) {
return ['error' => $xml];
}
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
// (un sitemap, l'export di un altro programma) lascerebbe
// l'installazione vuota con un "completato con successo". Si accetta
// solo un file prodotto da questa applicazione, con le sue sezioni.
if ($xml->getName() !== 'termanager2') {
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <termanager2>. Nessun dato è stato modificato.'];
}
$mancanti = array_values(array_filter(
['territori', 'proclamatori', 'assegnazioni'],
fn (string $sezione) => ! isset($xml->{$sezione})
));
if ($mancanti !== []) {
return ['error' => 'Il file è incompleto: mancano le sezioni ' . implode(', ', $mancanti) . '. Nessun dato è stato modificato.'];
}
// Chi esegue l'import (o il ripristino, che imposta l'utente a mano)
// non deve uscirne estromesso: il file può contenere il suo account
// con una password vecchia o senza il ruolo di amministratore.
$attore = auth()->user();
$attoreEraAmministratore = $attore?->hasRole('amministratore') ?? false;
$actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) {
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
@@ -264,14 +467,23 @@ class XmlExchange extends Component
'assegnazioni_saltate' => 0,
];
DB::transaction(function () use ($xml, $actorId, &$stats) {
try {
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
Assegnazione::query()->delete();
GruppoServizio::query()->delete();
Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete();
Proclamatore::query()->withTrashed()->forceDelete();
Tipologia::query()->delete();
Zona::query()->delete();
AnnoTeocratico::query()->delete();
// Il server di posta è configurazione di questa installazione, non
// un dato da trasferire: non si esporta (la password è cifrata con
// la sua APP_KEY) e un import non deve cancellarlo — senza posta
// nessuno riceverebbe più le email di conferma.
$posta = Setting::query()->first()?->only(Setting::CAMPI_POSTA) ?? [];
Setting::query()->delete();
// The mass delete above fires no model events, so Setting's
@@ -299,6 +511,10 @@ class XmlExchange extends Component
]);
}
if ($posta !== []) {
Setting::instance()->update($posta);
}
// Users are UPSERTED by email, never wiped. Deleting the users
// table during a restore risks locking out the administrator
// performing the import (e.g. a backup that predates their account,
@@ -306,10 +522,18 @@ class XmlExchange extends Component
// accounts in the file are (re)created. $userMap remaps the
// created_by/returned_by FKs on the assignments below.
$userMap = [];
$utentiDaCollegare = [];
foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? '');
// Nei file del 19-20 ottobre 2026 c'erano le utenze disattivate
// degli ex sorveglianti: ora, sciolto il gruppo, l'utenza non
// esiste più. Le loro assegnazioni passano a chi importa.
if (trim((string) ($userNode->disattivato_at ?? '')) !== '') {
continue;
}
if ($email === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
continue;
@@ -334,6 +558,10 @@ class XmlExchange extends Component
$roleNames[] = $roleName;
}
}
$eAttore = $attore !== null && $user->id === $attore->id;
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
$roleNames[] = 'amministratore';
}
$user->syncRoles($roleNames);
// Password hash, remember_token, verification flag and
@@ -341,13 +569,24 @@ class XmlExchange extends Component
// hash is preserved exactly (the 'hashed' cast would re-validate
// the bcrypt cost and could reject a hash made with a different
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
// Per chi sta importando password e token restano i suoi:
// quelli del file possono essere di mesi fa.
//
// Chi riceve l'hash dal file deve scegliere una password nuova,
// come dopo un ripristino: chi ha avuto il file fra le mani ha
// potuto tentarne il crack con calma. Il "ricordami" si azzera
// e basta: non si esporta più, e quello di un file vecchio
// sarebbe una credenziale persistente regalata a chi lo tiene.
$meta = [];
$passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '') {
if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
$meta['password_temporanea'] = false;
}
if (! $eAttore) {
$meta['remember_token'] = null;
}
$rememberToken = (string) ($userNode->remember_token ?? '');
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
@@ -361,6 +600,10 @@ class XmlExchange extends Component
DB::table('users')->where('id', $user->id)->update($meta);
// Utenza di un sorvegliante: si collega al proclamatore dopo che
// i proclamatori sono stati importati (vedi i gruppi di servizio).
$utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
$stats['utenti_importati']++;
if ($legacyId !== '') {
$userMap[$legacyId] = $user->id;
@@ -396,27 +639,62 @@ class XmlExchange extends Component
}
$proclamatoreMap = [];
$gruppiDeiProclamatori = [];
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
$p = Proclamatore::create([
'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
// <email> e <telefono>, presenti nei file del 13-19 ottobre
// 2026, si ignorano: i proclamatori non li hanno più.
]);
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++;
$gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? '');
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$proclamatoreMap[$legacyId] = $p->id;
}
}
$annoMap = [];
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
// Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di
// loro. Poi i collegamenti che li aspettavano (membri, utenze).
$gruppoMap = [];
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
// non si importa.
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
continue;
}
$g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
]);
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
$legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$gruppoMap[$legacyId] = $g->id;
}
}
foreach ($gruppiDeiProclamatori as $idProclamatore => $legacyGruppo) {
if (isset($gruppoMap[$legacyGruppo])) {
DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]);
}
}
foreach ($utentiDaCollegare as $idUtente => $legacyProclamatore) {
DB::table('users')->where('id', $idUtente)->update([
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
]);
}
$annoMap = []; foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
$label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([
'label' => $label,
'start_date' => (string) $annoNode->start_date,
'end_date' => (string) $annoNode->end_date,
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
]);
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
$stats['anni_importati']++;
@@ -430,8 +708,8 @@ class XmlExchange extends Component
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
$campagna = Campagna::create([
'descrizione' => (string) $campagnaNode->descrizione,
'start_date' => (string) $campagnaNode->start_date,
'end_date' => (string) $campagnaNode->end_date,
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
]);
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
$stats['campagne_importate']++;
@@ -455,9 +733,14 @@ class XmlExchange extends Component
continue;
}
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
continue;
// Zona e tipologia sono facoltative (le colonne sono nullable e
// un territorio resta senza quando la sua zona viene
// eliminata): un riferimento vuoto o sconosciuto non è motivo
// per scartare il territorio, che si porterebbe dietro tutto
// il suo storico di assegnazioni.
if (($legacyZonaId !== '' && ! isset($zoneMap[$legacyZonaId]))
|| ($legacyTipologiaId !== '' && ! isset($tipologiaMap[$legacyTipologiaId]))) {
$this->pushImportIssue('territorio', $legacyId, 'Zona o tipologia non trovata (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . '): territorio importato senza');
}
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
@@ -465,12 +748,13 @@ class XmlExchange extends Component
$territorio = Territorio::create([
'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId],
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
'confini' => (string) ($territorioNode->confini ?? ''),
'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null,
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
]);
@@ -532,6 +816,11 @@ class XmlExchange extends Component
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'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,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $createdBy,
@@ -540,7 +829,15 @@ class XmlExchange extends Component
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
$stats['assegnazioni_importate']++;
}
});
});
} catch (\Illuminate\Database\QueryException $e) {
// La transazione è già stata annullata: il database è com'era.
// Senza questo l'amministratore vedrebbe un "Server Error" senza
// sapere quale dato del file ha violato un vincolo.
report($e);
return ['error' => 'Import annullato, nessun dato è stato modificato: il file contiene un dato che il database rifiuta (' . Str::limit($e->getPrevious()?->getMessage() ?? $e->getMessage(), 240) . ').'];
}
return [
'stats' => $stats,
@@ -576,21 +873,84 @@ class XmlExchange extends Component
'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
'remember_token' => (string) ($u->getRememberToken() ?? ''),
// remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(),
// Sorvegliante di gruppo: il proclamatore collegato.
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(),
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at'])->toArray(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'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', 'cartaceo', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
];
}
/**
* Righe da esportare, con le date scritte in modo che il reimport le
* ritrovi identiche.
*
* toArray() serializza ogni data in UTC: con l'applicazione su
* Europe/Rome il 15/03 a mezzanotte diventava "2024-03-14T23:00:00Z" e
* al reimport tornava 14/03, un giorno perso a ogni backup e ripristino.
* Le colonne con cast date escono come Y-m-d, che non ha fuso; i
* timestamp come ISO 8601 con lo scostamento esplicito.
*
* @param array<int, string> $colonne
* @return array<int, array<string, mixed>>
*/
private function righeEsportate(\Illuminate\Database\Eloquent\Builder $query, array $colonne): array
{
return $query->get($colonne)->map(function (\Illuminate\Database\Eloquent\Model $riga) use ($colonne) {
$dati = [];
foreach ($colonne as $colonna) {
$valore = $riga->getAttribute($colonna);
if ($valore instanceof \Carbon\CarbonInterface) {
$valore = $riga->hasCast($colonna, ['date', 'immutable_date'])
? $valore->format('Y-m-d')
: $valore->toIso8601String();
}
$dati[$colonna] = $valore;
}
return $dati;
})->all();
}
/**
* Il file come SimpleXMLElement, oppure il messaggio d'errore con riga e
* motivo: "impossibile leggere il file" da solo non aiuta a correggerlo.
*/
private function leggiXml(string $content): \SimpleXMLElement|string
{
$precedente = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if ($xml !== false) {
return $xml;
}
$errore = libxml_get_errors()[0] ?? null;
return $errore
? sprintf('Impossibile leggere il file XML (riga %d): %s.', $errore->line, trim($errore->message))
: 'Impossibile leggere il file XML.';
} finally {
libxml_clear_errors();
libxml_use_internal_errors($precedente);
}
}
private function legacySqlToDataset(string $sql): array
{
$rows = $this->extractInsertRows($sql);
@@ -880,8 +1240,12 @@ class XmlExchange extends Component
return $nullable ? null : null;
}
// Riportata nel fuso dell'applicazione prima di tenere solo il giorno:
// i backup fatti prima della correzione dell'export hanno le date in
// UTC ("2024-03-14T23:00:00Z" per il 15/03), e così tornano giuste.
// Una data senza orario e senza fuso resta com'è.
try {
return Carbon::parse($raw)->toDateString();
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
} catch (\Throwable $e) {
return $nullable ? null : null;
}
@@ -923,7 +1287,7 @@ class XmlExchange extends Component
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');
@@ -957,6 +1321,7 @@ class XmlExchange extends Component
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
$node->addChild('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? ''));
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore);
}
@@ -972,6 +1337,7 @@ class XmlExchange extends Component
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
@@ -988,6 +1354,16 @@ class XmlExchange extends Component
$this->addTimestampNodes($node, $anno);
}
// Gruppi di servizio: il sorvegliante è un proclamatore (legacy id).
$gruppiNode = $xml->addChild('gruppi_servizio');
foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) {
$node = $gruppiNode->addChild('gruppo');
$this->addLegacyIdAttribute($node, $gruppo);
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
$this->addTimestampNodes($node, $gruppo);
}
$campagneNode = $xml->addChild('campagne');
foreach ($dataset['campagne'] as $campagna) {
$node = $campagneNode->addChild('campagna');
@@ -1012,6 +1388,11 @@ class XmlExchange extends Component
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$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'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
$this->addTimestampNodes($node, $assegnazione);
@@ -1022,6 +1403,13 @@ class XmlExchange extends Component
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{
// XML 1.0 non ammette i caratteri di controllo (tranne tab e a capo)
// né l'UTF-8 non valido: uno solo, magari incollato da Word in una
// nota, rende l'intero file illeggibile al reimport — e quel file è
// anche il data.xml dei backup.
$value = mb_scrub($value, 'UTF-8');
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
$child = $parent->addChild($name);
$dom = dom_import_simplexml($child);
$dom->textContent = $value;
@@ -1062,7 +1450,7 @@ class XmlExchange extends Component
}
try {
return Carbon::parse($raw)->toDateTimeString();
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
} catch (\Throwable $e) {
return null;
}

View File

@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
return;
}
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
if ($operationId === null) {
return;
}
$this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
$this->operationId = $operationId;
CreateBackupArchive::dispatch($this->operationId, true);

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use App\Models\Zona;
class ZoneIndex extends Component
{
use Avvisa;
public string $nuovaZona = '';
public ?int $editingId = null;
public string $editingNome = '';
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
$this->nuovaZona = '';
session()->flash('success', 'Zona aggiunta.');
$this->avvisa('Zona aggiunta.');
}
public function startEdit(int $id)
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
$zona->update(['nome' => $this->editingNome]);
$this->editingId = null;
$this->editingNome = '';
session()->flash('success', 'Zona aggiornata.');
$this->avvisa('Zona aggiornata.');
}
public function cancelEdit()
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
{
$zona = Zona::findOrFail($id);
if ($zona->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
$this->avvisaErrore("Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
return;
}
$zona->delete();
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
$this->avvisa("Zona '{$zona->nome}' eliminata.");
}
public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
class TerritorioCestino extends Component
{
use Avvisa;
use WithPagination;
public function restore(int $id)
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
activity()->causedBy(auth()->user())
->performedOn($territorio)
->log('restored');
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
$this->avvisa("Territorio {$territorio->numero} ripristinato.");
}
public function forceDelete(int $id)
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
$territorio = Territorio::onlyTrashed()->findOrFail($id);
if ($territorio->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
$this->avvisaErrore('Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
return;
}
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
$this->rimuoviFile($territorio);
$territorio->forceDelete();
session()->flash('success', 'Territorio eliminato definitivamente.');
$this->avvisa('Territorio eliminato definitivamente.');
}
/**

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Territorio;
@@ -11,6 +12,8 @@ use App\Services\TerritorioThumbnailService;
class TerritorioCreate extends Component
{
use RicordaRitorno;
use WithFileUploads;
public string $numero = '';
@@ -64,7 +67,7 @@ class TerritorioCreate extends Component
->log('created');
session()->flash('success', "Territorio {$territorio->numero} creato.");
return redirect()->route('territori.index');
return redirect()->to($this->destinazione(route('territori.index')));
}
public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Territorio;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Storage;
class TerritorioEdit extends Component
{
use RicordaRitorno;
use WithFileUploads;
public Territorio $territorio;
@@ -64,14 +67,17 @@ class TerritorioEdit extends Component
if ($this->pdf) {
$thumbService = app(TerritorioThumbnailService::class);
// Remove old PDF and thumbnail
if ($this->territorio->pdf_path) {
Storage::disk('public')->delete($this->territorio->pdf_path);
}
if ($this->territorio->thumbnail_path) {
$thumbService->delete($this->territorio->thumbnail_path);
}
// Prima il nuovo, poi il vecchio nello storico (ripristinabile
// dalla pagina Piantine). Prima il vecchio si cancellava subito:
// un caricamento sbagliato non si poteva disfare, e con il
// salvataggio fallito il territorio restava senza PDF.
$pdfPath = $this->pdf->store('territori-pdf', 'public');
if (! $pdfPath) {
$this->addError('pdf', 'Non è stato possibile salvare il PDF: quello in uso non è stato toccato.');
return;
}
app(\App\Services\Piantine\PiantinaPdfService::class)->archiviaCorrente($this->territorio, 'manuale');
$data['pdf_path'] = $pdfPath;
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
}
@@ -79,7 +85,7 @@ class TerritorioEdit extends Component
$this->territorio->update($data);
session()->flash('success', "Territorio {$this->territorio->numero} aggiornato.");
return redirect()->route('territori.show', $this->territorio);
return redirect()->to($this->destinazione(route('territori.show', $this->territorio)));
}
public function removePdf()

View File

@@ -2,7 +2,9 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
@@ -11,16 +13,28 @@ use App\Models\Tipologia;
class TerritorioIndex extends Component
{
use Avvisa;
use WithPagination;
// Ricerca, filtri e ordinamento stanno nell'indirizzo (#[Url]): tornando
// da una scheda o da un'operazione l'elenco si ritrova com'era.
#[Url(except: '')]
public string $search = '';
#[Url(except: '')]
public string $filterZona = '';
#[Url(except: '')]
public string $filterTipologia = '';
#[Url(except: '')]
public string $filterStato = '';
#[Url(except: '')]
public string $filterPriorita = '';
#[Url(except: '')]
public string $filterPdf = '';
#[Url(except: '')]
public string $filterContenuti = '';
#[Url(except: 'numero')]
public string $sortField = 'numero';
#[Url(except: 'asc')]
public string $sortDirection = 'asc';
protected $queryString = [
@@ -127,7 +141,7 @@ class TerritorioIndex extends Component
->performedOn($territorio)
->log('soft_deleted');
$territorio->delete();
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
$this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
}
public function render()
@@ -141,6 +155,7 @@ class TerritorioIndex extends Component
'tipologia',
'assegnazioneCorrente.proclamatore',
'ultimaAssegnazione',
'gruppo',
]);
if ($this->search) {
@@ -165,6 +180,7 @@ class TerritorioIndex extends Component
if ($this->filterStato) {
match ($this->filterStato) {
'in_reparto' => $query->inReparto(),
'in_gruppo' => $query->inGruppo(),
'assegnato' => $query->assegnato(),
'da_rientrare' => $query->daRientrare(),
'inattivo' => $query->where('attivo', false),

View File

@@ -2,12 +2,15 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Territorio;
use App\Models\Assegnazione;
class TerritorioShow extends Component
{
use RicordaRitorno;
public Territorio $territorio;
public function mount(Territorio $territorio)
@@ -20,8 +23,11 @@ class TerritorioShow extends Component
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
->orderByDesc('assigned_at')
->orderByDesc('id')
->get()
->groupBy(fn($a) => $a->annoTeocratico->label);
->groupBy(fn($a) => $a->annoTeocratico->label)
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
->sortKeysDesc();
return view('livewire.territori.territorio-show', [
'activeAssignment' => $this->territorio->assegnazioneCorrente,

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Il modulo S-13 in PDF per un amministratore (registro:invia-s13). Non va in
* coda (niente ShouldQueue): il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro.
*/
class ModuloS13Mail extends Mailable
{
public function __construct(
public User $utente,
public AnnoTeocratico $anno,
private string $pdf,
) {}
public function nomeFile(): string
{
return "S-13-registro-assegnazioni-{$this->anno->label}.pdf";
}
public function envelope(): Envelope
{
return new Envelope(subject: "Modulo S-13 — anno di servizio {$this->anno->label}");
}
public function content(): Content
{
return new Content(
text: 'mail.modulo-s13',
with: ['data' => now()->format('d/m/Y')],
);
}
/** @return array<int, Attachment> */
public function attachments(): array
{
return [
Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'),
];
}
}

View File

@@ -20,11 +20,22 @@ class Assegnazione extends Model
'campaign_id',
'pdf_access_code',
'link_sent',
'link_da_inviare',
'link_canale',
'cartaceo',
'note',
'created_by',
'returned_by',
'gruppo_servizio_id',
'gruppo_nome',
];
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
protected function casts(): array
{
return [
@@ -32,6 +43,10 @@ class Assegnazione extends Model
'returned_at' => 'date',
'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',
];
}
@@ -53,6 +68,58 @@ class Assegnazione extends Model
$territorio->update(['prioritario' => false]);
}
});
// Al rientro un territorio del pacchetto di un gruppo torna in reparto
// ed esce dal pacchetto. Qui e non nella pagina Rientra: vale per ogni
// via (rientro del sorvegliante, dell'amministratore, Registro).
static::updated(function (self $assegnazione): void {
if (! $assegnazione->wasChanged('returned_at') || $assegnazione->returned_at === null) {
return;
}
Territorio::withTrashed()
->whereKey($assegnazione->territorio_id)
->whereNotNull('gruppo_servizio_id')
->update(['gruppo_servizio_id' => null]);
});
}
/**
* L'assegnazione dello stesso territorio il cui periodo si sovrappone a
* quello indicato, oppure null. Un'assegnazione aperta dura fino a oggi e
* oltre; rientro e nuova uscita nello stesso giorno non si sovrappongono.
*
* Copre in un colpo solo la doppia assegnazione aperta, l'uscita
* retrodatata a prima dell'ultimo rientro e le correzioni a mano nel
* Registro che accavallano due periodi. Il confronto è fatto in PHP sulle
* poche assegnazioni del territorio: in SQL dipenderebbe da come il
* driver memorizza le DATE (MariaDB "Y-m-d", SQLite "Y-m-d H:i:s").
*/
public static function sovrappostaA(
int $territorioId,
Carbon $uscita,
?Carbon $rientro = null,
?int $esclusaId = null
): ?self {
$uscita = $uscita->copy()->startOfDay();
$rientro = $rientro?->copy()->startOfDay();
return static::with('proclamatore')
->where('territorio_id', $territorioId)
->when($esclusaId, fn ($q) => $q->whereKeyNot($esclusaId))
->orderBy('assigned_at')
->get()
->first(fn (self $altra) => ($rientro === null || $altra->assigned_at->lt($rientro))
&& ($altra->returned_at === null || $uscita->lt($altra->returned_at)));
}
/** Descrizione del periodo per i messaggi d'errore: "a Rossi Mario dal 01/09/2025 al 15/10/2025". */
public function descrizionePeriodo(): string
{
$chi = $this->proclamatore?->nome_completo ?? 'un proclamatore';
$al = $this->returned_at ? ' al ' . $this->returned_at->format('d/m/Y') : ', non ancora rientrato';
return "a {$chi} dal {$this->assigned_at->format('d/m/Y')}{$al}";
}
// ─── Relationships ─────────────────────────────────────────
@@ -95,7 +162,8 @@ class Assegnazione extends Model
public function getGiorniAttribute(): int
{
$end = $this->returned_at ?? now();
return Carbon::parse($this->assigned_at)->diffInDays($end);
// Carbon 3 restituisce un float: i giorni interi trascorsi.
return (int) Carbon::parse($this->assigned_at)->diffInDays($end);
}
public function getIsApertaAttribute(): bool
@@ -173,9 +241,56 @@ class Assegnazione extends Model
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
}
public function markLinkSent(): void
public function markLinkSent(?string $canale = null): void
{
$this->forceFill(['link_sent' => true])->saveQuietly();
$this->registraInvioLink(true, $canale);
}
/**
* Canali con cui si manda il link al proclamatore. Dal 19 ottobre 2026 se
* ne usano due: copia link e Condividi (il menu di condivisione del
* telefono); il destinatario lo sceglie chi usa l'app. "whatsapp" ed
* "email" restano solo perché le assegnazioni già inviate così lo
* mostrino e l'XML lo accetti.
*/
public const CANALI_LINK = ['copia' => 'Copia link', 'condividi' => 'Condividi', 'whatsapp' => 'WhatsApp', 'email' => 'Email'];
/** I canali che si possono scegliere oggi nella finestra di invio. */
public const CANALI_ATTIVI = ['copia', 'condividi'];
/**
* Esito dell'invio del link: inviato (con il canale usato) oppure no. Un
* link non inviato resta "da inviare" e lo si segnala sull'assegnazione,
* a meno che non ci sia proprio un link da mandare (territorio senza PDF).
*/
public function registraInvioLink(bool $inviato, ?string $canale = null): void
{
$this->forceFill([
'link_sent' => $inviato,
'link_da_inviare' => ! $inviato && $this->shortPdfUrl() !== null,
'link_canale' => $inviato ? $canale : $this->link_canale,
])->saveQuietly();
}
/** Il link va ancora mandato: l'avviso da mostrare sull'assegnazione. */
public function getLinkInSospesoAttribute(): bool
{
return $this->link_da_inviare && ! $this->link_sent && $this->is_aperta;
}
/**
* Il messaggio con il link, per "Condividi": il menu di condivisione del
* telefono, da cui chi usa l'app sceglie app e contatto. L'app non vede
* quei contatti.
*/
public function messaggioLink(?string $link = null): ?string
{
$link ??= $this->shortPdfUrl();
if ($link === null) {
return null;
}
return "Ciao {$this->proclamatore?->nome}, ti è stato assegnato il territorio N° {$this->territorio?->numero}. Ecco la piantina: {$link}";
}
// ─── Scopes ─────────────────────────────────────────────────
@@ -197,16 +312,69 @@ class Assegnazione extends Model
// ─── Business Logic ─────────────────────────────────────────
/**
* Registra nell'audit log la scelta dell'operatore sul conteggio in
* campagna: evento dedicato "campagna_percorso", leggibile senza dover
* interpretare le proprietà dell'evento "returned".
*/
public function logCampagnaPercorso(?Campagna $campagna, bool $percorso, string $origine): void
{
$this->loadMissing('territorio');
$giorni = $this->giorniPercorrenza();
$soglia = static::sogliaPercorrenzaElevata();
activity()->causedBy(auth()->user())
->performedOn($this)
->withProperties([
'territorio' => $this->territorio?->numero,
'proclamatore_id' => $this->proclamatore_id, // vedi Assegna: niente nomi in chiaro
'campagna' => $campagna?->descrizione,
'percorso_in_campagna' => $percorso ? 'Sì' : 'No',
'giorni_percorrenza' => $giorni,
'avviso_tempo_elevato' => $giorni > $soglia ? "Sì (soglia {$soglia} giorni)" : 'No',
'origine' => $origine,
])
->log('campagna_percorso');
}
/**
* Giorni tra assegnazione e rientro (o la data indicata, o oggi).
*/
public function giorniPercorrenza(?Carbon $returnDate = null): int
{
$end = $returnDate ?? $this->returned_at ?? now();
return (int) Carbon::parse($this->assigned_at)->startOfDay()->diffInDays(Carbon::parse($end)->startOfDay());
}
/**
* Oltre questa soglia il tempo di percorrenza è considerato molto alto:
* è la stessa soglia "giorni_per_smarrito" dello stato "da_rientrare".
*/
public static function sogliaPercorrenzaElevata(): int
{
return (int) Setting::getValue('giorni_per_smarrito', 120);
}
/**
* Check if a campaign prompt should be shown when returning this assignment.
* Returns the matching campaign or null.
*/
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna
{
$returnDate = $returnDate ?? now();
$returnDate = ($returnDate ?? now())->toDateString();
$assignedAt = $this->assigned_at->toDateString();
// Il territorio può essere conteggiato per una campagna se è rimasto
// in mano al proclamatore almeno in parte del periodo: assegnato
// durante la campagna, rientrato durante la campagna, oppure fuori per
// tutta la sua durata (assegnato prima e rientrato dopo). Se sia stato
// davvero percorso lo decide l'operatore con la spunta.
// Se più campagne corrispondono si propone la più recente.
return Campagna::where('start_date', '<=', $returnDate)
->where('end_date', '>=', $this->assigned_at)
->where('end_date', '>=', $assignedAt)
->orderByDesc('start_date')
->first();
}
}

View File

@@ -46,8 +46,13 @@ class Campagna extends Model
*/
public static function attiva(): ?static
{
return static::where('start_date', '<=', now())
->where('end_date', '>=', now())
// Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
// suo giorno, e contro now() la campagna spariva dalla home proprio
// nel suo ultimo giorno.
$oggi = now()->toDateString();
return static::whereDate('start_date', '<=', $oggi)
->whereDate('end_date', '>=', $oggi)
->first();
}
@@ -60,6 +65,16 @@ class Campagna extends Model
->where('counted_in_campaign', true);
}
/**
* Assignments made during this campaign and not yet returned.
*/
public function assegnazioniInCorso()
{
return Assegnazione::whereNull('returned_at')
->where('assigned_at', '>=', $this->start_date)
->where('assigned_at', '<=', $this->end_date);
}
/**
* All assignments with assigned_at in this campaign's range.
*/
@@ -70,9 +85,11 @@ class Campagna extends Model
}
/**
* Campaign coverage percentage.
* Numerator: assignments counted for campaign
* Denominator: total active territories
* Quota dei territori attivi percorsi durante la campagna.
*
* Si contano i territori, non le assegnazioni: un territorio percorso due
* volte valeva doppio, e insieme ai territori poi disattivati (fuori dal
* denominatore ma ancora nel numeratore) la percentuale superava il 100%.
*/
public function getPercentualePercorrenzaAttribute(): float
{
@@ -82,13 +99,17 @@ class Campagna extends Model
return 0.0;
}
$conteggiate = $this->assegnazioniConteggiate()->count();
$percorsi = $this->assegnazioniConteggiate()
->whereHas('territorio', fn ($q) => $q->where('attivo', true))
->distinct()
->count('territorio_id');
return round(($conteggiate / $totaleAttivi) * 100, 1);
return round(($percorsi / $totaleAttivi) * 100, 1);
}
public function scopeCompletate($query)
{
return $query->where('end_date', '<', now())->orderByDesc('end_date');
// Una campagna che finisce oggi è ancora in corso.
return $query->whereDate('end_date', '<', now()->toDateString())->orderByDesc('end_date');
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Gruppo di servizio: un sorvegliante (proclamatore con un'utenza limitata
* al gruppo), i membri (proclamatori, un solo gruppo a testa) e un pacchetto
* di territori che il sorvegliante distribuisce ai membri.
*
* Il pacchetto non è un'assegnazione: un territorio del pacchetto non ancora
* dato a nessuno è "in carico al gruppo" e non entra nelle statistiche. Quando
* il sorvegliante lo dà a un membro nasce un'assegnazione normale, con
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
* reparto ed esce dal pacchetto.
*
* Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
* ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
* il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
*/
class GruppoServizio extends Model
{
use LogsActivity;
protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id'];
protected function casts(): array
{
return ['sorvegliante_id' => 'integer'];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function sorvegliante()
{
return $this->belongsTo(Proclamatore::class, 'sorvegliante_id')->withTrashed();
}
public function membri()
{
return $this->hasMany(Proclamatore::class, 'gruppo_servizio_id');
}
/** Il pacchetto: i territori in carico al gruppo, dati o non ancora dati. */
public function territori()
{
return $this->hasMany(Territorio::class, 'gruppo_servizio_id');
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
}
/**
* I numeri del gruppo per la scheda del proclamatore e il profilo del
* sorvegliante: solo conteggi in SQL, nessun nome da decifrare.
*
* @return array{membri: int, pacchetto: int, da_dare: int, dati: int, assegnazioni: int}
*/
public function riepilogo(): array
{
$pacchetto = $this->territori()->count();
$dati = $this->territori()->whereHas('assegnazioni', fn ($q) => $q->whereNull('returned_at'))->count();
return [
'membri' => $this->membri()->count(),
'pacchetto' => $pacchetto,
'da_dare' => $pacchetto - $dati,
'dati' => $dati,
'assegnazioni' => $this->assegnazioni()->count(),
];
}
}

View File

@@ -13,7 +13,62 @@ class Proclamatore extends Model
protected $table = 'proclamatori';
protected $fillable = ['nome', 'cognome', 'attivo'];
// Niente email né telefono (tolti il 19 ottobre 2026): il link della
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
// destinatario lo sceglie dai propri contatti.
protected $fillable = ['nome', 'cognome', 'attivo', 'gruppo_servizio_id'];
/** Il gruppo di servizio di cui è membro (al massimo uno). */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
/** L'utenza, se è sorvegliante di un gruppo. */
public function utente()
{
return $this->hasOne(User::class, 'proclamatore_id');
}
/**
* Un sorvegliante che non è più attivo — reso non attivo, spostato nel
* cestino o anonimizzato — non guida più il gruppo: il gruppo si scioglie
* (GruppiServizio::sciogli), da qualunque pagina arrivi la modifica. Un
* membro nel cestino lascia il gruppo.
*/
protected static function booted(): void
{
static::updated(function (self $proclamatore) {
if ($proclamatore->wasChanged('attivo') && ! $proclamatore->attivo) {
$proclamatore->lasciaIlGruppo('sorvegliante non più attivo');
}
});
static::deleted(function (self $proclamatore) {
$proclamatore->lasciaIlGruppo('sorvegliante eliminato');
});
}
/** Il gruppo attivo di cui è sorvegliante, se c'è. */
public function gruppoSorvegliato(): ?GruppoServizio
{
return GruppoServizio::where('sorvegliante_id', $this->id)->first();
}
private function lasciaIlGruppo(string $motivo): void
{
$gruppo = $this->gruppoSorvegliato();
if ($gruppo) {
app(\App\Services\GruppiServizio::class)->sciogli($gruppo, $motivo);
return;
}
if ($this->trashed() && $this->gruppo_servizio_id) {
static::withTrashed()->whereKey($this->id)->update(['gruppo_servizio_id' => null]);
$this->gruppo_servizio_id = null;
}
}
protected function casts(): array
{
@@ -21,6 +76,7 @@ class Proclamatore extends Model
'nome' => 'encrypted',
'cognome' => 'encrypted',
'attivo' => 'boolean',
'gruppo_servizio_id' => 'integer',
];
}
@@ -40,6 +96,60 @@ class Proclamatore extends Model
return trim($this->cognome . ' ' . $this->nome);
}
/**
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
* cognome e nome. Nome e cognome sono cifrati nel database: la ricerca
* avviene sui valori già decifrati, nella pagina, mai in SQL.
*/
public function opzioneScelta(?string $motivo = null): array
{
// Il gruppo di servizio come nota, solo se già caricato (opzioniScelta).
$gruppo = $this->relationLoaded('gruppo') ? $this->gruppo_etichetta : null;
return array_filter([
'valore' => (string) $this->id,
'etichetta' => $this->nome_completo,
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
'chiavi' => [(string) $this->cognome, (string) $this->nome],
'motivo' => $motivo,
], fn ($v) => $v !== null);
}
/**
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. Con $conGruppo
* chi fa parte di un gruppo di servizio lo dice accanto al nome (non serve
* dove sono tutti dello stesso gruppo, come in "Il mio gruppo").
*/
public static function opzioniScelta(iterable $proclamatori, bool $conGruppo = true): array
{
if ($conGruppo && $proclamatori instanceof \Illuminate\Database\Eloquent\Collection) {
$proclamatori->loadMissing('gruppo');
}
return collect($proclamatori)
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
->values()
->all();
}
/**
* "Sorvegliante del gruppo Zisa" o "Gruppo Zisa"; null fuori dai gruppi.
* Senza gruppo_servizio_id non fa query.
*/
public function getGruppoEtichettaAttribute(): ?string
{
$gruppo = $this->gruppo;
if (! $gruppo) {
return null;
}
return $gruppo->sorvegliante_id === $this->id
? "Sorvegliante del gruppo {$gruppo->nome}"
: "Gruppo {$gruppo->nome}";
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
@@ -64,6 +174,9 @@ class Proclamatore extends Model
$this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false;
$this->gruppo_servizio_id = null;
// Se era sorvegliante, il salvataggio (non più attivo) scioglie il
// gruppo ed elimina la sua utenza: booted().
$this->save();
$this->delete(); // soft delete
}

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

@@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Una richiesta dal modulo pubblico (/richiesta): un proclamatore chiede un
* territorio nuovo o ne annuncia la riconsegna. Non tocca niente da sola:
* compare nella Home a chi ha `richieste.gestisci` (amministratore e
* assistenti), che la porta sul modulo di assegnazione o di rientro già
* esistenti; fatta l'operazione la richiesta risulta evasa. Si può anche
* scartare. Logica in App\Services\RichiesteServizio.
*
* Nome e note li scrive chi compila, senza account: cifrati come i dati dei
* proclamatori, e fuori dal registro delle attività (che logga solo lo stato).
*/
class Richiesta extends Model
{
use LogsActivity;
public const NUOVO = 'nuovo';
public const RICONSEGNA = 'riconsegna';
public const APERTA = 'aperta';
public const EVASA = 'evasa';
public const SCARTATA = 'scartata';
protected $table = 'richieste';
// Appena creata è aperta anche nell'oggetto, non solo nel database.
protected $attributes = ['stato' => self::APERTA];
// L'arrivo lo registra RichiesteServizio (con il numero del territorio):
// qui solo i cambi di stato, con chi li ha fatti.
protected static $recordEvents = ['updated'];
protected $fillable = ['tipo', 'nome', 'numero_territorio', 'note', 'stato', 'assegnazione_id', 'gestita_da', 'gestita_at'];
protected function casts(): array
{
return [
'nome' => 'encrypted',
'note' => 'encrypted',
'gestita_at' => 'datetime',
'assegnazione_id' => 'integer',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['tipo', 'stato', 'assegnazione_id'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function assegnazione()
{
return $this->belongsTo(Assegnazione::class);
}
public function gestitaDa()
{
return $this->belongsTo(User::class, 'gestita_da');
}
public function scopeAperte($query)
{
return $query->where('stato', self::APERTA);
}
public function isAperta(): bool
{
return $this->stato === self::APERTA;
}
public function getTipoEtichettaAttribute(): string
{
return $this->tipo === self::RICONSEGNA ? 'Riconsegna' : 'Nuovo territorio';
}
/** Chiusa da un'assegnazione o da un rientro fatti a partire da lei. */
public function evadi(?Assegnazione $assegnazione, ?User $chi): void
{
if (! $this->isAperta()) {
return;
}
$this->update([
'stato' => self::EVASA,
'assegnazione_id' => $assegnazione?->id,
'gestita_da' => $chi?->id,
'gestita_at' => now(),
]);
}
public function scarta(?User $chi): void
{
if (! $this->isAperta()) {
return;
}
$this->update([
'stato' => self::SCARTATA,
'gestita_da' => $chi?->id,
'gestita_at' => now(),
]);
}
}

View File

@@ -20,8 +20,31 @@ class Setting extends Model
'pdf_viewer_show_download',
'audit_retention_days',
'setup_completed',
'mail_host',
'mail_port',
'mail_cifratura',
'mail_username',
'mail_password',
'mail_from_address',
'mail_from_name',
];
/**
* Il server di posta: configurazione di questa installazione, non dati.
* Non si esporta e un import non la tocca (vedi XmlExchange).
*/
public const CAMPI_POSTA = [
'mail_host',
'mail_port',
'mail_cifratura',
'mail_username',
'mail_password',
'mail_from_address',
'mail_from_name',
];
protected $hidden = ['mail_password'];
protected function casts(): array
{
return [
@@ -33,6 +56,8 @@ class Setting extends Model
'home_limit_list' => 'integer',
'assignment_link_ttl_hours' => 'integer',
'audit_retention_days' => 'integer',
'mail_port' => 'integer',
'mail_password' => 'encrypted',
];
}

View File

@@ -24,12 +24,20 @@ class Territorio extends Model
'thumbnail_path',
'attivo',
'prioritario',
'gruppo_servizio_id',
];
/** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
protected function casts(): array
{
return [
'attivo' => 'boolean',
'gruppo_servizio_id' => 'integer',
'prioritario' => 'boolean',
];
}
@@ -61,9 +69,13 @@ class Territorio extends Model
public function assegnazioneCorrente()
{
// Il filtro sulle aperte va nella closure di ofMany: scritto fuori con
// whereNull() vale solo per la query esterna, mentre il MAX(assigned_at)
// del sottoquery si calcola su tutte le assegnazioni. Bastava una
// chiusa più recente dell'aperta per far risultare il territorio
// libero mentre era fuori, e quindi assegnabile una seconda volta.
return $this->hasOne(Assegnazione::class, 'territorio_id')
->whereNull('returned_at')
->latestOfMany('assigned_at');
->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
}
public function ultimaAssegnazione()
@@ -91,9 +103,25 @@ class Territorio extends Model
return 'assegnato';
}
// Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in
// reparto (non si assegna dalla pagina normale), è del gruppo.
if ($this->gruppo_servizio_id) {
return 'in_gruppo';
}
return 'in_reparto';
}
/** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */
public function getStatoEtichettaAttribute(): string
{
$stato = $this->stato;
return $stato === 'in_gruppo'
? 'In gruppo ' . ($this->gruppo?->nome ?? '')
: str_replace('_', ' ', ucfirst($stato));
}
public function getAssegnatarioAttribute(): ?Proclamatore
{
return $this->assegnazioneCorrente?->proclamatore;
@@ -141,6 +169,59 @@ class Territorio extends Model
return false;
}
/**
* La logica di priorità dei territori da distribuire, la stessa ovunque
* (Home "Da assegnare", pacchetto di un gruppo, "Il mio gruppo"): prima i
* prioritari — spunta manuale o fermi da più giorni della soglia
* "giorni_giacenza_prioritari" — poi chi è fermo da più tempo, poi il
* numero. Su ogni territorio imposta giacenza_giorni (dall'ultimo rientro,
* o dalla creazione se mai assegnato; 0 se è fuori) e in_priorita, da
* mostrare. Vuole 'ultimaAssegnazione' già caricata, per non fare una
* query a territorio.
*/
public static function ordinaPerPriorita(iterable $territori): \Illuminate\Support\Collection
{
$soglia = (int) (Setting::instance()->giorni_giacenza_prioritari ?? 180);
return collect($territori)
->each(function (self $territorio) use ($soglia) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorni = (int) $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorni = (int) $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorni = 0;
}
$territorio->setAttribute('giacenza_giorni', $giorni);
$territorio->setAttribute('in_priorita', (bool) $territorio->prioritario || $giorni > $soglia);
})
->sort(function (self $a, self $b) {
return [(int) $b->in_priorita, $b->giacenza_giorni] <=> [(int) $a->in_priorita, $a->giacenza_giorni]
?: strnatcasecmp((string) $a->numero, (string) $b->numero);
})
->values();
}
/**
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
* numero, zona e tipologia. Con $motivo compare cercando ma non si può
* scegliere ("già assegnato a …").
*/
public function opzioneScelta(?string $motivo = null, ?string $nota = null): array
{
$dettaglio = trim(($this->zona?->nome ?? '') . ($this->tipologia?->nome ? " ({$this->tipologia->nome})" : ''));
return array_filter([
'valore' => (string) $this->id,
'etichetta' => "N° {$this->numero}" . ($nota ? " ({$nota})" : ''),
'dettaglio' => $dettaglio !== '' ? $dettaglio : null,
'chiavi' => [(string) $this->numero, (string) $this->zona?->nome, (string) $this->tipologia?->nome],
'motivo' => $motivo,
], fn ($v) => $v !== null);
}
// ─── Scopes ─────────────────────────────────────────────────
public function scopeAttivi($query)
@@ -150,12 +231,23 @@ class Territorio extends Model
public function scopeInReparto($query)
{
// Quelli nel pacchetto di un gruppo non sono in reparto: stanno al
// gruppo finché il sorvegliante non li dà o non li rimanda indietro.
return $query->attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('assegnazioni', function ($q) {
$q->whereNull('returned_at');
});
}
/** Nel pacchetto di un gruppo e non ancora dati a nessuno. */
public function scopeInGruppo($query)
{
return $query->attivi()
->whereNotNull('gruppo_servizio_id')
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
}
public function scopeAssegnato($query)
{
return $query->attivi()
@@ -216,4 +308,32 @@ class Territorio extends Model
});
});
}
// ─── Indirizzi dei file (serviti da TerritorioFileController) ──────────
/**
* ?v= cambia con il file: l'indirizzo resta per territorio, e senza la
* cache del browser mostrerebbe il PDF di prima dopo una sostituzione.
*/
public function pdfUrl(): ?string
{
return $this->pdf_path
? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)])
: null;
}
public function miniaturaUrl(): ?string
{
return $this->thumbnail_path
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
/** La miniatura per il sorvegliante di gruppo ("Il mio gruppo"): solo del suo pacchetto. */
public function miniaturaGruppoUrl(): ?string
{
return $this->thumbnail_path
? route('mio-gruppo.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
}

View File

@@ -11,15 +11,30 @@ use Illuminate\Database\Eloquent\Model;
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
* campo `nome` come etichetta; una volta collegato comanda il numero del
* territorio, che e' il riferimento con cui lo chiamano tutti.
*
* `geojson` e' la bozza: l'editor la salva da solo a ogni modifica, e non
* tocca il PDF del territorio. Il PDF cambia solo con la pubblicazione, che
* ricorda cosa e' stato pubblicato (`geojson_pubblicato`,
* `impronta_pubblicata`, `pubblicata_at`).
*/
class TerritorioMappa extends Model
{
public const STATO_BOZZA = 'bozza';
public const STATO_PUBBLICATA = 'pubblicata';
public const STATO_MODIFICATA = 'modificata';
protected $table = 'territorio_mappe';
protected $fillable = [
'territorio_id',
'nome',
'geojson',
'geojson_pubblicato',
'impronta_pubblicata',
'pdf_pubblicato',
'pubblicata_at',
'image_path',
'rendered_at',
];
@@ -28,12 +43,19 @@ class TerritorioMappa extends Model
{
return [
'rendered_at' => 'datetime',
'pubblicata_at' => 'datetime',
];
}
/**
* withTrashed: un territorio nel cestino resta collegato. Senza, la
* relazione tornava null, l'editor mostrava la piantina come scollegata e
* il primo salvataggio cancellava il collegamento — che al ripristino del
* territorio non c'era piu'.
*/
public function territorio()
{
return $this->belongsTo(Territorio::class, 'territorio_id');
return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
}
/**
@@ -42,15 +64,22 @@ class TerritorioMappa extends Model
*/
public function geometria(): ?array
{
$decoded = json_decode($this->geojson, true);
$decoded = json_decode((string) $this->geojson, true);
return is_array($decoded) ? $decoded : null;
}
/** Se è collegata a un territorio, il PDF si può generare. */
/** Collegata a un territorio vivo: è la condizione per pubblicare. */
public function collegata(): bool
{
return $this->territorio_id !== null && $this->territorio !== null;
return $this->territorio_id !== null
&& $this->territorio !== null
&& ! $this->territorio->trashed();
}
public function territorioNelCestino(): bool
{
return $this->territorio !== null && $this->territorio->trashed();
}
/**
@@ -60,7 +89,7 @@ class TerritorioMappa extends Model
*/
public function etichetta(): string
{
if ($this->collegata()) {
if ($this->territorio !== null) {
return 'Terr. ' . $this->territorio->numero;
}
@@ -68,4 +97,93 @@ class TerritorioMappa extends Model
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
}
// ─── Bozza e pubblicazione ──────────────────────────────────────────────
/**
* Impronta di tutto cio' che finisce nel PDF: confine, territorio e i
* suoi dati stampati sulla cartolina (numero, zona come «Località»,
* confini). Se cambia uno qualunque di questi, il PDF in vigore non
* corrisponde piu' e la piantina ha modifiche da pubblicare — anche quando
* il disegno e' rimasto lo stesso.
*/
public function impronta(): string
{
$territorio = $this->territorio;
// Il territorio entra con il numero, non con l'id: il numero è unico e
// il ripristino di un backup ricrea i territori con id nuovi, che
// facevano risultare da pubblicare ogni piantina appena ripristinata.
return hash('sha256', json_encode([
$this->geometria(),
$territorio ? trim((string) $territorio->numero) : null,
$territorio?->zona?->nome,
$territorio ? trim((string) $territorio->confini) : null,
]));
}
public function stato(): string
{
if ($this->pubblicata_at === null) {
return self::STATO_BOZZA;
}
return $this->impronta() === $this->impronta_pubblicata && $this->pdfInUsoDallaPiantina()
? self::STATO_PUBBLICATA
: self::STATO_MODIFICATA;
}
/**
* Il PDF in vigore del territorio è ancora quello prodotto dall'ultima
* pubblicazione. Se nel frattempo è stato sostituito per altre vie
* (ripristino di una versione, caricamento a mano, import), quello che
* arriva ai proclamatori non è più questo disegno, anche se l'impronta
* torna. Si confronta il percorso, che il backup conserva: prima si
* cercava l'id del territorio nel nome del file, e dopo un ripristino
* (id nuovi) non tornava più.
*/
public function pdfInUsoDallaPiantina(): bool
{
return $this->pdf_pubblicato !== null
&& $this->territorio !== null
&& $this->territorio->pdf_path === $this->pdf_pubblicato;
}
/** Il testo dell'etichetta di stato, uguale in ogni pagina. */
public function statoEtichetta(): string
{
return match ($this->stato()) {
self::STATO_BOZZA => 'Bozza',
self::STATO_PUBBLICATA => 'Pubblicata',
self::STATO_MODIFICATA => 'Modifiche da pubblicare',
};
}
/**
* Perché non si può pubblicare, o null se si può. Il testo va a schermo
* accanto al pulsante: dice cosa fare, non solo che manca qualcosa.
*/
public function motivoNonPubblicabile(): ?string
{
if ($this->geometria() === null) {
return 'Disegna il confine della piantina.';
}
if ($this->territorioNelCestino()) {
return 'Il territorio collegato è nel cestino: ripristinalo o collega un altro territorio.';
}
if (! $this->collegata()) {
return 'Collega la piantina a un territorio: il PDF è quello del territorio.';
}
return null;
}
/** Il confine pubblicato è diverso da quello in bozza: si può scartare. */
public function bozzaScartabile(): bool
{
return $this->geojson_pubblicato !== null
&& json_decode($this->geojson_pubblicato, true) != $this->geometria();
}
}

View File

@@ -2,14 +2,22 @@
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Models\Activity;
use Illuminate\Support\Facades\DB;
class User extends Authenticatable
/**
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
* (middleware `verified`, VerificaEmailController). Chi cambia email — dal
* profilo o perché la cambia l'amministratore — torna da confermare.
*/
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable, HasRoles, LogsActivity;
@@ -18,6 +26,8 @@ class User extends Authenticatable
'email',
'password',
'password_change_required',
'password_temporanea',
'proclamatore_id',
];
protected $hidden = [
@@ -31,9 +41,80 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'password_change_required' => 'boolean',
// Il cambio obbligatorio viene da una password temporanea data
// dall'amministratore, non da un ripristino (pagina del cambio).
'password_temporanea' => 'boolean',
'proclamatore_id' => 'integer',
];
}
/** Il proclamatore di cui è l'utenza (sorvegliante di gruppo). */
public function proclamatore()
{
return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed();
}
/**
* Sorvegliante di gruppo: vede solo il proprio gruppo e il proprio
* profilo (middleware LimitaSorvegliante).
*/
public function isSorvegliante(): bool
{
return $this->hasRole('sorvegliante');
}
/** Il gruppo di cui è sorvegliante (sciolto il gruppo, l'utenza non esiste più). */
public function gruppoSorvegliato(): ?GruppoServizio
{
return $this->proclamatore_id
? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
: null;
}
/**
* Elimina l'utenza senza perdere lo storico: le assegnazioni che ha fatto
* o fatto rientrare passano a $subentra (la colonna non può restare
* vuota), e nel registro delle attività le sue voci si tengono, con
* scritto chi era ($nome, $email) al posto del collegamento all'utenza.
*/
public function eliminaConservandoStorico(self $subentra, string $nome, ?string $email): void
{
DB::transaction(function () use ($subentra, $nome, $email) {
$eliminatoIl = now()->toDateTimeString();
Activity::query()
->where('causer_type', self::class)
->where('causer_id', $this->id)
->chunkById(200, function ($attivita) use ($nome, $email, $eliminatoIl) {
foreach ($attivita as $voce) {
$proprieta = $voce->properties?->toArray() ?? [];
$proprieta['causer_name'] = $nome;
$proprieta['causer_email'] = $email;
$proprieta['causer_deleted_at'] = $eliminatoIl;
$voce->properties = $proprieta;
$voce->save();
}
});
Assegnazione::query()->where('created_by', $this->id)->update(['created_by' => $subentra->id]);
Assegnazione::query()->where('returned_by', $this->id)->update(['returned_by' => $subentra->id]);
$this->syncRoles([]);
$this->delete();
});
}
/**
* Il sorvegliante non conferma l'email (scelta dell'utente, 19 ottobre
* 2026): l'utenza la crea l'amministratore con una password temporanea.
* Per tutti gli altri resta obbligatoria.
*/
public function hasVerifiedEmail(): bool
{
return ! is_null($this->email_verified_at) || $this->isSorvegliante();
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
@@ -41,4 +122,62 @@ class User extends Authenticatable
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
* proclamatore: nel registro delle attività i proclamatori compaiono solo
* con l'identificativo, quindi anche qui.
*/
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
{
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
if (! $proclamatoreId) {
return;
}
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
if (! is_array($valori)) {
return $valori;
}
foreach (['name', 'email'] as $campo) {
if (array_key_exists($campo, $valori)) {
$valori[$campo] = "proclamatore #{$proclamatoreId}";
}
}
return $valori;
});
}
protected static function booted(): void
{
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
static::updating(function (self $utente) {
if ($utente->isDirty('email') && ! $utente->isDirty('email_verified_at')) {
$utente->email_verified_at = null;
}
});
}
/**
* Manda l'email di conferma; false se il server di posta non risponde o
* rifiuta. Chi chiama decide cosa dire: l'utente è comunque salvato, e
* l'email si può richiedere di nuovo.
*/
public function inviaConfermaEmail(): bool
{
if ($this->isSorvegliante()) {
return true;
}
try {
$this->sendEmailVerificationNotification();
} catch (\Throwable $e) {
report($e);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\Richiesta;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Avviso ad amministratori e assistenti: è arrivata una richiesta dal modulo
* pubblico. Volutamente non ShouldQueue: il nome scritto nella richiesta
* finirebbe in chiaro nella coda (Redis, failed_jobs).
*/
class RichiestaRicevuta extends Notification
{
public function __construct(private Richiesta $richiesta) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$r = $this->richiesta;
$messaggio = (new MailMessage)
->subject("Nuova richiesta: {$r->tipo_etichetta} — TerManager2")
->greeting('Ciao ' . $notifiable->name . ',')
->line($r->tipo === Richiesta::RICONSEGNA
? "{$r->nome} annuncia la riconsegna del territorio N° {$r->numero_territorio}."
: "{$r->nome} chiede un territorio nuovo.");
if ($r->note) {
$messaggio->line('Note: ' . $r->note);
}
return $messaggio
->action('Apri la Home di TerManager2', route('dashboard'))
->line('La richiesta si conferma dalla Home, nel riquadro "Richieste".')
->salutation('TerManager2');
}
}

View File

@@ -2,8 +2,14 @@
namespace App\Providers;
use App\Models\Setting;
use App\Services\ConfigurazionePosta;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
use RuntimeException;
@@ -17,6 +23,48 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
// Setting tiene le impostazioni in una variabile statica, svuotata
// solo quando le salva lo stesso processo. Il worker resta vivo fino a
// un'ora: senza questo, ogni job usava le impostazioni lette dal primo,
// anche dopo che l'amministratore le aveva cambiate dal browser o un
// ripristino le aveva sostituite.
Queue::before(function () {
Setting::flushCache();
// Anche il server di posta: cambiato dal browser, il worker
// continuava a usare quello del primo invio.
if ($this->app->resolved('mail.manager')) {
app(ConfigurazionePosta::class)->applica();
}
});
// L'email di conferma in italiano. Il link resta quello di Laravel
// (firmato, scade dopo auth.verification.expire minuti) e porta a
// VerificaEmailController::conferma, che funziona anche senza login.
VerifyEmail::toMailUsing(function ($utente, string $link) {
$minuti = (int) config('auth.verification.expire', 60);
return (new MailMessage)
->subject('Conferma il tuo indirizzo email — TerManager2')
->greeting('Ciao ' . $utente->name . ',')
->line('per usare TerManager2 conferma che questo indirizzo email è tuo.')
->action('Conferma l\'indirizzo', $link)
->line("Il link vale {$minuti} minuti. Se è scaduto, entra in TerManager2 e chiedine uno nuovo.")
->line('Se non sai di cosa si tratta, ignora questa email.')
->salutation('TerManager2');
});
// Le richieste di Livewire non ripassano dal gruppo di rotte della
// pagina: senza, una scheda aperta prima del blocco continuerebbe a
// lavorare con un'email non confermata.
Livewire::addPersistentMiddleware([EnsureEmailIsVerified::class]);
// Il server di posta configurato in Impostazioni prende il posto di
// quello del .env, appena Laravel crea il sistema di posta.
$this->app->afterResolving('mail.manager', function ($manager) {
app(ConfigurazionePosta::class)->applica($manager);
});
// Pin generated URLs to APP_URL.
//
// Behind a trusted proxy, Symfony derives the port from

View File

@@ -20,8 +20,15 @@ use ZipArchive;
*
* manifest.json formato, data, conteggi (letto per validare il file)
* data.xml lo stesso XML dell'export "Esporta XML"
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
* relativo che sta in pdf_path/thumbnail_path/logo_path
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
* che sta accanto al disco `public`, non dentro
*
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
* KML in uso non si tocca (vedi scambiaFile()).
*/
class BackupArchive
{
@@ -29,11 +36,23 @@ class BackupArchive
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
* quello che sa leggere invece di ripristinarli a meta'.
*/
public const FORMAT_VERSION = 1;
public const FORMAT_VERSION = 2;
private const ENTRY_MANIFEST = 'manifest.json';
private const ENTRY_DATA = 'data.xml';
private const ENTRY_PIANTINE = 'piantine.json';
private const PREFIX_STORAGE = 'storage/';
private const PREFIX_PIANTINE = 'piantine/';
/**
* Sottocartelle dello staging, una per ogni cartella che il ripristino
* sostituisce (vedi radiciDaSostituire()).
*/
private const STAGING_PUBBLICO = 'pubblico';
private const STAGING_PIANTINE = 'piantine';
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
private const CARTELLA_PIANTINE = 'piantine';
/**
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
@@ -65,12 +84,32 @@ class BackupArchive
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
*
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
* @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
* Obbligatorio e non facoltativo di proposito: un
* chiamante che se ne dimentica produrrebbe archivi
* che al ripristino svuotano le piantine.
* @param callable|null $onProgress fn(int $fatti, int $totale): void
*/
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
{
$disk = Storage::disk('public');
$files = $disk->allFiles();
$local = Storage::disk('local');
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
// (per i messaggi), percorso sul disco, nome dell'entry.
$files = [];
foreach ($disk->allFiles() as $relativePath) {
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
}
$filePubblici = count($files);
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
// percorso relativo al disco coincide con il nome dell'entry.
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
}
$filePiantine = count($files) - $filePubblici;
$totale = count($files);
$cartella = dirname($destinazione);
@@ -84,12 +123,14 @@ class BackupArchive
}
$zip->addFromString(self::ENTRY_DATA, $xml);
$zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
'format_version' => self::FORMAT_VERSION,
'application' => 'TerManager2',
'generated_at' => now()->toIso8601String(),
'app_url' => (string) config('app.url'),
'files_count' => $totale,
'files_count' => $filePubblici,
'piantine_files_count' => $filePiantine,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
try {
@@ -110,12 +151,12 @@ class BackupArchive
try {
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
$files, $totale, $perLotto, $onProgress, $destinazione
) {
$nelLotto = 0;
$fatti = 0;
foreach ($files as $relativePath) {
foreach ($files as [$relativePath, $sorgente, $entry]) {
// addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo.
//
@@ -133,7 +174,7 @@ class BackupArchive
// raggiunto mai, e l'amministratore leggerebbe la stringa
// grezza di PHP. Stesso problema, e stessa scelta, del
// close() avvolto in chiudi().
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
if (! @$zip->addFile($sorgente, $entry)) {
throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
@@ -157,7 +198,7 @@ class BackupArchive
}
try {
$this->verificaArchivio($destinazione, $totale);
$this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
} catch (\Throwable $e) {
@unlink($destinazione);
@@ -178,7 +219,7 @@ class BackupArchive
* non dei contenuti — quindi e' un controllo a buon mercato che copre
* troncamenti, chiusure a meta' e lotti persi.
*/
private function verificaArchivio(string $path, int $fileAttesi): void
private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void
{
$zip = new ZipArchive();
@@ -187,27 +228,31 @@ class BackupArchive
}
$trovati = 0;
$piantineTrovati = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
$trovati++;
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
$piantineTrovati++;
}
}
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
$zip->close();
if (! $haDati || ! $haManifest) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
if (! $haDati || ! $haManifest || ! $haPiantine) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
}
if ($trovati !== $fileAttesi) {
if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
throw new RuntimeException(sprintf(
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
$trovati,
$fileAttesi
$trovati + $piantineTrovati,
$fileAttesi + $piantineAttesi
));
}
}
@@ -294,8 +339,15 @@ class BackupArchive
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
* dati correnti sono ancora intatti.
*
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
* file troncato, un'entry corrotta o il disco che si riempie durante
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
* anche per lo scambio, che e' solo una serie di rename().
*
* @param callable|null $onProgress fn(int $fatti, int $totale): void
* @return array{xml: string, staging: string, files: int}
* @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int}
*/
public function extract(string $zipPath, ?callable $onProgress = null): array
{
@@ -334,12 +386,35 @@ class BackupArchive
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
}
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
// Null resta riservato al formato 1, che le piantine non le aveva.
$piantine = null;
if ($formatVersion >= 2) {
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
if (! is_array($piantine)) {
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
}
}
// Le entry che si estraggono, prefisso => sottocartella dello
// staging. L'area KML solo dal formato 2: un archivio vecchio non
// la porta, e non deve poterla portare per caso.
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
if ($formatVersion >= 2) {
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
}
if ($zip->numFiles > self::MAX_ENTRIES) {
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
}
$totalBytes = 0;
$extracted = 0;
$estrattiPiantine = 0;
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
// due entry di servizio": un archivio con una entry in piu' (una
@@ -348,7 +423,7 @@ class BackupArchive
$daEstrarre = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
$daEstrarre++;
}
}
@@ -361,11 +436,12 @@ class BackupArchive
continue;
}
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
$prefisso = $this->prefissoDi($entryName, $prefissi);
if ($prefisso === null) {
continue;
}
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
$relativePath = substr($entryName, strlen($prefisso));
$safePath = $this->safeRelativePath($relativePath);
if ($safePath === null) {
@@ -375,7 +451,8 @@ class BackupArchive
// La dimensione dichiarata si legge *prima* di estrarre: e'
// l'unico momento in cui si puo' rifiutare un'entry che, una
// volta decompressa, riempirebbe il disco.
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
$stat = $zip->statIndex($index);
$size = (int) ($stat['size'] ?? 0);
if ($size > self::MAX_ENTRY_BYTES) {
throw new RuntimeException(sprintf(
@@ -390,7 +467,7 @@ class BackupArchive
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
}
$destination = $staging . '/' . $safePath;
$destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
$directory = dirname($destination);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
@@ -409,12 +486,40 @@ class BackupArchive
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
}
stream_copy_to_stream($stream, $target);
// Il risultato della copia si controlla, ed e' qui che il
// ripristino diventa sicuro: prima un disco pieno o un'entry
// corrotta lasciavano nello staging un file troncato senza
// nessun errore, e quel file finiva al posto del PDF buono.
// La @ per lo stesso motivo di addFile(): il warning di PHP
// ("CRC error") arriverebbe all'amministratore al posto del
// messaggio che dice quale file.
$copiati = @stream_copy_to_stream($stream, $target);
fclose($stream);
fclose($target);
$chiuso = fclose($target);
$extracted++;
$onProgress && $onProgress($extracted, $daEstrarre);
if ($copiati === false || $copiati !== $size || ! $chiuso) {
throw new RuntimeException(sprintf(
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
$safePath,
$copiati === false ? '0' : (string) $copiati,
$size
));
}
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
// lettura arriva in fondo e come warning: ricalcolarlo sul file
// scritto costa una rilettura e non dipende da come libzip
// segnala l'errore.
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
}
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
$estrattiPiantine++;
} else {
$extracted++;
}
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
}
} catch (\Throwable $e) {
$zip->close();
@@ -425,63 +530,180 @@ class BackupArchive
$zip->close();
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
return [
'xml' => $xml,
'piantine' => $piantine,
'formato' => $formatVersion,
'staging' => $staging,
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
'files' => $extracted,
'file_piantine' => $estrattiPiantine,
];
}
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
private function prefissoDi(string $entryName, array $prefissi): ?string
{
foreach (array_keys($prefissi) as $prefisso) {
if (str_starts_with($entryName, $prefisso)) {
return $prefisso;
}
}
return null;
}
/**
* Sostituisce il contenuto del disco `public` con quello estratto.
* Mette i file estratti al posto di quelli in uso, tenendo da parte i
* vecchi finche' il ripristino non e' confermato.
*
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
* righe del database, che il ripristino ha appena riscritto per intero.
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
*
* @param callable|null $onProgress fn(int $fatti, int $totale): void
* Prima si svuotava il disco e poi si ricopiava dallo staging file per
* file: un errore a meta' (disco pieno, permessi) lasciava il database
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
* di spazio, e ognuno annotato in un registro che annullaScambio()
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
* e si solleva: il chiamante annulla la transazione e l'installazione
* resta com'era.
*
* Si spostano le voci e non la cartella radice: a quella del disco
* `public` punta il symlink public/storage.
*
* @param array $estratto Il risultato di extract().
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
* Da passare a confermaScambio() a database confermato, o ad
* annullaScambio() se qualcosa fallisce dopo.
*/
public function restoreFiles(string $staging, ?callable $onProgress = null): int
public function scambiaFile(array $estratto): array
{
$disk = Storage::disk('public');
// Fuori da backup-restore/ di proposito: quella cartella la ripulisce
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
// stesso fallisse, resterebbero gli unici file buoni.
$scambio = [
'file' => $estratto['files'] + $estratto['file_piantine'],
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
'spostamenti' => [],
];
$this->svuotaDiscoPubblico();
try {
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
$this->preparaCartella($radice);
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
if (! is_dir($staging)) {
return 0;
foreach ($this->vociDi($radice) as $voce) {
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
}
// Una sottocartella assente nello staging vuol dire "nessun
// file": la radice resta vuota, come nell'installazione
// salvata.
$nuovi = $estratto['staging'] . '/' . $nome;
foreach ($this->vociDi($nuovi) as $voce) {
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
}
}
} catch (\Throwable $e) {
$this->annullaScambio($scambio, $e);
throw $e;
}
$restored = 0;
$totale = iterator_count(new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
));
$onProgress && $onProgress(0, $totale);
return $scambio;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
);
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
public function confermaScambio(array $scambio): void
{
$this->deleteDirectory($scambio['accantonati']);
}
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
/**
* Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
* spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
* posto.
*
* Se anche questo fallisce non si cancella niente: i file precedenti
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
* si possono rimettere a mano invece di darli per persi.
*/
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
{
try {
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
if (! @rename($a, $da)) {
throw new RuntimeException('impossibile rimettere "' . $da . '"');
}
}
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
continue;
}
$disk->writeStream($relativePath, $handle);
if (is_resource($handle)) {
fclose($handle);
}
$restored++;
$onProgress && $onProgress($restored, $totale);
} catch (\Throwable $e) {
throw new RuntimeException(
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
. 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
. 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
0,
$causa ?? $e
);
}
return $restored;
$this->deleteDirectory($scambio['accantonati']);
}
/**
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
* => cartella vera.
*
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
* riga lo cita, e' la mappa della citta'.
*
* @return array<string, string>
*/
private function radiciDaSostituire(int $formato): array
{
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
if ($formato >= 2) {
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
}
return $radici;
}
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
private function vociDi(string $cartella): array
{
if (! is_dir($cartella)) {
return [];
}
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
}
private function preparaCartella(string $cartella): void
{
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
}
}
private function sposta(string $da, string $a, array &$scambio): void
{
if (! @rename($da, $a)) {
throw new RuntimeException(sprintf(
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
basename($da)
));
}
$scambio['spostamenti'][] = [$da, $a];
}
/**

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
/**
@@ -23,6 +24,18 @@ class BackupOperationState
public const TIPO_RESTORE = 'restore';
public const TIPO_RESET = 'reset';
/**
* Gli import non sono operazioni di backup, ma occupano lo stesso posto:
* l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file
* e righe dei territori. Uno dei due in parallelo a un ripristino (o a un
* backup che sta copiando quegli stessi file) produce uno stato che nessuno
* ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha
* TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo
* l'occupazione del posto.
*/
public const TIPO_IMPORT_PDF = 'import-pdf';
public const TIPO_IMPORT_XML = 'import-xml';
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
@@ -48,6 +61,16 @@ class BackupOperationState
private const CHIAVE_CORRENTE = 'backup-op:corrente';
private const TTL_CORRENTE = 3600;
/**
* Mutex attorno a "il posto e' libero? allora lo prendo".
*
* corrente() seguito da initialize() sono due passi: due richieste che
* arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono
* — la seconda sovrascrive il puntatore della prima, e i due lavori girano
* in parallelo proprio come il posto unico doveva impedire.
*/
private const CHIAVE_AVVIO = 'backup-op:avvio';
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{
$state = [
@@ -89,16 +112,78 @@ class BackupOperationState
$stato = $this->get($id);
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
// e' libero, e il puntatore va tolto di mezzo.
// e' libero. Il puntatore pero' qui non si tocca: corrente() la
// chiamano anche le pagine che si aggiornano, fuori dal mutex di
// occupa(), e cancellarlo poteva togliere quello che un'altra
// richiesta aveva appena scritto per un'operazione nuova. Lo
// sovrascrive initialize() al prossimo avvio.
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null;
}
return $id;
}
/**
* Prende il posto unico se e' libero, in un passo solo.
*
* Restituisce null se il posto e' stato preso (e l'operazione inizializzata
* come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa.
* Tutte le porte d'ingresso passano da qui proprio per il mutex: un
* controllo con corrente() e un initialize() separati lasciano aperta la
* finestra fra i due.
*
* $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a
* meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il
* posto resterebbe occupato per l'ora intera del TTL ordinario.
*/
public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string
{
try {
return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) {
$inCorso = $this->corrente();
if ($inCorso !== null) {
return $inCorso;
}
$this->initialize($id, $tipo, $fase);
if ($ttlPosto !== null) {
Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto);
}
return null;
});
} catch (LockTimeoutException) {
// Qualcun altro sta prendendo il posto proprio adesso: il mutex
// copre pochi millisecondi, quindi cinque secondi d'attesa vogliono
// dire che il posto non e' disponibile, non che va forzato.
return $this->corrente() ?? 'avvio-in-corso';
}
}
/**
* Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo
* blocca, perche' l'operazione in corso puo' essere stata avviata da un
* altro amministratore in un'altra zona della pagina.
*/
public function messaggioOccupato(string $idInCorso): string
{
$tipo = $this->get($idInCorso)['tipo'] ?? null;
$cosa = match ($tipo) {
self::TIPO_BACKUP => ' (un backup)',
self::TIPO_RESTORE => ' (un ripristino)',
self::TIPO_RESET => ' (un azzeramento)',
self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)',
self::TIPO_IMPORT_XML => ' (un import XML)',
default => '',
};
return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.';
}
public function get(string $id): ?array
{
return Cache::get($this->key($id));
@@ -213,8 +298,17 @@ class BackupOperationState
*/
private function liberaCorrente(string $id): void
{
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE);
// Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi
// un'altra richiesta poteva prendere il posto, e il forget le toglieva
// il puntatore. Se il mutex non arriva si lascia stare: il puntatore a
// un'operazione chiusa vale già come posto libero (vedi corrente()).
try {
Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id) {
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE);
}
});
} catch (LockTimeoutException) {
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
/**
* Il server di posta configurato dall'applicazione (Impostazioni → Posta).
*
* Se in Impostazioni c'è un server, vale quello; altrimenti resta quello del
* .env, come prima. La configurazione si applica quando Laravel crea il
* sistema di posta (AppServiceProvider) e prima di ogni job della coda,
* perché il worker vive a lungo e avrebbe tenuto quella del primo invio.
*/
class ConfigurazionePosta
{
public const CIFRATURA_AUTOMATICA = 'auto';
public const CIFRATURA_SSL = 'ssl';
public const CIFRATURA_NESSUNA = 'nessuna';
public const CIFRATURE = [
self::CIFRATURA_AUTOMATICA => 'Automatica (STARTTLS se il server la offre, di solito porta 587)',
self::CIFRATURA_SSL => 'SSL/TLS dall\'inizio (di solito porta 465)',
self::CIFRATURA_NESSUNA => 'Nessuna (solo per server interni)',
];
/** C'è un server di posta configurato dall'applicazione. */
public function configurataNellApp(): bool
{
return trim((string) Setting::instance()->mail_host) !== '';
}
/**
* Le email partono davvero: server dall'app, oppure un mailer del .env che
* non sia "log" o "array" (che le scrivono nel log o le buttano).
*/
public function funzionante(): bool
{
if ($this->configurataNellApp()) {
return true;
}
$mailer = (string) config('mail.default');
return ! in_array($mailer, ['log', 'array'], true)
&& ($mailer !== 'smtp' || trim((string) config('mail.mailers.smtp.host')) !== '');
}
/** Porta la configurazione dell'app nel sistema di posta di Laravel. */
public function applica(?MailManager $manager = null): void
{
try {
$impostazioni = Setting::instance();
} catch (\Throwable) {
// Database non ancora pronto (prima installazione, migrazioni in
// corso): resta il .env.
return;
}
if (trim((string) $impostazioni->mail_host) === '') {
return;
}
try {
$password = $impostazioni->mail_password;
} catch (DecryptException) {
// Cifrata con un'altra APP_KEY: meglio tentare senza password (e
// ricevere dal server un errore che dice "autenticazione") che
// far cadere ogni pagina che tocca la posta.
$password = null;
}
$cifratura = $impostazioni->mail_cifratura ?: self::CIFRATURA_AUTOMATICA;
config([
'mail.default' => 'smtp',
'mail.mailers.smtp' => [
'transport' => 'smtp',
'scheme' => $cifratura === self::CIFRATURA_SSL ? 'smtps' : 'smtp',
'host' => $impostazioni->mail_host,
'port' => $impostazioni->mail_port ?: ($cifratura === self::CIFRATURA_SSL ? 465 : 587),
'username' => $impostazioni->mail_username ?: null,
'password' => $password ?: null,
'timeout' => 20,
'local_domain' => config('mail.mailers.smtp.local_domain'),
// Senza, Symfony prova STARTTLS da solo appena il server lo offre.
'auto_tls' => $cifratura !== self::CIFRATURA_NESSUNA,
],
'mail.from.address' => $impostazioni->mail_from_address ?: config('mail.from.address'),
'mail.from.name' => $impostazioni->mail_from_name ?: ($impostazioni->congregazione_nome ?: config('mail.from.name')),
]);
// Un mailer già creato tiene la configurazione con cui è nato.
($manager ?? app('mail.manager'))->purge('smtp');
}
/**
* Invia l'email di prova. Lancia RuntimeException con un messaggio
* leggibile se il server rifiuta o non risponde.
*/
public function inviaProva(string $destinatario): void
{
$this->applica();
try {
Mail::raw(
"Questa è un'email di prova di TerManager2.\n\nSe la stai leggendo, il server di posta è configurato correttamente: "
. 'gli utenti riceveranno le email per confermare il loro indirizzo.',
fn ($messaggio) => $messaggio->to($destinatario)->subject('TerManager2 — email di prova'),
);
} catch (\Throwable $e) {
throw new RuntimeException('Il server di posta ha rifiutato l\'invio: ' . $e->getMessage(), 0, $e);
}
}
}

View File

@@ -0,0 +1,347 @@
<?php
namespace App\Services;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use InvalidArgumentException;
/**
* Le regole dei gruppi di servizio, in un posto solo: le usano le pagine
* dell'amministratore (Gruppi di servizio) e quella del sorvegliante (Il mio
* gruppo). Ogni metodo lancia InvalidArgumentException con un messaggio da
* mostrare così com'è quando una regola non è rispettata.
*/
class GruppiServizio
{
/** Il dominio delle email fittizie dei sorveglianti (iniziale+cognome@…). */
public const DOMINIO_EMAIL = 'termanager.it';
/**
* Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
* suo nome (emailFittizia), ruolo "sorvegliante", password temporanea da
* cambiare al primo accesso, nessuna conferma dell'email. Restituisce il
* gruppo e la password temporanea, da mostrare una volta sola
* all'amministratore insieme all'email.
*
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string}
*/
public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
{
$nome = trim($nome);
if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
}
if (GruppoServizio::where('nome', $nome)->exists()) {
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
}
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
}
if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
}
if ($sorvegliante->utente()->exists()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
}
$password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = User::create([
'name' => $sorvegliante->nome_completo,
'email' => self::emailFittizia($sorvegliante),
'password' => $password,
'password_change_required' => true,
'password_temporanea' => true,
'proclamatore_id' => $sorvegliante->id,
]);
$utente->assignRole('sorvegliante');
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
->log('gruppo_creato');
return $gruppo;
});
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
* dell'app (Francesco Fontana → ffontana@termanager.it, Francesco
* D'Angelo → fdangelo@…). Se è già presa, un numero in coda
* (ffontana2@…). Non riceve posta: il sorvegliante non conferma l'email.
*/
public static function emailFittizia(Proclamatore $proclamatore): string
{
$pulisci = fn (string $testo) => preg_replace('/[^a-z0-9]/', '', Str::lower(Str::ascii($testo)));
$base = Str::limit(mb_substr($pulisci((string) $proclamatore->nome), 0, 1) . $pulisci((string) $proclamatore->cognome), 60, '');
if ($base === '') {
$base = "sorvegliante{$proclamatore->id}";
}
$email = "{$base}@" . self::DOMINIO_EMAIL;
for ($n = 2; User::where('email', $email)->exists(); $n++) {
$email = "{$base}{$n}@" . self::DOMINIO_EMAIL;
}
return $email;
}
/** @param int[] $ids */
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
{
$this->attivo($gruppo);
foreach (Proclamatore::whereIn('id', $ids)->get() as $p) {
if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo.");
}
if (! $p->attivo) {
throw new InvalidArgumentException("{$p->nome_completo} non è attivo.");
}
$p->update(['gruppo_servizio_id' => $gruppo->id]);
}
}
public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void
{
$this->attivo($gruppo);
if ($membro->id === $gruppo->sorvegliante_id) {
throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.');
}
if ($membro->gruppo_servizio_id === $gruppo->id) {
$membro->update(['gruppo_servizio_id' => null]);
}
}
/**
* Mette nel pacchetto del gruppo dei territori in reparto (attivi, non
* assegnati, non già in un altro pacchetto). Restituisce quanti.
*
* @param int[] $ids
*/
public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int
{
$this->attivo($gruppo);
$territori = Territorio::whereIn('id', $ids)->get();
foreach ($territori as $t) {
if (! $t->attivo) {
throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo.");
}
if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo.");
}
if ($t->assegnazioneCorrente) {
throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare.");
}
}
Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territori' => $territori->pluck('numero')->all()])
->log('pacchetto_aggiunti');
return $territori->count();
}
/**
* Rimanda in reparto un territorio del pacchetto non ancora dato a
* nessuno (lo fa l'amministratore o il sorvegliante).
*/
public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void
{
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
}
if ($territorio->assegnazioneCorrente) {
throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro.");
}
$territorio->update(['gruppo_servizio_id' => null]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territorio' => $territorio->numero])
->log('pacchetto_rimandato_in_reparto');
}
/**
* Il sorvegliante dà un territorio del pacchetto a un membro (o a sé):
* 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, bool $cartaceo = false): Assegnazione
{
$this->attivo($gruppo);
// Si decide sui dati di adesso, non su quelli con cui è partita la
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
// o membri.
$territorio->refresh();
$membro->refresh();
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
}
if ($membro->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
}
if (! $membro->attivo) {
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
}
if ($data->isFuture()) {
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
}
$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.";
}
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
if ($conflitto) {
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
}
return Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $membro->id,
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
'assigned_at' => $data,
'created_by' => auth()->id(),
'gruppo_servizio_id' => $gruppo->id,
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
'gruppo_nome' => $gruppo->nome,
'link_da_inviare' => (bool) $territorio->pdf_path,
'cartaceo' => $cartaceo,
]);
});
if (is_string($esito)) {
throw new InvalidArgumentException($esito);
}
activity()->causedBy(auth()->user())->performedOn($esito)
->withProperties([
'territorio' => $territorio->numero,
'proclamatore_id' => $membro->id,
'gruppo_servizio_id' => $gruppo->id,
])
->log('assigned');
return $esito;
}
/**
* Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
* sorvegliante non è più attivo) e lo elimina: non resta nello storico.
* I territori del pacchetto non ancora dati tornano in reparto; quelli
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
* L'utenza del sorvegliante si elimina: torna un proclamatore come gli
* altri. Le assegnazioni che aveva fatto o fatto rientrare passano a chi
* scioglie il gruppo; nel registro le sue voci restano, a nome
* "Sorvegliante del gruppo …", senza dati personali.
*/
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{
if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
return;
}
DB::transaction(function () use ($gruppo, $motivo) {
$liberati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
->pluck('numero');
Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_eliminato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto');
if ($utente) {
$utente->eliminaConservandoStorico($this->subentrante($utente), "Sorvegliante del gruppo {$gruppo->nome}", null);
}
$gruppo->delete();
});
}
/**
* Chi prende le assegnazioni fatte dal sorvegliante quando la sua utenza
* si elimina: chi sta sciogliendo il gruppo, altrimenti il primo
* amministratore.
*/
private function subentrante(User $sorvegliante): User
{
$chi = auth()->user();
if ($chi instanceof User && $chi->id !== $sorvegliante->id) {
return $chi;
}
return User::role('amministratore')->orderBy('id')->firstOrFail();
}
private function attivo(GruppoServizio $gruppo): void
{
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
}
}
}

View File

@@ -36,15 +36,24 @@ class PiantinaPdfService
) {
}
/** Il PDF completo, come stringa binaria. */
public function genera(Territorio $territorio, TerritorioMappa $mappa): string
/**
* Il PDF completo, come stringa binaria.
*
* $immagine è il percorso assoluto di un'immagine diversa da quella
* salvata nella piantina: l'anteprima della pubblicazione compone il PDF
* con l'immagine appena generata, senza averla ancora resa ufficiale.
*/
public function genera(Territorio $territorio, TerritorioMappa $mappa, ?string $immagine = null): string
{
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
if ($immagine === null) {
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
}
$immagine = Storage::disk('public')->path($mappa->image_path);
}
$immagine = Storage::disk('public')->path($mappa->image_path);
$dimensioni = getimagesize($immagine);
$dimensioni = @getimagesize($immagine);
if ($dimensioni === false) {
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
}
@@ -92,7 +101,7 @@ class PiantinaPdfService
($h - $imgH) / 2,
$imgW,
$imgH,
$this->tipoImmagine($mappa->image_path)
$this->tipoImmagine($immagine)
);
return $pdf->Output('S');
@@ -179,13 +188,26 @@ class PiantinaPdfService
*/
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
{
$binario = $this->genera($territorio, $mappa);
$this->sostituisciConPdf($territorio, $this->genera($territorio, $mappa), $origine);
}
/**
* Rende ufficiale un PDF già composto, archiviando il precedente.
*
* Prima si scrive il nuovo file, poi si archivia il vecchio: nell'ordine
* inverso un errore di scrittura (disco pieno) lasciava pdf_path puntato
* a un file già spostato nello storico, e i link delle assegnazioni in 404.
*/
public function sostituisciConPdf(Territorio $territorio, string $binario, string $origine = 'piantine'): void
{
$disk = Storage::disk('public');
$this->archiviaCorrente($territorio, $origine);
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
$disk->put($nuovoPath, $binario);
if (! $disk->put($nuovoPath, $binario)) {
throw new RuntimeException('Non è stato possibile scrivere il nuovo PDF sul disco: il PDF in vigore non è stato toccato.');
}
$this->archiviaCorrente($territorio, $origine);
$territorio->update([
'pdf_path' => $nuovoPath,
@@ -235,8 +257,11 @@ class PiantinaPdfService
/**
* Sposta il PDF attuale del territorio (se esiste) nello storico.
*
* Pubblica perché la usa anche l'import dei PDF da cartella: chi
* sostituisce un PDF lo archivia sempre allo stesso modo.
*/
private function archiviaCorrente(Territorio $territorio, string $origine): void
public function archiviaCorrente(Territorio $territorio, string $origine): void
{
$disk = Storage::disk('public');

View File

@@ -3,6 +3,7 @@
namespace App\Services\Piantine;
use App\Models\TerritorioMappa;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
@@ -14,6 +15,11 @@ use RuntimeException;
*/
class PiantinaRenderer
{
/**
* Rendering e archiviazione come immagine della piantina.
*
* @return string il percorso sul disco pubblico
*/
public function render(TerritorioMappa $mappa): string
{
$geometry = $mappa->geometria();
@@ -22,19 +28,38 @@ class PiantinaRenderer
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
}
[$immagine, $estensione] = $this->immagine($geometry);
return $this->archivia($mappa, $immagine, $estensione);
}
/**
* Solo il rendering, senza toccare niente: serve all'anteprima della
* pubblicazione, che non deve cambiare la piantina finché non si conferma.
*
* @return array{0: string, 1: string} contenuto e estensione ('jpg'|'png')
*/
public function immagine(array $geometry): array
{
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
$response = Http::timeout((int) config('piantine.renderer_timeout'))
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
'geometry' => $geometry,
'marginRatio' => (float) config('piantine.margin_ratio'),
'maxSide' => (int) config('piantine.max_side'),
'aspect' => (float) config('piantine.aspetto'),
'strokeWidth' => (float) config('piantine.tratto'),
'strokeOpacity' => (float) config('piantine.tratto_opacita'),
'format' => $formato,
'quality' => (int) config('piantine.qualita'),
]);
try {
$response = Http::timeout((int) config('piantine.renderer_timeout'))
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
'geometry' => $geometry,
'marginRatio' => (float) config('piantine.margin_ratio'),
'maxSide' => (int) config('piantine.max_side'),
'aspect' => (float) config('piantine.aspetto'),
'strokeWidth' => (float) config('piantine.tratto'),
'strokeOpacity' => (float) config('piantine.tratto_opacita'),
'format' => $formato,
'quality' => (int) config('piantine.qualita'),
]);
} catch (ConnectionException $e) {
// Container fermo, nome non risolto o tempo scaduto: il messaggio
// di cURL in inglese non dice niente a chi sta pubblicando.
throw new RuntimeException('Il servizio che disegna le mappe non risponde (container map-renderer). Riprova fra qualche minuto; se continua, va controllato il server.', 0, $e);
}
if (! $response->successful()) {
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
@@ -47,15 +72,28 @@ class PiantinaRenderer
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
}
// Il vecchio file puo' avere un'estensione diversa da quella nuova (si
// e' passati da PNG a JPEG): senza questa rimozione resterebbe sul
// volume per sempre, senza che niente lo referenzi piu'.
if ($mappa->image_path && $mappa->image_path !== $this->percorso($mappa, $formato)) {
Storage::disk('public')->delete($mappa->image_path);
return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
}
/**
* Salva l'immagine come quella della piantina, rimuovendo la precedente.
*
* Il nome cambia a ogni rendering: nginx e Cloudflare tengono in cache i
* file di /storage per giorni, e con un nome fisso il link mostrava
* l'immagine vecchia anche dopo averla rigenerata.
*/
public function archivia(TerritorioMappa $mappa, string $immagine, string $estensione): string
{
$disco = Storage::disk('public');
$path = 'territori-mappe/piantina-' . $mappa->id . '-' . now()->format('YmdHis') . '.' . $estensione;
if (! $disco->put($path, $immagine)) {
throw new RuntimeException('Non è stato possibile salvare l\'immagine della piantina sul disco.');
}
$path = $this->percorso($mappa, $formato);
Storage::disk('public')->put($path, $immagine);
if ($mappa->image_path && $mappa->image_path !== $path) {
$disco->delete($mappa->image_path);
}
$mappa->update([
'image_path' => $path,
@@ -65,11 +103,6 @@ class PiantinaRenderer
return $path;
}
private function percorso(TerritorioMappa $mappa, string $formato): string
{
return 'territori-mappe/piantina-' . $mappa->id . '.' . ($formato === 'png' ? 'png' : 'jpg');
}
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
private function immagineValida(string $dati, string $formato): bool
{

View File

@@ -12,10 +12,10 @@ use RuntimeException;
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
* restano al loro posto.
*
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
* KML originale — che quindi conviene conservare fuori di qui.
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
* contiene, e ripristinandolo l'area in uso resta com'e'.
*/
class PiantineArea
{

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
/**
* Le tabelle della sotto-app delle piantine dentro il backup completo:
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
* (`territorio_pdf_versioni`).
*
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
*
* Il collegamento al territorio si salva come *numero*, non solo come id:
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
* unico.
*/
class PiantineBackup
{
public const TABELLA_MAPPE = 'territorio_mappe';
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
/**
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
* del territorio collegato.
*
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
* compresi, da reinserire identica — non i valori passati per i cast.
*
* @return array<string, list<array<string, mixed>>>
*/
public function esporta(): array
{
return [
self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE),
self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI),
];
}
/**
* Controlla la struttura di piantine.json prima che il ripristino tocchi
* qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le
* piantine non le conteneva.
*/
public function valida(?array $dati): void
{
if ($dati === null) {
return;
}
foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) {
if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) {
throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".');
}
foreach ($dati[$tabella] as $posizione => $riga) {
if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) {
throw new RuntimeException(sprintf(
'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.',
$posizione + 1,
$tabella
));
}
}
}
}
/**
* Sostituisce il contenuto delle due tabelle con quello del backup.
*
* Va chiamato dentro la transazione del ripristino e *dopo* l'import XML:
* servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e
* non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe'
* chiuderebbe a meta' la transazione che deve poter annullare tutto.
*
* Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto.
* Tenere le righe dell'installazione corrente vorrebbe dire piantine che
* citano immagini del disco appena sostituito — file che non ci sono piu'
* — accanto ai dati di un'altra epoca: proprio lo stato misto che il
* ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la
* cascata sulla cancellazione dei territori.
*
* @return array{mappe: int, versioni: int, issues: list<array{entity: string, legacy_id: string, reason: string}>}
*/
public function ripristina(?array $dati): array
{
DB::table(self::TABELLA_VERSIONI)->delete();
DB::table(self::TABELLA_MAPPE)->delete();
$esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []];
if ($dati === null) {
return $esito;
}
// withTrashed implicito: il query builder non conosce i soft delete, e
// una piantina collegata a un territorio nel cestino deve restarlo.
$territori = DB::table('territori')->pluck('id', 'numero')
->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id])
->all();
$colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE));
$collegati = [];
foreach ($dati[self::TABELLA_MAPPE] as $riga) {
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
$territorioId = null;
if (($riga['territorio_id'] ?? null) !== null) {
$territorioId = $territori[$numero] ?? null;
// territorio_id e' unico: due piantine sullo stesso numero
// (possibile solo se il backup aveva territori con numero
// ripetuto, che l'import scarta) non possono restare collegate
// entrambe.
if ($territorioId !== null && isset($collegati[$territorioId])) {
$territorioId = null;
}
if ($territorioId === null) {
// Il disegno non si butta: resta scollegato, con il numero
// come etichetta per riconoscerlo.
if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') {
$riga['nome'] = 'Terr. ' . $numero;
}
$esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata');
} else {
$collegati[$territorioId] = true;
}
}
$riga['territorio_id'] = $territorioId;
DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe));
$esito['mappe']++;
}
$colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI));
foreach ($dati[self::TABELLA_VERSIONI] as $riga) {
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
$territorioId = $territori[$numero] ?? null;
// Una versione senza territorio non e' ripristinabile da nessuna
// schermata (e la colonna non ammette NULL): si scarta, e lo si dice.
if ($territorioId === null) {
$esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata');
continue;
}
$riga['territorio_id'] = $territorioId;
DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni));
$esito['versioni']++;
}
return $esito;
}
private function righe(string $tabella): array
{
return DB::table($tabella)
->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id')
->orderBy($tabella . '.id')
->get([$tabella . '.*', 'territori.numero as territorio_numero'])
->map(fn (object $riga) => (array) $riga)
->all();
}
/** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */
private function problema(string $entita, array $riga, string $motivo): array
{
return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo];
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Services\Piantine;
use App\Jobs\PreparaAnteprimaPiantina;
use App\Models\TerritorioMappa;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Pubblicazione di una piantina: dalla bozza al PDF ufficiale del territorio.
*
* Tre passi, perché il PDF pubblicato viene stampato e consegnato e va visto
* prima di sostituire quello in vigore:
*
* 1. avvia() — mette in coda la preparazione dell'anteprima. In coda e
* non nella richiesta: il primo rendering di un territorio
* scarica centinaia di tessere e può superare i 100 secondi
* dopo i quali Cloudflare chiude la connessione.
* 2. prepara() — (dal job) genera immagine e PDF in una cartella privata,
* senza toccare né la piantina né il territorio.
* 3. conferma() — rende ufficiali i file dell'anteprima. Se nel frattempo la
* piantina o i dati stampati sono cambiati, rifiuta: si
* pubblicherebbe qualcosa di diverso da quello che si è visto.
*
* Lo stato di ogni anteprima vive in cache sotto un token casuale; i file
* stanno sul disco locale, mai su quello pubblico servito da nginx.
*/
class PubblicazionePiantina
{
public const IN_CORSO = 'in_corso';
public const PRONTA = 'pronta';
public const ERRORE = 'errore';
/** Quanto resta valida un'anteprima non confermata. */
private const DURATA_SECONDI = 7200;
private const CARTELLA = 'piantine-anteprime';
public function __construct(
private PiantinaRenderer $renderer,
private PiantinaPdfService $pdf,
) {
}
/** Mette in coda l'anteprima e ne restituisce il token. */
public function avvia(TerritorioMappa $mappa): string
{
$mappa->loadMissing('territorio.zona');
if ($motivo = $mappa->motivoNonPubblicabile()) {
throw new RuntimeException($motivo);
}
$this->pulisciVecchie();
$token = Str::random(40);
$this->scrivi($token, ['stato' => self::IN_CORSO, 'mappa_id' => $mappa->id]);
PreparaAnteprimaPiantina::dispatch($token);
return $token;
}
/** Lo stato dell'anteprima, o null se il token non esiste o è scaduto. */
public function stato(string $token): ?array
{
return Cache::get($this->chiave($token));
}
/** Il lavoro vero, eseguito dal job. */
public function prepara(string $token): void
{
$stato = $this->stato($token);
if (! $stato || $stato['stato'] !== self::IN_CORSO) {
return;
}
try {
$mappa = TerritorioMappa::with('territorio.zona')->find($stato['mappa_id']);
if (! $mappa) {
throw new RuntimeException('La piantina non esiste più.');
}
if ($motivo = $mappa->motivoNonPubblicabile()) {
throw new RuntimeException($motivo);
}
// L'impronta si prende adesso, sullo stesso stato da cui escono
// immagine e PDF: è quello che la conferma dovrà ritrovare.
$impronta = $mappa->impronta();
[$immagine, $estensione] = $this->renderer->immagine($mappa->geometria());
$disco = Storage::disk('local');
$percorsoImmagine = self::CARTELLA . "/{$token}.{$estensione}";
$percorsoPdf = self::CARTELLA . "/{$token}.pdf";
if (! $disco->put($percorsoImmagine, $immagine)) {
throw new RuntimeException('Non è stato possibile salvare l\'immagine dell\'anteprima.');
}
$binario = $this->pdf->genera($mappa->territorio, $mappa, $disco->path($percorsoImmagine));
if (! $disco->put($percorsoPdf, $binario)) {
throw new RuntimeException('Non è stato possibile salvare il PDF dell\'anteprima.');
}
$this->scrivi($token, $stato + [
'impronta' => $impronta,
'immagine' => $percorsoImmagine,
'estensione' => $estensione,
'pdf' => $percorsoPdf,
], self::PRONTA);
} catch (\Throwable $e) {
report($e);
$this->segnaErrore($token, $e->getMessage());
}
}
public function segnaErrore(string $token, string $messaggio): void
{
$stato = $this->stato($token);
if (! $stato) {
return;
}
$this->scrivi($token, $stato + ['messaggio' => $messaggio], self::ERRORE);
}
/** Percorso assoluto del PDF di un'anteprima pronta, o null. */
public function pdfAnteprima(string $token): ?string
{
$stato = $this->stato($token);
if (($stato['stato'] ?? null) !== self::PRONTA || ! Storage::disk('local')->exists($stato['pdf'])) {
return null;
}
return Storage::disk('local')->path($stato['pdf']);
}
/**
* Rende ufficiale l'anteprima: PDF del territorio sostituito (il
* precedente va nello storico), immagine della piantina aggiornata, bozza
* segnata come pubblicata.
*/
public function conferma(string $token): TerritorioMappa
{
$stato = $this->stato($token);
if (($stato['stato'] ?? null) !== self::PRONTA) {
throw new RuntimeException('L\'anteprima non è più disponibile: generala di nuovo.');
}
$mappa = TerritorioMappa::with('territorio.zona')->find($stato['mappa_id']);
if (! $mappa || ! $mappa->collegata()) {
throw new RuntimeException('La piantina non è più collegata a un territorio: non si può pubblicare.');
}
if ($mappa->impronta() !== $stato['impronta']) {
throw new RuntimeException('La piantina o i dati del territorio sono cambiati dopo l\'anteprima: rigenerala per pubblicare la versione attuale.');
}
$disco = Storage::disk('local');
if (! $disco->exists($stato['pdf']) || ! $disco->exists($stato['immagine'])) {
throw new RuntimeException('I file dell\'anteprima non ci sono più: generala di nuovo.');
}
$this->pdf->sostituisciConPdf($mappa->territorio, $disco->get($stato['pdf']), 'piantine');
$this->renderer->archivia($mappa, $disco->get($stato['immagine']), $stato['estensione']);
$mappa->update([
'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $stato['impronta'],
// Il PDF appena messo in vigore: finché il territorio usa questo
// file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()).
'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path,
'pubblicata_at' => now(),
]);
activity()
->causedBy(auth()->user())
->performedOn($mappa->territorio)
->log('piantina_pubblicata');
$this->annulla($token);
return $mappa->fresh('territorio');
}
/** Butta l'anteprima: file e stato. */
public function annulla(string $token): void
{
$stato = $this->stato($token);
if ($stato) {
Storage::disk('local')->delete(array_filter([$stato['immagine'] ?? null, $stato['pdf'] ?? null]));
}
Cache::forget($this->chiave($token));
}
private function scrivi(string $token, array $dati, ?string $nuovoStato = null): void
{
if ($nuovoStato !== null) {
$dati['stato'] = $nuovoStato;
}
Cache::put($this->chiave($token), $dati, self::DURATA_SECONDI);
}
private function chiave(string $token): string
{
return 'piantine:anteprima:' . $token;
}
/**
* Le anteprime aperte e mai confermate né annullate (finestra chiusa)
* lascerebbero file per sempre: chi ne avvia una nuova pulisce quelle
* scadute.
*/
private function pulisciVecchie(): void
{
$disco = Storage::disk('local');
$limite = now()->subSeconds(self::DURATA_SECONDI)->getTimestamp();
foreach ($disco->files(self::CARTELLA) as $file) {
if ($disco->lastModified($file) < $limite) {
$disco->delete($file);
}
}
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Services\Registro;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Territorio;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use setasign\Fpdi\Fpdi;
/**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del territorio")
* compilato per un anno di servizio.
*
* Il PDF è il modulo originale (resources/pdf/S-13_I.pdf) importato com'è, con
* i dati scritti sopra alle coordinate della sua griglia: il foglio stampato è
* quello ufficiale, non una sua ricostruzione. La matrice è salvata in PDF 1.4
* senza object stream, l'unico formato che il parser gratuito di FPDI legge;
* se il modulo viene aggiornato va riconvertito allo stesso modo (qpdf
* --object-streams=disable --force-version=1.4, o pikepdf).
*
* Le regole di compilazione, come sul cartaceo:
*
* - Una riga per ogni territorio attivo, anche se nell'anno non è mai uscito:
* il registro serve proprio a vedere quali territori restano fermi.
*
* - Quattro gruppi "Assegnato a" per riga. Alla quinta assegnazione la riga
* si cancella: la data di rientro della quarta passa nella colonna
* "Restituito l'ultima volta il" e la quinta va nel primo gruppo. Sul foglio
* c'è quindi solo il ciclo in corso; lo storico completo resta nel database.
*
* - Nel primo ciclo dell'anno il riporto è l'ultima restituzione avvenuta
* prima dell'inizio dell'anno, come chiede la nota a piè del modulo.
*
* - Un'assegnazione a cavallo d'anno (uscita prima dell'inizio, rientrata
* dopo o ancora fuori) compare su entrambi i fogli: su quello vecchio senza
* data di rientro, perché a fine anno il territorio era ancora fuori; su
* quello nuovo nel primo gruppo, con la data di assegnazione originale e
* quella di rientro.
*/
class ModuloS13
{
/** Blocchi-territorio per foglio, come sul modulo. */
public const RIGHE_PER_PAGINA = 20;
/** Gruppi "Assegnato a" per riga. */
public const ASSEGNAZIONI_PER_RIGA = 4;
// Geometria della griglia in mm, misurata sulla matrice. Le y sono il
// bordo superiore delle linee: quelle spesse separano i territori, quella
// sottile divide la riga dei nomi da quella delle date.
/** Linea spessa sotto l'intestazione, cioè sopra il primo territorio. */
private const Y_PRIMA_RIGA = 49.28;
/** Distanza fra due territori. */
private const PASSO_RIGA = 11.042;
/** Dalla linea spessa alla sottile che separa nomi e date. */
private const Y_META_RIGA = 5.68;
/** Spessore delle linee spesse (1,56pt) e di quelle sottili (0,48pt). */
private const LINEA_SPESSA = 0.55;
private const LINEA_SOTTILE = 0.17;
/** Colonne fisse: [x iniziale, x finale] al netto dei bordi. */
private const COL_NUMERO = [12.14, 24.06];
private const COL_RIPORTO = [24.23, 46.11];
/** Inizio di ciascun gruppo "Assegnato a", divisione interna e fine. */
private const GRUPPI = [
[46.62, 65.41, 84.03],
[84.54, 103.05, 121.74],
[122.25, 140.69, 159.38],
[159.89, 178.40, 196.74],
];
/** Riga "Anno di servizio: ____": estremi della linea e sua quota. */
private const ANNO = [49.96, 72.74, 34.38];
/** Corpo massimo e minimo dei nomi: sotto il minimo si tronca. */
private const CORPO_NOME_MAX = 8.0;
private const CORPO_NOME_MIN = 5.0;
/** Il PDF compilato, come stringa binaria. */
public function genera(AnnoTeocratico $anno): string
{
$pdf = new Fpdi();
$pdf->SetAutoPageBreak(false);
$pdf->SetMargins(0, 0, 0);
$pdf->setSourceFile(resource_path('pdf/S-13_I.pdf'));
$modulo = $pdf->importPage(1);
$pdf->SetTextColor(0, 0, 0);
foreach ($this->pagine($this->righe($anno)) as $righe) {
$pdf->AddPage('P', 'A4');
$pdf->useTemplate($modulo);
[$x0, $x1, $y] = self::ANNO;
$pdf->SetFont('Helvetica', '', 10);
$pdf->SetXY($x0, $y - 4.6);
$pdf->Cell($x1 - $x0, 4.4, $this->latin($anno->label), 0, 0, 'C');
foreach ($righe as $indice => $riga) {
$this->scriviRiga($pdf, $indice, $riga);
}
}
return $pdf->Output('S');
}
/**
* Righe del modulo: una per territorio attivo, in ordine naturale di
* numero ("1", "1 A", "2", "10").
*
* Ogni riga: ['numero' => string, 'riporto' => string, 'assegnazioni' =>
* quattro celle ['nome', 'assegnato_il', 'restituito_il'], le libere vuote].
*
* @return Collection<int, array>
*/
public function righe(AnnoTeocratico $anno): Collection
{
$inizio = $anno->start_date->toDateString();
$fine = $anno->end_date->copy()->endOfDay();
$territori = Territorio::where('attivo', true)->get(['id', 'numero']);
// Le assegnazioni del foglio, scelte per data e non per
// anno_teocratico_id (che una correzione a mano o un import possono
// lasciare incoerente): quelle uscite nell'anno, più quelle uscite
// prima e ancora fuori al suo inizio. Confronti con stringhe Y-m-d e
// solo con < e >=: valgono sia sulle colonne DATE di MariaDB sia sulle
// "Y-m-d H:i:s" di SQLite, e usano l'indice (whereDate non lo userebbe).
$assegnazioni = Assegnazione::with('proclamatore')
->whereIn('territorio_id', $territori->pluck('id'))
->where('assigned_at', '<', $anno->end_date->copy()->addDay()->toDateString())
->where(fn ($q) => $q
->where('assigned_at', '>=', $inizio)
->orWhereNull('returned_at')
->orWhere('returned_at', '>=', $inizio))
->orderBy('assigned_at')
->orderBy('id')
->get()
->groupBy('territorio_id');
// Riporto del primo ciclo: l'ultima restituzione prima dell'anno.
$rientriPrecedenti = Assegnazione::query()
->whereIn('territorio_id', $territori->pluck('id'))
->where('returned_at', '<', $inizio)
->groupBy('territorio_id')
->selectRaw('territorio_id, MAX(returned_at) as ultimo')
->pluck('ultimo', 'territorio_id');
return $territori
->map(function (Territorio $territorio) use ($assegnazioni, $rientriPrecedenti, $fine) {
$gruppo = ($assegnazioni->get($territorio->id) ?? collect())->values();
$inizioCiclo = $gruppo->isEmpty()
? 0
: intdiv($gruppo->count() - 1, self::ASSEGNAZIONI_PER_RIGA) * self::ASSEGNAZIONI_PER_RIGA;
$riporto = $inizioCiclo === 0
? $this->data($rientriPrecedenti->get($territorio->id))
: $this->rientroEntro($gruppo->get($inizioCiclo - 1), $fine);
$celle = $gruppo
->slice($inizioCiclo, self::ASSEGNAZIONI_PER_RIGA)
->map(fn (Assegnazione $a) => [
'nome' => $this->nomeBreve($a->proclamatore),
'assegnato_il' => $this->data($a->assigned_at),
'restituito_il' => $this->rientroEntro($a, $fine),
])
->values()
->all();
return [
'numero' => (string) $territorio->numero,
'riporto' => $riporto,
'assegnazioni' => $this->completaCelle($celle),
];
})
->sort(fn (array $a, array $b) => strnatcasecmp($a['numero'], $b['numero']))
->values();
}
/**
* Fogli da venti righe, l'ultimo completato con righe vuote: sul modulo le
* righe libere restano disponibili per la compilazione a mano. Senza
* territori esce comunque un foglio, in bianco.
*
* @return Collection<int, Collection<int, array>>
*/
public function pagine(Collection $righe): Collection
{
$vuota = ['numero' => '', 'riporto' => '', 'assegnazioni' => $this->completaCelle([])];
$pagine = $righe->isEmpty()
? collect([collect()])
: $righe->chunk(self::RIGHE_PER_PAGINA)->map->values()->values();
return $pagine->map(fn (Collection $pagina) => collect(
array_pad($pagina->all(), self::RIGHE_PER_PAGINA, $vuota)
));
}
private function scriviRiga(Fpdi $pdf, int $indice, array $riga): void
{
$alto = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::LINEA_SPESSA;
$meta = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::Y_META_RIGA;
$basso = self::Y_PRIMA_RIGA + ($indice + 1) * self::PASSO_RIGA;
// Numero e riporto occupano il blocco intero, nomi e date mezzo.
$pdf->SetFont('Helvetica', 'B', 10);
$this->cella($pdf, self::COL_NUMERO, $alto, $basso, $riga['numero']);
$pdf->SetFont('Helvetica', '', 8);
$this->cella($pdf, self::COL_RIPORTO, $alto, $basso, $riga['riporto']);
foreach ($riga['assegnazioni'] as $g => $a) {
[$inizio, $divisione, $fine] = self::GRUPPI[$g];
$this->scriviNome($pdf, [$inizio, $fine], $alto, $meta, $a['nome']);
$pdf->SetFont('Helvetica', '', 7.5);
$this->cella($pdf, [$inizio, $divisione], $meta + self::LINEA_SOTTILE, $basso, $a['assegnato_il']);
$this->cella($pdf, [$divisione, $fine], $meta + self::LINEA_SOTTILE, $basso, $a['restituito_il']);
}
}
/**
* I nomi stanno su una riga sola: il corpo scende finché il nome entra
* nella cella, e sotto il minimo leggibile si tronca con i puntini, che
* rendono visibile l'accorciamento invece di sbordare sulla griglia.
*/
private function scriviNome(Fpdi $pdf, array $colonna, float $alto, float $basso, string $nome): void
{
if ($nome === '') {
return;
}
$testo = $this->latin($nome);
$spazio = $colonna[1] - $colonna[0] - 1.2;
$pdf->SetFont('Helvetica', '', self::CORPO_NOME_MAX);
$larghezza = $pdf->GetStringWidth($testo);
if ($larghezza > $spazio) {
$corpo = max(self::CORPO_NOME_MIN, floor(self::CORPO_NOME_MAX * $spazio / $larghezza * 10) / 10);
$pdf->SetFont('Helvetica', '', $corpo);
while ($pdf->GetStringWidth($testo) > $spazio && strlen($testo) > 1) {
$testo = rtrim(substr(rtrim($testo, "\x85"), 0, -1)) . "\x85";
}
}
$this->cella($pdf, $colonna, $alto, $basso, $testo, giaConvertito: true);
}
/** Testo centrato in un rettangolo della griglia. */
private function cella(Fpdi $pdf, array $colonna, float $alto, float $basso, string $testo, bool $giaConvertito = false): void
{
if ($testo === '') {
return;
}
$pdf->SetXY($colonna[0], $alto);
$pdf->Cell(
$colonna[1] - $colonna[0],
$basso - $alto,
$giaConvertito ? $testo : $this->latin($testo),
0,
0,
'C'
);
}
/**
* Data di rientro così come la si leggeva alla fine dell'anno: se il
* territorio è rientrato dopo, sul foglio di quell'anno risulta ancora
* fuori.
*/
private function rientroEntro(?Assegnazione $assegnazione, CarbonInterface $fine): string
{
$rientro = $assegnazione?->returned_at;
return $rientro && $rientro->lte($fine) ? $this->data($rientro) : '';
}
/**
* Cognome e iniziale del nome ("Rossi M."): la cella è stretta, e col nome
* per intero i nomi lunghi scendevano a un corpo che su carta non si legge.
*/
private function nomeBreve(?Proclamatore $proclamatore): string
{
if (! $proclamatore) {
return '';
}
$nome = trim((string) $proclamatore->nome);
$iniziale = $nome !== '' ? mb_strtoupper(mb_substr($nome, 0, 1)) . '.' : '';
return trim(trim((string) $proclamatore->cognome) . ' ' . $iniziale);
}
private function data(mixed $valore): string
{
if (! $valore) {
return '';
}
return ($valore instanceof CarbonInterface ? $valore : \Illuminate\Support\Carbon::parse($valore))
->format('d/m/Y');
}
private function completaCelle(array $celle): array
{
return array_pad($celle, self::ASSEGNAZIONI_PER_RIGA, ['nome' => '', 'assegnato_il' => '', 'restituito_il' => '']);
}
/** FPDF ragiona in windows-1252: gli accenti UTF-8 vanno convertiti. */
private function latin(string $testo): string
{
$convertito = @iconv('UTF-8', 'windows-1252//TRANSLIT', $testo);
return $convertito !== false ? $convertito : $testo;
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Services;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Territorio;
use App\Models\User;
use App\Notifications\RichiestaRicevuta;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
/**
* Le richieste dal modulo pubblico: registrarle, avvisare chi le gestisce e
* aiutarlo a ricondurle a un proclamatore o a un'assegnazione. La conferma
* passa dai moduli già esistenti (Assegna, Rientra), che chiudono la
* richiesta da cui sono partiti.
*/
class RichiesteServizio
{
public function __construct(private ConfigurazionePosta $posta) {}
/** Registra la richiesta e avvisa per email amministratori e assistenti. */
public function registra(string $tipo, string $nome, ?string $numero, ?string $note): Richiesta
{
$richiesta = Richiesta::create([
'tipo' => $tipo === Richiesta::RICONSEGNA ? Richiesta::RICONSEGNA : Richiesta::NUOVO,
'nome' => trim($nome),
'numero_territorio' => $tipo === Richiesta::RICONSEGNA ? trim((string) $numero) : null,
'note' => trim((string) $note) !== '' ? trim((string) $note) : null,
]);
// Nel registro tipo e numero, mai il nome.
activity()->performedOn($richiesta)
->withProperties(array_filter(['tipo' => $richiesta->tipo, 'territorio' => $richiesta->numero_territorio]))
->log('richiesta_ricevuta');
$this->avvisa($richiesta);
return $richiesta;
}
/**
* Chi gestisce le richieste e ha un'email vera: amministratori e
* assistenti (permesso richieste.gestisci) con l'indirizzo confermato.
*/
public function destinatari(): Collection
{
return User::permission('richieste.gestisci')->get()
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
->values();
}
/**
* L'email parte subito e non in coda: contiene il nome scritto nella
* richiesta, che nel database è cifrato e in coda starebbe in chiaro.
* Senza un server di posta funzionante non si manda niente: la richiesta
* resta comunque nella Home.
*/
private function avvisa(Richiesta $richiesta): void
{
if (! $this->posta->funzionante()) {
return;
}
$destinatari = $this->destinatari();
if ($destinatari->isEmpty()) {
return;
}
try {
Notification::send($destinatari, new RichiestaRicevuta($richiesta));
} catch (\Throwable $e) {
report($e);
}
}
/**
* I proclamatori attivi con un nome riconducibile a quello scritto nella
* richiesta, dal più simile: uguale, iniziale, una lettera sbagliata.
* I nomi sono cifrati, quindi il confronto si fa qui, non in SQL.
*
* @return Collection<int, Proclamatore>
*/
public function nomiSimili(string $nome, ?Collection $proclamatori = null, int $quanti = 5): Collection
{
$cercate = self::parole($nome);
if ($cercate === []) {
return collect();
}
return ($proclamatori ?? Proclamatore::attivi()->get())
->map(function (Proclamatore $p) use ($cercate) {
$sue = self::parole($p->cognome . ' ' . $p->nome);
$punti = 0;
foreach ($cercate as $parola) {
$migliore = 0;
foreach ($sue as $sua) {
$migliore = max($migliore, self::somiglianza($parola, $sua));
}
$punti += $migliore;
}
return [$p, $punti];
})
->filter(fn ($coppia) => $coppia[1] >= 2)
// Prima il punteggio più alto, a pari punti in ordine alfabetico.
->sort(fn ($a, $b) => [$b[1], mb_strtolower($a[0]->nome_completo)] <=> [$a[1], mb_strtolower($b[0]->nome_completo)])
->take($quanti)
->map(fn ($coppia) => $coppia[0])
->values();
}
/** L'assegnazione aperta del territorio indicato in una riconsegna, se c'è. */
public function assegnazioneAperta(?string $numero): ?Assegnazione
{
$numero = trim((string) $numero);
if ($numero === '') {
return null;
}
$territorio = Territorio::query()
->whereRaw('LOWER(numero) = ?', [Str::lower($numero)])
->with('assegnazioneCorrente.proclamatore')
->first();
return $territorio?->assegnazioneCorrente;
}
/** @return string[] parole minuscole, senza accenti né segni */
private static function parole(string $testo): array
{
$pulito = preg_replace('/[^a-z0-9 ]+/', ' ', Str::lower(Str::ascii($testo)));
return array_values(array_filter(explode(' ', (string) $pulito), fn ($p) => strlen($p) >= 2));
}
/** 3 uguale, 2 una inizia con l'altra, 1 una lettera di differenza. */
private static function somiglianza(string $a, string $b): int
{
if ($a === $b) {
return 3;
}
if (min(strlen($a), strlen($b)) >= 3 && (str_starts_with($a, $b) || str_starts_with($b, $a))) {
return 2;
}
if (min(strlen($a), strlen($b)) >= 4 && levenshtein($a, $b) <= 1) {
return 1;
}
return 0;
}
}

View File

@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
public function __construct(
protected TerritorioPdfImportState $stateService,
protected BackupOperationState $postoUnico,
) {
}
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
/**
* Prende il posto unico per questo import, o solleva spiegando chi lo occupa.
*
* L'import PDF sostituisce file e righe dei territori: in parallelo a un
* ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante
* un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o
* fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto
* si libera nel job, a fine lavoro o in failed().
*/
public function occupaPosto(string $importId): void
{
$inCorso = $this->postoUnico->occupa($importId, BackupOperationState::TIPO_IMPORT_PDF, 'In coda');
if ($inCorso !== null) {
throw new RuntimeException($this->postoUnico->messaggioOccupato($inCorso));
}
}
/** Libera il posto preso da un import che non e' arrivato alla coda. */
public function liberaPosto(string $importId, string $motivo): void
{
$this->postoUnico->markFailed($importId, $motivo);
}
/**
* Vero per i file che non vanno nemmeno considerati: quelli che non sono
* PDF e quelli di sistema o nascosti.
*
* Una cartella vera non contiene solo PDF: Windows ci lascia Thumbs.db e
* desktop.ini, macOS .DS_Store e — dentro gli ZIP — una copia "._nome.pdf"
* di ogni file sotto __MACOSX/. Prima uno solo di questi faceva fallire la
* validazione dell'intero caricamento; peggio, "._1.pdf" ha l'estensione
* giusta e sarebbe finito come mappa del territorio 1.
*/
public static function daIgnorare(string $percorso): bool
{
$segmenti = array_values(array_filter(preg_split('#[/\\\\]+#', $percorso) ?: [], fn ($s) => $s !== ''));
if ($segmenti === []) {
return true;
}
foreach ($segmenti as $segmento) {
if (str_starts_with($segmento, '.') || strcasecmp($segmento, '__MACOSX') === 0) {
return true;
}
}
// "~$" e' il prefisso dei file temporanei di Office.
$nome = end($segmenti);
return str_starts_with($nome, '~$')
|| strtolower(pathinfo($nome, PATHINFO_EXTENSION)) !== 'pdf';
}
/**
* Mette in coda l'import. Il posto unico deve essere gia' stato preso con
* occupaPosto(): se l'invio alla coda fallisce lo si libera qui.
*
* @param array<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
* @param array<int, array{file: string, message: string}> $scartati PDF rifiutati dalla validazione
*/
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog, array $ignorati = [], array $scartati = []): array
{
$state = $this->stateService->initialize($importId, count($storedFiles));
$this->stateService->appendLog($importId, $initialLog);
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
if ($ignorati !== []) {
$elenco = array_slice($ignorati, 0, 10);
$altri = count($ignorati) - count($elenco);
$this->stateService->appendLog(
$importId,
'Ignorati ' . count($ignorati) . ' file che non sono PDF da importare (file di sistema, nascosti o di altro tipo): '
. implode(', ', $elenco) . ($altri > 0 ? ' e altri ' . $altri : '') . '.'
);
}
foreach ($scartati as $scartato) {
$this->stateService->appendLog($importId, '[SKIP] ' . $scartato['file'] . ' - ' . $scartato['message']);
$this->stateService->addIssue($importId, [
'file' => $scartato['file'],
'type' => 'invalid-file',
'message' => $scartato['message'],
]);
}
try {
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
} catch (\Throwable $exception) {
// Il job non partira' mai, quindi non sara' lui a liberare il posto.
$this->stateService->markFailed($importId);
$this->liberaPosto($importId, 'Import PDF non messo in coda: ' . $exception->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $exception;
}
return $this->stateService->get($importId) ?? $state;
}
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
{
$importId = (string) Str::uuid();
// Il posto si prende prima di salvare ed estrarre: e' la parte lenta, e
// farla per poi sentirsi rispondere "c'e' un ripristino in corso" sarebbe
// lavoro buttato.
$this->occupaPosto($importId);
try {
return $this->estraiEAccoda($zipFile, $importId, $actorId);
} catch (\Throwable $exception) {
// Se il job e' partito il posto e' suo e lo libera lui; altrimenti
// resterebbe occupato da un import che non esiste.
if ($this->stateService->get($importId) === null) {
$this->liberaPosto($importId, 'Import PDF non avviato: ' . $exception->getMessage());
}
throw $exception;
}
}
private function estraiEAccoda(UploadedFile $zipFile, string $importId, ?int $actorId): string
{
$zipStoredPath = $zipFile->storeAs(
'bulk-territori-imports/' . $importId,
'archivio-' . $importId . '.zip',
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
$zip = new ZipArchive();
if ($zip->open($zipAbsolutePath) !== true) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Impossibile aprire il file ZIP.');
}
$storedFiles = [];
$ignorati = [];
$entryIndex = 0;
try {
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
continue;
}
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
if (self::daIgnorare($entryName)) {
$ignorati[] = basename($entryName);
continue;
}
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
}
if ($storedFiles === []) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
}
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
$importId,
$storedFiles,
$actorId,
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
$ignorati
);
return $importId;

49
app/Support/Ritorno.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
namespace App\Support;
/**
* La pagina da cui è partito un percorso, per tornarci dopo un'operazione.
*
* I link che aprono una scheda o un'operazione portano `?da=<percorso>`
* (lo aggiunge resources/js/app.js ai link con data-ritorno): il percorso
* della pagina di partenza con i suoi filtri, o il `da` che quella pagina
* aveva già ricevuto, così si risale sempre all'origine. Elenco → scheda →
* Rientra → conferma riporta all'elenco filtrato.
*
* Si accettano solo percorsi interni ("/territori?zona=3"): un indirizzo
* completo o "//altro.sito" farebbe del parametro un rimando verso l'esterno.
*/
final class Ritorno
{
public const PARAM = 'da';
/** L'URL a cui tornare, o null se il valore non è un percorso interno valido. */
public static function valida(mixed $percorso): ?string
{
if (! is_string($percorso) || $percorso === '' || strlen($percorso) > 2000) {
return null;
}
if (! str_starts_with($percorso, '/')
|| str_starts_with($percorso, '//')
|| str_contains($percorso, '\\')
|| preg_match('/[\x00-\x1F\x7F]/', $percorso)) {
return null;
}
// Le richieste interne di Livewire non sono pagine.
$path = (string) parse_url($percorso, PHP_URL_PATH);
if ($path === '' || str_starts_with($path, '/livewire')) {
return null;
}
return url($percorso);
}
/** Il ritorno indicato dalla richiesta corrente (?da=…), se valido. */
public static function daRichiesta(): ?string
{
return self::valida(request()->query(self::PARAM));
}
}

View File

@@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// '*' is deliberate. The container is only reachable through Traefik
// (no published port in Dokploy), and nginx's real_ip module has
// already rewritten REMOTE_ADDR to the client's address. Restricting
// this to private ranges would therefore stop Laravel from honouring
// the X-Forwarded-Proto that nginx sets server-side, and every signed
// URL and secure cookie would break behind HTTPS.
//
// X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
// set an arbitrary Host and have route()/url() emit links pointing at
// an attacker's domain — including the public assignment PDF links,
@@ -39,8 +46,19 @@ return Application::configure(basePath: dirname(__DIR__))
// Dopo il ripristino di un backup ogni account resta bloccato finche'
// non imposta una password nuova. In coda al gruppo web, quindi la
// sessione e' gia' avviata e l'utente risolto quando gira.
//
// AuthenticateSession prima: ogni sessione ricorda l'impronta della
// password, e quando la password cambia (profilo, cambio obbligatorio,
// l'amministratore che la reimposta) le sessioni aperte altrove si
// chiudono alla richiesta successiva. Quella che ha fatto il cambio
// resta aperta: il middleware ci registra l'impronta nuova. Senza,
// session()->regenerate() cambiava solo l'id della sessione corrente e
// le altre restavano valide su Redis.
$middleware->web(append: [
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\RequirePasswordChange::class,
// Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo.
\App\Http\Middleware\LimitaSorvegliante::class,
]);
$middleware->alias([

26
config/queue.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/*
* Solo la connessione redis: il resto resta quello del framework (Laravel 11
* unisce queue.connections con i propri valori predefiniti).
*/
return [
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
// Più lungo del job più lungo (import PDF: 900 s). Con i 90 s del
// framework un backup, un ripristino o un import ancora in corso
// tornava disponibile in coda e poteva partire una seconda volta
// sugli stessi dati — per esempio dopo un riavvio del worker.
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 960),
'block_for' => null,
'after_commit' => false,
],
],
];

View File

@@ -0,0 +1,56 @@
<?php
use App\Models\TerritorioMappa;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bozza e pubblicazione delle piantine.
*
* `geojson` diventa la bozza, su cui l'editor salva da solo a ogni modifica.
* Accanto si tiene quello che è stato pubblicato: il confine (per poter
* scartare la bozza), l'impronta del contenuto finito nel PDF (per sapere se
* da allora qualcosa è cambiato) e il momento della pubblicazione.
*
* Le piantine esistenti nascono "pubblicate" solo se il PDF in vigore del
* loro territorio è uno di quelli composti dalle piantine
* (territori-pdf/piantina-{territorio}-…): è l'unico caso in cui si sa che quel
* PDF viene da questo disegno. Tutte le altre partono come bozza.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->longText('geojson_pubblicato')->nullable()->after('geojson');
$table->string('impronta_pubblicata', 64)->nullable()->after('geojson_pubblicato');
$table->timestamp('pubblicata_at')->nullable()->after('impronta_pubblicata');
});
TerritorioMappa::with('territorio.zona')
->whereNotNull('territorio_id')
->whereNotNull('image_path')
->get()
->each(function (TerritorioMappa $mappa) {
$pdf = (string) $mappa->territorio?->pdf_path;
if (! str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-')) {
return;
}
$mappa->forceFill([
'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $mappa->impronta(),
'pubblicata_at' => $mappa->rendered_at ?? now(),
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->dropColumn(['geojson_pubblicato', 'impronta_pubblicata', 'pubblicata_at']);
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Toglie dal registro attività i nomi dei proclamatori scritti in chiaro.
*
* Assegnazioni e rientri registravano "proclamatore" => nome e cognome:
* dati che nel database sono cifrati, e che lì restavano leggibili anche
* dopo l'anonimizzazione. Al loro posto va l'id del proclamatore, preso
* dall'assegnazione a cui la voce si riferisce; il registro mostra il nome
* attuale. Se l'assegnazione non c'è più il nome si toglie e basta.
*/
return new class extends Migration
{
public function up(): void
{
$tabella = config('activitylog.table_name', 'activity_log');
DB::table($tabella)
->whereIn('description', ['assigned', 'returned'])
->where('properties', 'like', '%"proclamatore"%')
->orderBy('id')
->each(function ($voce) use ($tabella) {
$proprieta = json_decode((string) $voce->properties, true);
if (! is_array($proprieta) || ! array_key_exists('proclamatore', $proprieta)) {
return;
}
unset($proprieta['proclamatore']);
$idProclamatore = $voce->subject_id
? DB::table('assegnazioni')->where('id', $voce->subject_id)->value('proclamatore_id')
: null;
if ($idProclamatore) {
$proprieta['proclamatore_id'] = (int) $idProclamatore;
}
DB::table($tabella)->where('id', $voce->id)->update([
'properties' => json_encode($proprieta, JSON_UNESCAPED_UNICODE),
]);
});
}
public function down(): void
{
// I nomi tolti non si rimettono: era proprio il punto.
}
};

View File

@@ -0,0 +1,68 @@
<?php
use App\Models\TerritorioMappa;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* La piantina ricorda il percorso del PDF che ha pubblicato.
*
* Prima "Pubblicata" si riconosceva dall'id del territorio, sia
* nell'impronta sia nel nome del file (territori-pdf/piantina-{id}-…): il
* ripristino di un backup ricrea i territori con id nuovi, e ogni piantina
* pubblicata tornava "da pubblicare". Ora l'impronta usa il numero e lo stato
* confronta il percorso del PDF in vigore con quello registrato qui; entrambi
* attraversano il ripristino intatti.
*
* Le piantine già pubblicate restano tali solo se lo erano davvero: impronta
* corrispondente (con la formula di prima, o con la nuova se la migrazione
* precedente gira adesso su un'installazione nuova) e PDF in vigore composto
* da questa piantina.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->string('pdf_pubblicato')->nullable()->after('impronta_pubblicata');
});
TerritorioMappa::with('territorio.zona')
->whereNotNull('pubblicata_at')
->whereNotNull('territorio_id')
->get()
->each(function (TerritorioMappa $mappa) {
$territorio = $mappa->territorio;
$pdf = (string) $territorio?->pdf_path;
$improntaVecchia = hash('sha256', json_encode([
$mappa->geometria(),
$mappa->territorio_id,
$territorio ? trim((string) $territorio->numero) : null,
$territorio?->zona?->nome,
$territorio ? trim((string) $territorio->confini) : null,
]));
$improntaNuova = $mappa->impronta();
$eraPubblicata = in_array($mappa->impronta_pubblicata, [$improntaVecchia, $improntaNuova], true)
&& str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-');
if (! $eraPubblicata) {
return;
}
$mappa->forceFill([
'impronta_pubblicata' => $improntaNuova,
'pdf_pubblicato' => $pdf,
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->dropColumn('pdf_pubblicato');
});
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Tre cose che vanno insieme, perché la terza ha bisogno della prima.
*
* 1. Il server di posta si configura dall'applicazione (Impostazioni → Posta)
* e non più solo dal .env: servono le email di conferma degli utenti.
* La password è cifrata con APP_KEY (cast `encrypted` sul modello).
* 2. I proclamatori possono avere email e telefono, facoltativi. Cifrati come
* nome e cognome: sono dati personali, e il database non deve mostrarli
* in chiaro a chi ne legge una copia.
* 3. Gli utenti devono confermare la loro email. Decisione dell'utente
* (13/10/2026): anche gli account già esistenti, amministratore compreso.
* La pagina della posta resta raggiungibile da un amministratore non
* ancora confermato, e c'è `php artisan utenti:conferma-email` per i casi
* in cui la posta non funziona affatto.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->string('mail_host')->nullable();
$table->unsignedInteger('mail_port')->nullable();
$table->string('mail_cifratura', 10)->nullable();
$table->string('mail_username')->nullable();
$table->text('mail_password')->nullable();
$table->string('mail_from_address')->nullable();
$table->string('mail_from_name')->nullable();
});
Schema::table('proclamatori', function (Blueprint $table) {
// text: il valore cifrato è molto più lungo dell'indirizzo.
$table->text('email')->nullable();
$table->text('telefono')->nullable();
});
DB::table('users')->update(['email_verified_at' => null]);
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn([
'mail_host', 'mail_port', 'mail_cifratura', 'mail_username',
'mail_password', 'mail_from_address', 'mail_from_name',
]);
});
Schema::table('proclamatori', function (Blueprint $table) {
$table->dropColumn(['email', 'telefono']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Invio del link dopo l'assegnazione.
*
* - link_da_inviare: l'assegnazione aspetta che il link venga mandato al
* proclamatore. Nasce vero con l'assegnazione fatta dalla pagina Assegna e
* torna falso quando il link risulta inviato. Le assegnazioni già esistenti
* restano false: non si segnalano a posteriori centinaia di link di cui non
* si sa niente.
* - link_canale: come è stato mandato l'ultima volta (copia, whatsapp, email).
* Non è un dato personale: email e telefono restano solo, cifrati, nel
* proclamatore.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('link_da_inviare')->default(false)->after('link_sent');
$table->string('link_canale', 16)->nullable()->after('link_da_inviare');
});
}
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn(['link_da_inviare', 'link_canale']);
});
}
};

Some files were not shown because too many files have changed in this diff Show More