Compare commits

..

38 Commits

Author SHA1 Message Date
Francesco Picone
c3f262d89d ++ HANDOFF: correzione di PHP in produzione il 21 ottobre 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:50:51 +02:00
Francesco Picone
1ddb97371f ++ 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:03:33 +02:00
eb756ad3b1 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 12:03:23 +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
162 changed files with 12820 additions and 2148 deletions

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 # I dati personali dei proclamatori sono cifrati con questa chiave: se la
# perdi non sono piu' leggibili nemmeno avendo il backup del database. # perdi non sono piu' leggibili nemmeno avendo il backup del database.
# Conservala in un password manager. # 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= APP_KEY=
LOG_CHANNEL=stderr LOG_CHANNEL=stderr
@@ -122,15 +126,24 @@ SESSION_SECURE_COOKIE=false
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Posta # 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 produzione: SMTP esterno.
# In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025 # In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025
# Per disattivare del tutto l'invio: MAIL_MAILER=log # 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_MAILER=smtp
MAIL_HOST= MAIL_HOST=
MAIL_PORT=587 MAIL_PORT=587
MAIL_USERNAME= MAIL_USERNAME=
MAIL_PASSWORD= MAIL_PASSWORD=
MAIL_SCHEME=tls MAIL_SCHEME=
MAIL_FROM_ADDRESS=noreply@example.org MAIL_FROM_ADDRESS=noreply@example.org
MAIL_FROM_NAME="${APP_NAME}" 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

2
.gitignore vendored
View File

@@ -18,6 +18,7 @@
/.phpunit.cache/ /.phpunit.cache/
/.idea/ /.idea/
/.vscode/ /.vscode/
.claude
*.swp *.swp
*.swo *.swo
*.sql *.sql
@@ -29,3 +30,4 @@
docker-compose.override.yml docker-compose.override.yml
db_data/ db_data/
redis_data/ redis_data/
.claude

View File

@@ -4,7 +4,52 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto. può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **28 agosto 2026**. Ultimo aggiornamento: **21 ottobre 2026** (rilascio di sicurezza di PHP) — vedi [Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
> **Questo è l'HANDOFF di `main` (produzione).** Il 21/10/2026 in produzione
> è andata **solo la correzione di PHP**, scelta dell'utente: le funzioni nuove
> di `dev` (gruppi di servizio e lavori del 20 ottobre) restano sulla demo.
> Il quadro completo e aggiornato del progetto è nell'HANDOFF di `dev`, che
> sostituirà questo quando `dev` verrà portato in produzione.
---
## Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)
Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
La produzione girava su 8.3.35. Rilasciati su `main` solo i due commit di
sicurezza di `dev` (cherry-pick):
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
un tag mobile o a una versione inferiore.
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
quel giorno pecl.php.net rispondeva 504 e la build falliva.
- `docker/php/php.ini`: `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1`, la mitigazione indicata da Sansec in attesa
della patch, da tenere anche dopo (l'applicazione usa le sessioni di
Laravel, in Redis).
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
**In produzione dal 21 ottobre 2026, ore 18:48** (16:48 UTC; `main` =
`1ddb973`). Su Docker Hub il tag `php:8.3.36-fpm` è comparso alle 15:38 UTC
ma vuoto: l'immagine per amd64 (il server) è arrivata alle 16:41 UTC, e prima
la build falliva con "no match for platform in manifest". Prima del deploy:
test di questa versione su PHP 8.3.36 (524 PHP + 13 JS verdi) e backup
`/etc/dokploy/termanager2-backups/prod-20261021-164333` (database, 78 MB di
`storage/app`, conteggi). Deploy lanciato a mano (l'autodeploy non è
partito), uno solo. Verifica: PHP 8.3.36, `session.upload_progress.enabled`
Off e `session.use_strict_mode` On dal `php.ini` dell'immagine, nessuna
migrazione in sospeso, conteggi identici al backup (274 territori, 1130
assegnazioni di cui 77 aperte, 111 proclamatori, 4 utenti, 1251 righe di log,
35 migrazioni), login 200, container sani, nessun errore nei log. La
mitigazione applicata a mano ai container la mattina (in `conf.d`) è
sparita con la ricostruzione, come previsto: vale il `php.ini`.
--- ---
@@ -75,6 +120,332 @@ e l'unica che richiede una decisione, non del lavoro.
--- ---
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo):
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
(integrità dati).
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
della tabella sono nel commit successivo.
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
Richieste dell'utente, con le sue scelte:
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
"Invia email di prova". Se compilato prende il posto del `.env`
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
un valore non valido per Laravel 11: ora vuoto.
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
considera italiano).
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
(`Assegna::DA_INVIARE`) non esiste più.
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
L'admin crea l'utente con la password come prima; parte l'email di conferma
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
link. In Utenti: stato "da confermare" e "Reinvia conferma".
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
e — per l'amministratore — il server di posta.
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
creato da riga di comando nasce confermato.
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
`$confermaUtentiCreati`).
Provato nel browser in locale con mailpit come server configurato dall'app:
email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo.
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email
né telefono**, e WhatsApp ed email spariscono come canali. Restano:
- **Copia link**;
- **Condividi…**: `navigator.share()` con il messaggio già scritto
(`Assegnazione::messaggioLink()`), cioè il menu di condivisione del telefono
(WhatsApp, Telegram, SMS…), da cui chi usa l'app sceglie app e contatto.
Compare solo dove il browser lo offre (telefoni, alcuni computer); il canale
si annota a condivisione fatta, non se la si annulla. Nell'elenco dei
territori assegnati "Condividi" andato a buon fine segna il link come
inviato (`ElencoAssegnati::segnaCondiviso`).
Cosa è cambiato:
- Migrazione `2026_10_19_000020_togli_contatti_proclamatori`: elimina le
colonne `email` e `telefono`. In produzione erano vuote (0 su 111) al
momento della decisione; su dev c'era un solo valore di prova.
- Tolti dal form, dalla scheda e dall'elenco dei proclamatori; tolti
`Proclamatore::telefonoInternazionale()/whatsappUrl()`, il componente
`<x-whatsapp>`, gli stili `tm-whatsapp`, la notifica `LinkTerritorio` e
`Assegna::inviaEmail()`.
- XML: non si esportano più; un file vecchio che li contiene si importa
ignorandoli.
- `Assegnazione::CANALI_ATTIVI` = copia, condividi. In `CANALI_LINK` restano
anche "whatsapp" ed "email" solo per le assegnazioni già inviate così.
- La posta resta, ma solo per le email agli utenti (conferma dell'indirizzo).
- Informativa privacy, README e test (`InvioLinkTest`, al posto di
`ContattiProclamatoriTest`) aggiornati.
**In produzione dal 19 ottobre 2026, ore 17:10** (`main` = `7384a4b`). Prima:
ricontrollato che email e telefono fossero vuoti (0 su 111) e backup in
`/etc/dokploy/termanager2-backups/prod-20261019-150408/`. Dopo: migrazione
applicata, colonne tolte, conteggi identici a prima (1130 assegnazioni, 77
aperte), 111/111 proclamatori decifrati, 272/272 PDF, 4/4 utenti confermati.
L'autodeploy non era partito entro 2 minuti dal push: deploy avviato a mano.
### Allineamento di main in dev e passaggio in produzione (19 ottobre 2026)
`main` è stato unito in `dev` (merge, non rebase): da `main` arrivano solo
`S-12_I.pdf` nella radice e una riga di `.gitignore`. Quel PDF, copia
identica di `resources/pdf/S-12_I.pdf` e non usato dal codice, è stato poi
tolto su richiesta dell'utente; il modulo usato da `PiantinaPdfService` è
quello in `resources/pdf/` e resta. I 5 conflitti (README,
`CampagnaShow`, `Registro`, `Assegnazione`, vista della campagna) nascevano
dalle correzioni sulle campagne fatte su `main` la mattina e già portate su
`dev`: si è tenuta la versione di `dev`, che le contiene già (con
`proclamatore_id` nel log al posto del nome). Dopo, `main` va portato avanti
fino a `dev` senza un nuovo merge, così in produzione va esattamente il codice
provato su dev.
Analisi d'impatto per prod (fatta sui dati di prod copiati in dev):
- 11 migrazioni nuove, provate sulla copia: nessun errore, conteggi identici.
Due toccano dati esistenti: `nomi_proclamatori_fuori_dal_log` (irreversibile,
solo da backup) e `posta_contatti_e_conferma_email`, che **azzera la
conferma email di tutti gli utenti**.
- In prod non c'è un server di posta: subito dopo il deploy va eseguito
`php artisan utenti:conferma-email <email>` per ogni utente, altrimenti
nessuno riesce a lavorare. La posta si configura poi dall'app.
- `MAIL_SCHEME=tls` nell'ambiente di prod non è valido per Laravel 11:
svuotarlo. Prod è in HTTPS con `SESSION_SECURE_COOKIE=false`: valutare `true`.
- Il nuovo controllo dell'entrypoint su `APP_KEY` passa: chiave d'ambiente e
`storage/app/.app_key` coincidono.
- Ritorno indietro: ripristino del dump fatto prima del deploy e deploy del
`main` precedente (`bde7a4a`).
**Rilasciato in produzione il 19 ottobre 2026 alle 16:13** (`main` =
`e4549f8`, fast-forward da `dev`). Prima: backup in
`/etc/dokploy/termanager2-backups/prod-20261019-141137/` sul server (database,
file, conteggi) e copia del dump sul PC dell'utente; `MAIL_SCHEME` svuotato
nell'ambiente di prod. Deploy di 32 secondi, 11 migrazioni applicate senza
errori, email dei 4 utenti confermata con `utenti:conferma-email`. Verifica:
conteggi identici a prima del deploy, 111/111 proclamatori decifrati, 272/272
PDF presenti, campagna di settembre a 13 conteggiati (4,7%), nessun nome in
chiaro rimasto nel log. Subito dopo, su decisione dell'utente,
`SESSION_SECURE_COOKIE=true` nell'ambiente di prod (nuovo deploy: i cookie di
sessione e XSRF ora sono `secure; httponly; samesite=lax`). Resta da fare:
configurare la posta dall'app (*Impostazioni → Server di posta*) per poter
usare il canale Email.
Deploy: su Dokploy l'autodeploy da push è attivo (`triggerType=push`, nessun
filtro di percorso) e secondo l'utente funziona. Le variabili d'ambiente
invece diventano effettive solo con un nuovo deploy, che una loro modifica
non avvia da sola.
### Informativa privacy aggiornata e dinamica (19 ottobre 2026)
Richiesta dell'utente: aggiornare la sezione privacy con i dati attuali e
prendere i parametri dalle impostazioni. Il testo era fermo a una versione
precedente (solo nome e cognome, "tre ruoli: Admin, Servitore di Territorio,
Proclamatore Semplificato" che non esistono).
- `App\Livewire\Privacy` legge al momento: congregazione,
`audit_retention_days`, `giorni_per_smarrito` (scadenza del link),
`pdf_viewer_show_download`, server di posta (`ConfigurazionePosta`),
`config('app.cipher')`, `config('session.lifetime')`, `APP_URL` (avvisa se
non è HTTPS), i ruoli con i permessi veri (`Privacy::PERMESSI` li spiega) e
quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
- Aggiunte: email/telefono cifrati, link alla piantina, invio via WhatsApp
(parte dal telefono di chi assegna) ed email (server di posta configurato),
mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi.
- Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali,
da far rivedere a chi ne ha la competenza.
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
legge i proclamatori dal modello e quindi esporta nome, cognome, email e
telefono **decifrati**. L'informativa ora lo dice; valutare se cifrare
l'archivio (per esempio con una password scelta al download).
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
i conteggi senza nomi e l'accesso solo amministratore.
### Pagina Assegna più leggera; "Vedi tutti" in testata nella Home (19 ottobre 2026)
Segnalazione dell'utente: la pagina Assegnazioni era lenta. Misurato su dev
con i dati di prod (79 assegnazioni aperte): il server la genera in ~80 ms
con 10 query, ma l'HTML pesava **387 KB** (299 l'elenco dei territori
assegnati, di cui 58 di icone SVG ripetute; 75 le opzioni dei campi di scelta)
e Livewire lo rimandava **per intero a ogni interazione**: scegliere un
territorio (anteprima con `wire:model.live`), scrivere nella ricerca
dell'elenco (`linkSearch`), aprire l'invio del link. Rimedi:
- L'elenco è un componente a sé, `Assegnazioni\ElencoAssegnati`, **lazy**: la
pagina mostra subito il form e le sue azioni non lo ridisegnano. "Invia
link" nell'elenco chiede alla pagina di aprire l'invio con l'evento
`apri-invio` (`Assegna::apriInvio` ha `#[On('apri-invio')]`);
`toggleLinkSent` sta nell'elenco.
- La ricerca nell'elenco filtra nel browser (`data-cerca` senza accenti,
Alpine `elencoCercabile`): nessuna richiesta mentre si scrive.
- Il territorio va a Livewire con `wire:model` (non più `.live`): l'anteprima
della miniatura segue la scelta nel browser (evento `scelta` di
`<x-scelta>`, URL della miniatura nelle opzioni).
- Righe compatte: icone come simboli SVG definiti una volta e richiamati con
`<use>`, classi brevi `tm-assegnato-*` in `app.css` al posto delle catene
di utility, copia del link con il metodo condiviso `copia()` di
`elencoCercabile`. I pulsanti restano `<x-btn>` (lo impone
`StileUnificatoTest`). Il ramo "link scaduto" dell'elenco (solo per non autenticati, cioè
mai su questa pagina) è stato tolto.
- `AssegnaPrestazioniTest` tiene fermi questi punti, compreso un tetto al
peso per riga dell'elenco.
- Corretto anche `Assegnazione::giorni`: con Carbon 3 `diffInDays()` è un
float e PHP segnalava una conversione implicita a ogni riga.
Home: "Vedi tutti" di *Da assegnare* e *Da rientrare* è ora un pulsante nella
testata del riquadro, accanto al PDF, invece che in fondo all'elenco. Compare
alle stesse condizioni di prima (elenco troncato al limite della Home).
### Scelta di territori e proclamatori con ricerca (19 ottobre 2026)
Al posto delle tendine di territorio e proclamatore (Assegna, finestra del
Registro, editor delle piantine) c'è `<x-scelta>`: si scrive e compaiono le
proposte più vicine; campo vuoto o appena aperto = la tendina completa.
- Ordine: uguale → inizia con → contiene → a una lettera di distanza ("forse
cercavi", due lettere dalle 7 in su; mai sui numeri sotto le 4 cifre, così
"35" non trova "36"). Le parole scritte valgono in qualsiasi ordine ("mario
ros"); maiuscole e accenti non contano. Logica pura in
`resources/js/scelta.js`, provata da `tests/js/scelta.test.mjs`
(`npm run test:js`, oppure `node --test tests/js/`).
- Le opzioni le prepara il server (`Territorio::opzioneScelta()`,
`Proclamatore::opzioneScelta()/opzioniScelta()`). Con `motivo` un'opzione
compare cercando ma non si sceglie: in Assegna i territori già assegnati
("già assegnato a …"), nel Registro territori e proclamatori disattivati.
- Nomi e cognomi sono cifrati: si ordinano e si cercano decifrati, nella
pagina. Prima il Registro li ordinava in SQL, cioè a caso.
- Telefono: testo a 16px sotto i 640px (iOS non ingrandisce), voci alte 44px,
elenco in mezzo schermo, scelta al tocco senza perdere il focus; dopo la
scelta col dito il campo perde il focus e la tastiera si chiude.
- Con Livewire: `wire:model` sul componente (x-modelable). `vuoto` è il valore
di "nessuna scelta": `null` per le proprietà `?int` (Assegna), `''` per le
stringhe. Il componente si ridisegna quando cambiano le opzioni (wire:key
con l'impronta), perché x-data le legge una volta sola.
- Il filtro "Filtra territori" di Assegna non serve più ed è stato tolto.
### Si torna da dove si è partiti (19 ottobre 2026)
Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna
alla pagina da cui il percorso era partito, con i suoi filtri, ricerca,
ordinamento e pagina. Elenco filtrato → scheda → Rientra → conferma riporta
all'**elenco filtrato**, non alla scheda (scelta esplicita).
- Gli elenchi (territori, proclamatori, registro) tengono ricerca, filtri e
ordinamento nell'indirizzo con `#[Url]`; la pagina c'era già (paginazione).
- I link verso schede e operazioni hanno `data-ritorno`: `resources/js/app.js`
gli aggiunge `?da=<percorso>` al momento dell'uso (pointerdown/click/menu
contestuale/focus, prima di wire:navigate). Se la pagina corrente ha già un
`da`, passa quello: si risale sempre all'origine.
- `App\Support\Ritorno::valida()` accetta solo percorsi interni (niente
`https://…`, `//…`, `\`, caratteri di controllo, `/livewire/…`): il
parametro non può diventare un rimando verso l'esterno.
- Le pagine di scheda e operazione usano il trait `RicordaRitorno`
(`$ritorno`, `destinazione()`): redirect dopo il salvataggio, "Annulla" e
"Torna…" (che con un ritorno diventa "Torna indietro").
- Aperte senza `da` (menu laterale, link salvato) tutto resta com'era.
- Link nuovi verso una scheda o un'operazione: metterci `data-ritorno`;
pagine nuove di operazione: usare `RicordaRitorno`.
### Invio del link dopo l'assegnazione, nuovo territorio dal designer (19 ottobre 2026)
Richieste dell'utente:
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
apre una finestra che blocca la pagina (`Assegna::apriInvio`) con i canali
- *Copia link*: sempre;
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
- *Email*: solo se ha un indirizzo **e** la posta funziona
(`ConfigurazionePosta::funzionante`). La manda l'app con
`Notification::route('mail', …)->notifyNow(new LinkTerritorio)`: **mai in
coda**, perché il job salverebbe in chiaro su Redis/`failed_jobs`
l'indirizzo che nel database è cifrato.
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
possibile. Solo dopo la risposta si ricarica la pagina.
- **Se la procedura non si conclude** (scheda chiusa) o si risponde *No*, resta
`assegnazioni.link_da_inviare = true` e compare "Link ancora da inviare"
nell'elenco della pagina Assegna (con "Invia link", che riapre la finestra)
e nella scheda del territorio (che rimanda a `assegnazioni/assegna?invia=<id>`).
L'avviso sparisce quando il link risulta inviato, anche dal pulsante
"Non inviato / Link inviato" dell'elenco.
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
assegnazioni già esistenti restano false: niente avvisi a posteriori su
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`,
`Assegnazione::CANALI_LINK`). Entrambe nell'XML.
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
mai nome, numero o email. Email e telefono restano solo, cifrati, nel
proclamatore.
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
`settings.manage` resta sul form `territori.create`, che esiste ancora.
### Revisione profonda: blocchi 2 e 3 chiusi
| Problema | Correzione |
|---|---|
| Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) | trait `Avvisa` + `<x-avvisi>`; un test vieta `session()->flash()` senza redirect |
| PDF, miniature e versioni scaricabili da `/storage/territori-*` senza login (nomi indovinabili, cache Cloudflare 7 giorni) | nginx risponde 404 su `/storage/territori-`; `TerritorioFileController` con permessi della pagina e cache privata; `Territorio::pdfUrl()` / `miniaturaUrl()` |
| Ripristino non atomico | estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (`rename`, annullabile) → file vecchi cancellati solo dopo il commit |
| Piantine, storico PDF, area KML fuori dal backup | formato archivio 2 (`piantine.json`, `piantine/…`, `PiantineBackup`); gli archivi di formato 1 si ripristinano lo stesso |
| Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi | `BackupOperationState::occupa()` sotto `Cache::lock`, usato da tutte le porte; rilascio anche su errore e in `failed()` |
| Import PDF da cartella: `1-A` → territorio 1, `12 B` ambiguo, solo 20 file, `Thumbs.db` blocca tutto, `__MACOSX` | abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati |
| Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo | prima il nuovo, poi il vecchio nello storico (`PiantinaPdfService::archiviaCorrente`, origini `import` / `manuale`) |
| L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) | l'import elimina i file non più usati; per i vecchi c'è `php artisan territori:file-orfani` (elenca; `--elimina` cancella) — **da lanciare quando si decide** |
| Azzeramento lasciava piantine orfane, area KML, anteprime | cancellate anche quelle |
| `/cambia-password` senza password attuale | richiesta, tranne dopo un ripristino (cambio obbligatorio) |
| Sessioni aperte altrove non chiuse al cambio password | `AuthenticateSession` nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori |
| Nomi dei proclamatori in chiaro nel registro attività | si salva `proclamatore_id`, il registro mostra il nome attuale; migrazione `2026_10_12_000010` ripulisce le voci vecchie |
| Export XML con `remember_token`; l'import XML non obbligava a cambiare password | token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password |
| Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% | confronto per data; si contano i territori attivi distinti |
| `retry_after` 90 s con job fino a 900 s | `config/queue.php`: 960 s |
| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` |
| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` |
| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) |
| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) |
| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` |
| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore |
Non cambiato, di proposito: `trustProxies('*')` (commento in
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
Suite: **429 test verdi**. Provati in browser in locale: file protetti (404
senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2
lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato).
**Non provato / aperto:**
- chiusura delle sessioni con due browser veri; coda e worker con un riavvio
reale; modifica vertici al tocco su tablet vero;
- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche
lotto riceve 429 (il browser lo segnala);
- numeri di territorio con lettere davanti ("A12") non si abbinano da nome
file: il PDF si carica dalla scheda.
---
## Da dove veniamo ## Da dove veniamo
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
@@ -212,13 +583,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare `nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
`territori` come fa ogni import XML, non porta più via i disegni. `territori` come fa ogni import XML, non porta più via i disegni.
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e **Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato. si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si **Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
rigenera un territorio alla volta dall'elenco. il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
PDF cambia solo con **Pubblica**, uguale in editor e gestione
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
Cloudflare), che genera immagine e PDF sul disco **privato**
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
qualcosa di stampato, la conferma rifiuta.
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
in vigore del territorio veniva da una piantina.
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
componente `<x-avvisi>`): i flash di sessione nel telaio non si vedono dopo
un'azione Livewire.
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è **La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
@@ -234,11 +640,6 @@ nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
marca i territori collegati senza zona: senza, l'intestazione della cartolina marca i territori collegati senza zona: senza, l'intestazione della cartolina
esce vuota e ce se ne accorge in stampa. esce vuota e ce se ne accorge in stampa.
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
dopo significa trovarselo già nello storico.
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte **L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`): che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
@@ -650,13 +1051,16 @@ Scelte non ovvie:
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
sopravvive, quella dell'azzeramento stesso. sopravvive, quella dell'azzeramento stesso.
- **Ordine del restore: staging → database → file.** I file si estraggono - **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o (`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi `piantine.json` del formato 2) prima di toccare qualcosa; poi una sola
`importXmlFromContent()` in transazione; poi la sostituzione dei file. Il `DB::transaction` contiene import XML, tabelle delle piantine, blocco degli
disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`:
appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa
schermata mostra e nessuna cancellazione raggiunge. fallisce si torna allo stato di prima; i file vecchi aspettano in
`storage/app/backup-precedenti/<uuid>` e si cancellano solo dopo il commit.
Il disco `public` viene sostituito, non fuso: i percorsi vivono nelle righe
appena riscritte.
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su - **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
numero di entry, dimensione della singola entry e totale decompresso, pochi numero di entry, dimensione della singola entry e totale decompresso, pochi
@@ -938,10 +1342,12 @@ Le scelte che vale la pena conoscere:
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con
la chiave di produzione, che vive nell'ambiente e nel volume. la chiave di produzione, che vive nell'ambiente e nel volume.
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è - **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto: PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano
renderla pubblica solo per poterla testare avrebbe allargato la superficie direttamente; il componente `Registro` si limita a validare l'anno e
del componente per comodità del test. scaricare. Il PDF scrive sopra il modulo ufficiale
(`resources/pdf/S-13_I.pdf`, convertito in PDF 1.4 per FPDI) alle
coordinate della sua griglia, come la cartolina S-12.
### Un solo container esegue le migrazioni ### Un solo container esegue le migrazioni
@@ -956,6 +1362,12 @@ mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
provider **solleva un'eccezione** se manca, invece di rigenerarla. provider **solleva un'eccezione** se manca, invece di rigenerarla.
Dal 09/10/2026 l'entrypoint **si rifiuta di partire** se `APP_KEY`
nell'ambiente è diversa dalla copia in `storage/app/.app_key`: prima la
sovrascriveva, e un valore sbagliato in Dokploy distruggeva l'unica copia della
chiave giusta. Per un cambio voluto si aggiunge `APP_KEY_ROTATE=1` (la chiave
vecchia resta in `.app_key.prev-<data>`) e poi la si toglie.
--- ---
## Bug corretti — i più significativi ## Bug corretti — i più significativi
@@ -984,6 +1396,14 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` | | Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` |
| Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` | | Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` |
| Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` | | Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` |
| Date esportate in UTC | Ogni backup → ripristino (e export → import XML) spostava **tutte** le date indietro di un giorno, sommandosi a ogni giro. I file vecchi vengono riportati al giorno giusto all'import | `XmlExchange::righeEsportate()`, `normalizeDateForDb()` |
| Import di qualsiasi XML | Un XML ben formato ma estraneo svuotava il database con "completato con successo". Ora servono la radice `<termanager2>` e le sezioni principali | `XmlExchange::importXmlFromContent()` |
| Caratteri di controllo nell'export | Uno solo, in una nota, rendeva il file (e il `data.xml` del backup) non reimportabile | `XmlExchange::addXmlText()` |
| Territori senza zona scartati all'import | Sparivano con tutto il loro storico di assegnazioni | `XmlExchange::importXmlFromContent()` |
| Import che estromette chi lo esegue | Password vecchia e ruoli del file applicati anche all'amministratore che importava | `XmlExchange::importXmlFromContent()` |
| `whereNull` fuori da `latestOfMany` | Con una chiusa più recente dell'aperta il territorio risultava libero mentre era fuori, e assegnabile due volte | `Territorio::assegnazioneCorrente()` |
| Periodi di assegnazione accavallati | Uscite retrodatate a prima dell'ultimo rientro, doppie aperte dal Registro, anno scelto a mano incoerente con la data | `Assegnazione::sovrappostaA()`, `Assegna`, `Registro` |
| Declassamento dell'ultimo amministratore | Togliendosi il ruolo l'installazione restava senza nessuno che potesse gestirla | `Settings/UsersIndex` |
| `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` | | `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` |
| `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` | | `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
| Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` | | Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` |
@@ -1031,7 +1451,7 @@ docker compose --profile test run --build --rm test
|------|---------------| |------|---------------|
| `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio | | `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio |
| `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata | | `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata |
| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" compreso il caso in cui l'ultima del giro precedente è ancora aperta, esclusione di territori disattivati e cestinati, filtro sull'anno di servizio, ordinamento naturale dei numeri | | `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" (fine ciclo e ultimo rientro prima dell'anno), assegnazioni a cavallo d'anno su entrambi i fogli, una riga per ogni territorio attivo anche senza assegnazioni, esclusione di territori disattivati e cestinati, ordinamento naturale dei numeri, fogli da 20 righe e generazione del PDF |
| `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede | | `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede |
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine | | `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina | | `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
@@ -1073,15 +1493,14 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL. (`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
### 2. Race su doppia assegnazione ### 2. Race su doppia assegnazione — chiusa il 09/10/2026
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma `Assegna::save()` e il salvataggio del Registro controllano e scrivono dentro
nulla a livello di database impedisce due assegnazioni aperte sullo stesso una transazione con `lockForUpdate()` sulla riga del territorio, e rifiutano
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`, ogni periodo che si accavalla a un altro (`Assegnazione::sovrappostaA()`). Non
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi. c'è ancora un vincolo nel database: l'import XML e i dati storici possono
contenere sovrapposizioni, che il controllo segnala solo quando si modifica
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna una di quelle righe.
generata che imponga una sola assegnazione aperta per territorio.
### 3. Scrittura durante il rendering ### 3. Scrittura durante il rendering
@@ -1132,21 +1551,12 @@ pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
`pagination::`. L'esclusione resta a guardia di future viste di terze parti. `pagination::`. L'esclusione resta a guardia di future viste di terze parti.
### 7. Il backup non contiene l'area KML delle piantine ### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026
`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe`
`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
mano dal KML originale, che quindi conviene conservare fuori di qui. lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave
esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che
fa ogni import XML — scollegati, ma recuperabili.
Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che
tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è
stato fatto perché il KML è un file che l'utente ha già sul suo computer: la
perdita è un fastidio, non un dato irrecuperabile.
--- ---

View File

@@ -338,6 +338,16 @@ Cosa serve sapere:
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va `CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
provato sullo stack vero, non qui. 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 ## Variabili d'ambiente
@@ -678,11 +688,15 @@ docker compose --profile dev up -d --build
- Media mensile di percorrenza e durata media delle assegnazioni - Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso - Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**, - Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
**da rientrare** **da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante
in testata che apre l'elenco territori già filtrato
### Territori ### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente - 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) - 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
- Link PDF pubblico condivisibile, a scadenza configurabile - Link PDF pubblico condivisibile, a scadenza configurabile
@@ -696,12 +710,31 @@ docker compose --profile dev up -d --build
- Soft delete con cestino - Soft delete con cestino
### Assegnazione e rientro ### 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 - Anno teocratico rilevato automaticamente dalla data
- Validazione: territorio non già assegnato, entrambi attivi - 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
- Rientro con calcolo automatico dei giorni - Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene - **Prompt campagna**: se il territorio era fuori durante una campagna viene
chiesto se conteggiarlo 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)
### Campagne ### Campagne
- CRUD con date di inizio e fine - CRUD con date di inizio e fine
@@ -764,8 +797,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
## Sicurezza e GDPR ## Sicurezza e GDPR
### Sicurezza ### Sicurezza
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con AES-256-CBC - **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
tramite `APP_KEY` AES-256-CBC tramite `APP_KEY`
- **Password**: hash bcrypt - **Password**: hash bcrypt
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle - **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
richieste Livewire richieste Livewire
@@ -779,15 +812,32 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione - **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php` - **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
restituisce 404 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 ### 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 - **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB
- **Audit trail**: registro completo delle operazioni - **Audit trail**: registro completo delle operazioni
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile - **Diritto all'oblio**: funzione di anonimizzazione irreversibile
- **Retention**: cancellazione automatica dei log oltre il periodo configurato - **Retention**: cancellazione automatica dei log oltre il periodo configurato
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi - **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
in chiaro in chiaro
- **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
--- ---
@@ -799,11 +849,11 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| `settings` | Configurazione singleton | | `settings` | Configurazione singleton |
| `zone` | Zone territoriali | | `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio | | `tipologie` | Tipologie di territorio |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete | | `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete | | `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
| `anni_teocratici` | Periodi settembre → agosto | | `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione | | `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`) |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**
@@ -842,8 +892,17 @@ sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
quando la soglia è scattata. quando la soglia è scattata.
### Percentuale di percorrenza delle campagne ### Percentuale di percorrenza delle campagne
- **Denominatore**: tutti i territori assegnati nel periodo della campagna - **Denominatore**: tutti i territori attivi
- **Numeratore**: le assegnazioni contrassegnate come conteggiate - **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".
--- ---
@@ -854,7 +913,7 @@ quando la soglia è scattata.
| Componente | Tecnologia | | Componente | Tecnologia |
|-------------------|--------------------------------------------| |-------------------|--------------------------------------------|
| Framework | Laravel 11 | | Framework | Laravel 11 |
| PHP | 8.3 (FPM) | | PHP | 8.3 (FPM), versione fissata nel Dockerfile |
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js | | Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
| Database | MariaDB 11 | | Database | MariaDB 11 |
| Cache/Sessioni/Code| Redis 7 | | Cache/Sessioni/Code| Redis 7 |

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), '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'); $user->assignRole('amministratore');
return $user; return $user;

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

@@ -43,23 +43,30 @@ class PasswordChangeController extends Controller
public function store(Request $request) 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, // Dopo un ripristino no: gli hash sono quelli del backup, e la
// la "password attuale" di un account e' quella che aveva all'epoca del // "password attuale" di un account e' quella che aveva all'epoca, che
// backup, che l'utente puo' benissimo non ricordare — l'amministratore // l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori // proprio le persone che questa schermata deve sbloccare; la garanzia li'
// proprio le persone che questa schermata deve sbloccare. La garanzia // e' la sessione. Fuori da quel caso, invece, bastava una sessione
// qui e' la sessione: si arriva a questa pagina gia' autenticati. // lasciata aperta per cambiare la password a qualcun altro e chiuderlo
$validated = $request->validate([ // fuori dal suo account.
'password' => ['required', 'confirmed', Password::min(8)], $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.required' => 'Inserisci la nuova password.',
'password.confirmed' => 'Le due password non coincidono.', '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. // Il cast `hashed` sul modello cifra il valore al salvataggio.
$user->update([ $user->update([
'password' => $validated['password'], 'password' => $validated['password'],
@@ -68,7 +75,10 @@ class PasswordChangeController extends Controller
// La sessione corrente sopravvive al cambio, le altre no: dopo un // La sessione corrente sopravvive al cambio, le altre no: dopo un
// ripristino le vecchie credenziali sono da considerarsi compromesse e // 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(); $request->session()->regenerate();
$user->setRememberToken(null); $user->setRememberToken(null);
$user->save(); $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,17 +110,22 @@ class BackupController extends Controller
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.', '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' // Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
// una porta d'ingresso separata: un ripristino avviato mentre gira un // una porta d'ingresso separata: un ripristino avviato mentre gira un
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando. // 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([ return response()->json([
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.', 'message' => $this->stato->messaggioOccupato($inCorso),
], 409); ], 409);
} }
$operationId = (string) Str::uuid(); try {
// Il file dell'upload vive in una cartella temporanea che sparisce a // Il file dell'upload vive in una cartella temporanea che sparisce a
// fine richiesta: il job lo troverebbe gia' cancellato. // fine richiesta: il job lo troverebbe gia' cancellato.
$percorso = $request->file('backupZip')->storeAs( $percorso = $request->file('backupZip')->storeAs(
@@ -128,12 +133,22 @@ class BackupController extends Controller
$operationId . '.zip', $operationId . '.zip',
'local' '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());
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.'); $this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
RestoreBackupArchive::dispatch( RestoreBackupArchive::dispatch(

View File

@@ -3,37 +3,30 @@
namespace App\Http\Controllers\Settings; namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\TerritorioMappa; use App\Services\Piantine\PubblicazionePiantina;
use App\Services\Piantine\PiantinaPdfService;
use Symfony\Component\HttpFoundation\Response; 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 * Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
* territorio, non archivia versioni, non lascia file sul disco. Serve a * soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione * e scade con l'anteprima; la rotta resta comunque riservata
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un * all'amministratore come il resto delle piantine.
* confine sbagliato quando è già nello storico.
*/ */
class PiantinaAnteprimaController extends Controller 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()->file($percorso, [
return response($pdf, 200, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
// `inline`: si apre nel visualizzatore del browser invece di // `inline`: si apre nel visualizzatore del browser invece di
// finire fra i download, che di un'anteprima non se ne fa niente. // finire fra i download, che di un'anteprima non se ne fa niente.
'Content-Disposition' => 'inline; filename="anteprima-territorio-' 'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
. $mappa->territorio->numero . '.pdf"',
// Un'anteprima rispecchia lo stato di adesso: se la cache la
// congela, si continua a guardare il confine di prima.
'Cache-Control' => 'no-store, max-age=0', '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\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Services\BackupOperationState;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str;
class XmlExchangeUploadController extends Controller 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) public function convertSqlToXml(Request $request)
{ {
$request->validate([ $request->validate([
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']); return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
} }
// 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(); $exchange = new XmlExchange();
$fileDiPrima = XmlExchange::fileDeiTerritori();
$result = $exchange->importXmlFromContent($content); $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'])) { if (isset($result['error'])) {
$stato->markFailed($operationId, $result['error']);
return back()->withErrors(['xmlImport' => $result['error']]); return back()->withErrors(['xmlImport' => $result['error']]);
} }
$stato->markCompleted($operationId);
$stats = $result['stats']; $stats = $result['stats'];
$issues = $result['issues']; $issues = $result['issues'];
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) { if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.'; $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') return redirect()->route('xml.exchange')
->with('success', $message) ->with('success', $message)

View File

@@ -0,0 +1,62 @@
<?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');
}
public function miniatura(Territorio $territorio): Response
{
return $this->servi($territorio->thumbnail_path, null, null);
}
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

@@ -26,6 +26,9 @@ class RequirePasswordChange
'password.change', 'password.change',
'password.change.store', 'password.change.store',
'logout', '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 public function handle(Request $request, Closure $next): Response

View File

@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Services\BackupArchive; use App\Services\BackupArchive;
use App\Services\BackupOperationState; use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; 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->markRunning($this->operationId);
$stato->fase($this->operationId, 'Lettura dei dati gestionali'); $stato->fase($this->operationId, 'Lettura dei dati gestionali');
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
$this->rimuoviArchiviVecchi($archivio); $this->rimuoviArchiviVecchi($archivio);
$xml = $exchange->currentXmlBackup(); $xml = $exchange->currentXmlBackup();
$datiPiantine = $piantine->esporta();
$stato->fase($this->operationId, 'Compressione dei file'); $stato->fase($this->operationId, 'Compressione dei file');
$archivio->create( $archivio->create(
$xml, $xml,
$datiPiantine,
$destinazione, $destinazione,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) 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\Territorio;
use App\Models\User; use App\Models\User;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantinaPdfService;
use App\Services\TerritorioPdfImportState; use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService; use App\Services\TerritorioThumbnailService;
use Illuminate\Bus\Queueable; 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); $stateService->markRunning($this->importId);
$postoUnico->markRunning($this->importId);
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.'); $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 = []; $territoriMap = [];
foreach (Territorio::withTrashed()->get() as $territorio) { 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); $actor = User::find($this->actorId);
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
continue; 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']; $matchedNumber = $territoryMatch['matched_number'];
$territorio = $territoryMatch['territorio']; $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])) { if (isset($seenNumbers[$normalizedNumber])) {
$stateService->increment($this->importId, 'skipped'); $stateService->increment($this->importId, 'skipped');
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
} }
try { 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 // The territory id is part of the filename because the slug
// alone is not unique: "1 A" and "1-A" are distinct // alone is not unique: "1 A" and "1-A" are distinct
// territory numbers that both slug to "territorio-1-a". // territory numbers that both slug to "territorio-1-a".
// Without it the second import overwrites the first's PDF // Without it the second import overwrites the first's PDF
// and both rows point at one file — then removing the PDF // and both rows point at one file — then removing the PDF
// from either territory breaks the other. // 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) $storedFilename = Str::slug('territorio-' . $territorio->numero)
. '-' . $territorio->id . '.pdf'; . '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
$publicPath = 'territori-pdf/' . $storedFilename; $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); $thumbnailPath = $thumbnailService->generate($publicPath);
$territorio->update([ $territorio->update([
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
$stateService->appendLog($this->importId, 'Import completato.'); $stateService->appendLog($this->importId, 'Import completato.');
$stateService->markCompleted($this->importId); $stateService->markCompleted($this->importId);
$postoUnico->markCompleted($this->importId);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors'); $stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage()); $stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
$stateService->markFailed($this->importId); $stateService->markFailed($this->importId);
$postoUnico->markFailed($this->importId, $exception->getMessage());
throw $exception; throw $exception;
} finally { } finally {
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
* Without this the import state is stranded in "running" — the UI polls it * 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 * until the 24h cache entry expires — and the uploaded temp directory is
* never reclaimed, since handle()'s finally never executes. * 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 public function failed(?\Throwable $exception): void
{ {
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') '[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
); );
$stateService->markFailed($this->importId); $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); 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 protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
{ {
if ($territoriMap === []) { $trovati = [];
return null; $suffissiSconosciuti = [];
foreach ($this->numeriNelNomeFile($filename) as $codice) {
if (isset($territoriMap[$codice])) {
foreach ($territoriMap[$codice] as $territorio) {
$trovati[$territorio->id] = $territorio;
} }
$basename = pathinfo($filename, PATHINFO_FILENAME);
$normalizedBasename = mb_strtoupper($basename);
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
$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; continue;
} }
$matches[] = [ // "1A" che non esiste, mentre 1 si': e' il caso che prima
'normalized_number' => $normalizedNumber, // sovrascriveva il territorio 1. Lo si annota per spiegarlo.
'matched_number' => $territoriMap[$normalizedNumber]->numero, $base = preg_replace('/\p{L}$/u', '', $codice);
'territorio' => $territoriMap[$normalizedNumber], 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; return null;
} }
if (count($matches) > 1) { $territorio = reset($trovati);
return [ return [
'type' => 'ambiguous', 'type' => 'single',
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)), 'normalized_number' => $this->chiaveTerritorio($territorio->numero),
'matched_number' => $territorio->numero,
'territorio' => $territorio,
]; ];
} }
return $matches[0]; /**
} * I numeri di territorio presenti nel nome del file, gia' nella forma di
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool *
* @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)) { // Il lookahead finale e' quello che separa "12 B" (numero 12B) da
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/'); // "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 preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
return ltrim($normalized, '0') ?: '0';
}
return $normalized;
} }
} }

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

@@ -8,6 +8,8 @@ use App\Models\Campagna;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\User; use App\Models\User;
use App\Models\Zona; use App\Models\Zona;
@@ -21,6 +23,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/** /**
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale, * Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
@@ -72,12 +75,17 @@ class ResetApplication implements ShouldQueue
$stato->avanza($this->operationId, 1); $stato->avanza($this->operationId, 1);
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.'); $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', 10);
DB::transaction(function () use ($stato) { DB::transaction(function () use ($stato) {
$passi = [ $passi = [
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
'Campagne' => fn () => Campagna::query()->delete(), '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(), 'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(), 'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
'Tipologie' => fn () => Tipologia::query()->delete(), 'Tipologie' => fn () => Tipologia::query()->delete(),
@@ -100,6 +108,13 @@ class ResetApplication implements ShouldQueue
// ricreare la riga. // ricreare la riga.
Setting::flushCache(); 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'); $stato->fase($this->operationId, 'Rimozione degli altri utenti');
$utentiRimossi = $this->rimuoviAltriUtenti($stato); $utentiRimossi = $this->rimuoviAltriUtenti($stato);

View File

@@ -3,8 +3,10 @@
namespace App\Jobs; namespace App\Jobs;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Services\BackupArchive; use App\Services\BackupArchive;
use App\Services\BackupOperationState; use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use RuntimeException;
use Spatie\Permission\PermissionRegistrar; 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); $stato->markRunning($this->operationId);
$staging = null; $staging = null;
$scambio = null;
$confermato = false;
try { try {
$stato->fase($this->operationId, 'Estrazione dell\'archivio'); $stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
$estratto = $archivio->extract( $estratto = $archivio->extract(
$this->zipPath, $this->zipPath,
@@ -56,34 +80,47 @@ class RestoreBackupArchive implements ShouldQueue
); );
$staging = $estratto['staging']; $staging = $estratto['staging'];
$piantine->valida($estratto['piantine']);
$stato->fase($this->operationId, 'Riscrittura del database', 1); $stato->fase($this->operationId, 'Riscrittura del database', 1);
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni // importXmlFromContent() attribuisce a auth()->id() le assegnazioni
// il cui autore non e' nel file. Qui non c'e' sessione, quindi // il cui autore non e' nel file. Qui non c'e' sessione, quindi
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul // l'utente va reimpostato a mano: senza, l'audit finirebbe sul
// primo id disponibile invece che su chi ha avviato il ripristino. // 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); Auth::onceUsingId($this->actorId);
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']); $result = $exchange->importXmlFromContent($estratto['xml']);
if (isset($result['error'])) { if (isset($result['error'])) {
$stato->markFailed($this->operationId, $result['error']); throw new RuntimeException($result['error']);
return;
} }
$stato->avanza($this->operationId, 1, 1); $esitoPiantine = $piantine->ripristina($estratto['piantine']);
$stato->fase($this->operationId, 'Ripristino dei file');
$fileRipristinati = $archivio->restoreFiles(
$staging,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
);
$stato->fase($this->operationId, 'Blocco degli account', 1);
$utenti = $this->richiediCambioPasswordATutti(); $utenti = $this->richiediCambioPasswordATutti();
$stato->avanza($this->operationId, 1, 1);
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
$scambio = $archivio->scambiaFile($estratto);
});
$confermato = true;
$archivio->confermaScambio($scambio);
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la // L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia' // mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
// caricata continuano a decidere gli accessi con quella di prima // caricata continuano a decidere gli accessi con quella di prima
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
$stato->avanza($this->operationId, 1, 1); $stato->avanza($this->operationId, 1, 1);
} catch (\Throwable $e) { } 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]); Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
$stato->markFailed($this->operationId, $e->getMessage()); $stato->markFailed($this->operationId, $messaggio);
return; return;
} finally { } finally {
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
} }
$stats = $result['stats']; $stats = $result['stats'];
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
$fileRipristinati = $scambio['file'];
$messaggio = sprintf( $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.', . 'Per sicurezza %s impostare una nuova password.',
$stats['territori_importati'] ?? 0, $stats['territori_importati'] ?? 0,
$stats['proclamatori_importati'] ?? 0, $stats['proclamatori_importati'] ?? 0,
$stats['assegnazioni_importate'] ?? 0, $stats['assegnazioni_importate'] ?? 0,
$esitoPiantine['mappe'],
$fileRipristinati, $fileRipristinati,
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono' $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 // 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, // pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
// il middleware dirotta l'amministratore sul cambio password. Lo si // il middleware dirotta l'amministratore sul cambio password. Lo si
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
$stato->log($this->operationId, $messaggio); $stato->log($this->operationId, $messaggio);
$stato->markCompleted($this->operationId, [ $stato->markCompleted($this->operationId, [
'stats' => $stats, 'stats' => $stats,
'issues' => array_slice($result['issues'], 0, 50), 'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
'file_ripristinati' => $fileRipristinati, 'file_ripristinati' => $fileRipristinati,
'messaggio' => $messaggio, 'messaggio' => $messaggio,
]); ]);
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
* muore per cause che handle() non intercetta, cosi' il posto unico si * muore per cause che handle() non intercetta, cosi' il posto unico si
* libera subito invece di restare occupato fino alla scadenza. * libera subito invece di restare occupato fino alla scadenza.
* *
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino * Qui il messaggio dice anche in che stato si e' rimasti. Un processo
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi * ucciso a meta' non arriva al commit, e il database annulla da se' la
* con database e file disallineati, e chi legge deve sapere che la strada * transazione rimasta aperta: i dati restano quelli di prima. L'unica
* e' rilanciare il ripristino, non ignorare l'errore. * 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 public function failed(?\Throwable $e): void
{ {
app(BackupOperationState::class)->markFailed( app(BackupOperationState::class)->markFailed(
$this->operationId, $this->operationId,
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).') ($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); @unlink($this->zipPath);

View File

@@ -2,21 +2,42 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Computed; use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\DB;
class Assegna extends Component class Assegna extends Component
{ {
use RicordaRitorno;
// ─── Invio del link ─────────────────────────────────────────
//
// Dopo l'assegnazione non si torna subito alla pagina: 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.
/** 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 = '';
public ?int $territorio_id = null; public ?int $territorio_id = null;
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
public string $assigned_at = ''; public string $assigned_at = '';
public string $territorioSearch = '';
public string $linkSearch = '';
// Optional pre-selection from parent context // Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null; public ?int $preselectedTerritorioId = null;
@@ -26,6 +47,12 @@ class Assegna extends Component
$this->preselectedTerritorioId = $territorioId; $this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId; $this->territorio_id = $territorioId;
$this->assigned_at = now()->format('Y-m-d'); $this->assigned_at = now()->format('Y-m-d');
// ?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 protected function rules(): array
@@ -43,118 +70,196 @@ class Assegna extends Component
$territorio = Territorio::findOrFail($this->territorio_id); $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) { 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; return;
} }
// Check territory is active
if (!$territorio->attivo) { if (!$territorio->attivo) {
session()->flash('error', "Il territorio {$territorio->numero} è inattivo."); $this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
return; return;
} }
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id); $proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
if (!$proclamatore->attivo) { if (!$proclamatore->attivo) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo."); $this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
return; return;
} }
$assignedDate = \Carbon\Carbon::parse($this->assigned_at); $assignedDate = \Carbon\Carbon::parse($this->assigned_at);
$annoTeocratico = AnnoTeocratico::perData($assignedDate); $annoTeocratico = AnnoTeocratico::perData($assignedDate);
$assegnazione = Assegnazione::create([ // 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, 'territorio_id' => $this->territorio_id,
'proclamatore_id' => $this->proclamatore_id, 'proclamatore_id' => $this->proclamatore_id,
'anno_teocratico_id' => $annoTeocratico->id, 'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate, 'assigned_at' => $assignedDate,
'created_by' => auth()->id(), 'created_by' => auth()->id(),
// C'è un link da mandare solo se il territorio ha il PDF.
'link_da_inviare' => (bool) $territorio->pdf_path,
]); ]);
});
if (is_string($esito)) {
$this->addError('assigned_at', $esito);
return;
}
$assegnazione = $esito;
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->performedOn($assegnazione) ->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([ ->withProperties([
'territorio' => $territorio->numero, 'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo, 'proclamatore_id' => $proclamatore->id,
]) ])
->log('assigned'); ->log('assigned');
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}."); // Non si va avanti: si apre l'invio del link (vedi in cima).
return $this->redirect(route('territori.show', $territorio), navigate: true); $this->apriInvio($assegnazione->id);
} }
public function toggleLinkSent(int $assegnazioneId): void /**
* Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione
* rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé
* e lo chiede con l'evento apri-invio).
*/
#[On('apri-invio')]
public function apriInvio(int $assegnazioneId): void
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly(); $assegnazione = Assegnazione::aperte()->find($assegnazioneId);
if (! $assegnazione) {
return;
} }
#[Computed] $this->invioId = $assegnazione->id;
public function selectedThumbnailUrl(): ?string $this->canaleUsato = null;
$this->erroreInvio = '';
}
/**
* Copia link o Condividi: l'invio vero lo fa chi usa l'app (il
* destinatario lo sceglie lui dai suoi contatti), qui si annota il canale.
*/
public function canale(string $canale): void
{ {
if (!$this->territorio_id) { $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; return null;
} }
$t = Territorio::find($this->territorio_id);
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : 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 nome, numero o email.
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->destinazione(route('assegnazioni.assegna')), navigate: true);
}
private function assegnazioneInInvio(): ?Assegnazione
{
if (! $this->invioId) {
return null;
}
$this->authorize('territori.assign');
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
} }
public function render() public function render()
{ {
$territoriQuery = Territorio::where('attivo', true) // Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte()) // i territori già assegnati, che non si possono scegliere: cercandone
->with(['zona', 'tipologia']); // uno si legge a chi è, invece di un "nessun risultato". La miniatura
// serve all'anteprima, che si aggiorna nel browser.
if (trim($this->territorioSearch) !== '') { $territori = Territorio::attivi()
$term = trim($this->territorioSearch); ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
$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()
->get() ->get()
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome)); ->sortBy('numero', SORT_NATURAL);
// All currently assigned territories with links $opzioniTerritori = $territori
$assegnazioniAperte = Assegnazione::aperte() ->map(fn (Territorio $t) => $t->opzioneScelta(
->with(['territorio.zona', 'proclamatore']) $t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
->get() ) + array_filter(['miniatura' => $t->miniaturaUrl()]))
->sortBy(fn($a) => (int) $a->territorio?->numero); ->values()
->all();
// Ricerca rapida nella lista dei link: numero territorio, zona, $opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
// proclamatore in un solo campo. In memoria perché nome/cognome del
// proclamatore sono cifrati e non filtrabili via SQL. // L'elenco dei territori assegnati sta in ElencoAssegnati.
$assegnazioniAperteVisibili = $assegnazioniAperte; // L'invio in corso; se nel frattempo l'assegnazione è rientrata o
if (trim($this->linkSearch) !== '') { // sparita non c'è più niente da mandare.
$term = mb_strtolower(trim($this->linkSearch)); $invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) { if ($this->invioId && ! $invio) {
$haystack = mb_strtolower( $this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
($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));
return view('livewire.assegnazioni.assegna', [ return view('livewire.assegnazioni.assegna', [
'territoriDisponibili' => $territoriDisponibili, 'invio' => $invio,
'proclamatoriAttivi' => $proclamatoriAttivi, 'opzioniTerritori' => $opzioniTerritori,
'assegnazioniAperte' => $assegnazioniAperte, 'opzioniProclamatori' => $opzioniProclamatori,
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili, 'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
'sogliaRientro' => $sogliaRientro,
]); ]);
} }
} }

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,15 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
class Rientra extends Component class Rientra extends Component
{ {
use RicordaRitorno;
public Assegnazione $assegnazione; public Assegnazione $assegnazione;
public string $returned_at = ''; public string $returned_at = '';
public bool $showCampaignPrompt = false; public bool $showCampaignPrompt = false;
@@ -92,22 +95,43 @@ class Rientra extends Component
->performedOn($this->assegnazione) ->performedOn($this->assegnazione)
->withProperties([ ->withProperties([
'territorio' => $territorio->numero, 'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo, 'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
'giorni' => $this->assegnazione->giorni, 'giorni' => $this->assegnazione->giorni,
'campagna' => $this->counted_in_campaign, 'campagna' => $this->counted_in_campaign,
]) ])
->log('returned'); ->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'
);
}
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni."); 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(route('territori.show', $territorio)), navigate: true);
} }
public function render() public function render()
{ {
$campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null; $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', [ return view('livewire.assegnazioni.rientra', [
'campagna' => $campagna, 'campagna' => $campagna,
'giorniPercorrenza' => $giorniPercorrenza,
'sogliaPercorrenza' => $sogliaPercorrenza,
]); ]);
} }
} }

View File

@@ -48,8 +48,22 @@ class AuditLog extends Component
$events = Activity::select('description')->distinct()->pluck('description'); $events = Activity::select('description')->distinct()->pluck('description');
$users = \App\Models\User::orderBy('name')->get(); $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', [ return view('livewire.audit-log', [
'activities' => $query->paginate(30), 'activities' => $activities,
'nomiProclamatori' => $nomiProclamatori,
'events' => $events, 'events' => $events,
'users' => $users, 'users' => $users,
]); ]);

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Campagna; use App\Models\Campagna;
class CampagnaCreate extends Component class CampagnaCreate extends Component
{ {
use RicordaRitorno;
public string $descrizione = ''; public string $descrizione = '';
public string $start_date = ''; public string $start_date = '';
public string $end_date = ''; public string $end_date = '';
@@ -34,7 +37,7 @@ class CampagnaCreate extends Component
]); ]);
session()->flash('success', "Campagna '{$campagna->descrizione}' creata."); 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() public function render()

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Campagna; use App\Models\Campagna;
class CampagnaEdit extends Component class CampagnaEdit extends Component
{ {
use RicordaRitorno;
public Campagna $campagna; public Campagna $campagna;
public string $descrizione = ''; public string $descrizione = '';
public string $start_date = ''; public string $start_date = '';
@@ -47,7 +50,7 @@ class CampagnaEdit extends Component
]); ]);
session()->flash('success', "Campagna aggiornata."); 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() public function render()

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -9,6 +10,8 @@ use App\Models\Territorio;
class CampagnaShow extends Component class CampagnaShow extends Component
{ {
use RicordaRitorno;
public Campagna $campagna; public Campagna $campagna;
public function mount(Campagna $campagna) public function mount(Campagna $campagna)
@@ -18,16 +21,28 @@ class CampagnaShow extends Component
public function render() public function render()
{ {
// Assignments counted for this campaign: // Le assegnazioni contate per la campagna, cioè quelle per cui al
// - assigned on or after campaign start // rientro si è risposto sì (campagnaApplicabile propone anche quelle
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed) // uscite poco prima dell'inizio e rientrate durante). Le stesse della
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id) // percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
->where('counted_in_campaign', true) // e l'elenco era più corto di quanto la percentuale contasse.
->where('assigned_at', '>=', $this->campagna->start_date) $conteggiate = $this->campagna->assegnazioniConteggiate()
->with(['territorio', 'proclamatore']) ->with(['territorio', 'proclamatore'])
->orderBy('returned_at') ->orderBy('returned_at')
->get(); ->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 // All assignments that were active during this campaign range
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date) $assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
->where(function ($q) { ->where(function ($q) {
@@ -38,6 +53,7 @@ class CampagnaShow extends Component
return view('livewire.campagne.campagna-show', [ return view('livewire.campagne.campagna-show', [
'conteggiate' => $conteggiate, 'conteggiate' => $conteggiate,
'inCorso' => $inCorso,
'assegnateNelRange' => $assegnateNelRange, '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,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; return true;
} }
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha $this->rifiutaPerOccupato($inCorso);
// 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.';
return false; 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(); $operationId = (string) Str::uuid();
$stato = app(BackupOperationState::class); $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.'); $stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
$this->logSpediti = 0; $this->logSpediti = 0;

View File

@@ -2,45 +2,54 @@
namespace App\Livewire\Piantine; namespace App\Livewire\Piantine;
use App\Livewire\Concerns\Avvisa;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\Zona; use App\Models\Zona;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea; use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
/** /**
* Editor delle piantine a schermo intero (solo amministratore). * Editor delle piantine a schermo intero (solo amministratore).
* *
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una * Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo * disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un * La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
* modulo che poi gira di mano in mano. * (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 * Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
* solo dopo si compila la scheda del territorio — che può anche non esistere * aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o * disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
* la tipologia mancano si creano sul posto, senza abbandonare il disegno. *
* 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')] #[Layout('components.layouts.editor')]
class PiantineEditor extends Component 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 ?int $mappaId = null;
public string $nome = ''; public string $nome = '';
/** Geometria appena disegnata, non ancora salvata. */ /** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
public ?string $geometriaInAttesa = null; public string $salvatoAlle = '';
public bool $pannelloAperto = false; // ─── Territorio ─────────────────────────────────────────────────────────
// ─── Scheda del territorio ──────────────────────────────────────────────
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */ /** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
public string $territorioScelto = ''; public string $territorioScelto = '';
@@ -53,15 +62,24 @@ class PiantineEditor extends Component
public string $confini = ''; public string $confini = '';
/** Campi di creazione al volo, aperti solo quando servono. */
public string $nuovaZona = ''; public string $nuovaZona = '';
public string $nuovaTipologia = ''; 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 public function mount(?int $mappa = null): void
{ {
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) { 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); $mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
if (! $mappa) { if (! $mappa) {
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
return; return;
} }
$this->mappaId = $mappa->id; $this->carica($mappa);
$this->nome = (string) $mappa->nome; $this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
$this->geometriaInAttesa = null;
$this->pannelloAperto = true;
$this->caricaScheda($mappa->territorio);
$this->aggiornaMappaJs(inquadra: true);
} }
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
public function nuova(): void 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([ $this->reset([
'mappaId', 'nome', 'geometriaInAttesa', 'mappaId', 'nome', 'salvatoAlle',
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini', 'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
'nuovaZona', 'nuovaTipologia', 'nuovaZona', 'nuovaTipologia',
]); ]);
$this->resetValidation();
$this->pannelloAperto = true;
$this->aggiornaMappaJs();
} }
public function chiudiPannello(): void private function caricaTerritorio(?Territorio $territorio): void
{
$this->pannelloAperto = false;
}
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
private function caricaScheda(?Territorio $territorio): void
{ {
$this->territorioScelto = $territorio ? (string) $territorio->id : ''; $this->territorioScelto = $territorio ? (string) $territorio->id : '';
$this->numero = (string) ($territorio->numero ?? ''); $this->numero = (string) ($territorio->numero ?? '');
@@ -113,12 +139,123 @@ class PiantineEditor extends Component
$this->confini = (string) ($territorio->confini ?? ''); $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 public function updatedTerritorioScelto(string $valore): void
{ {
$this->resetValidation(); $this->resetValidation();
$mappa = $this->mappaAperta();
if (! $mappa) {
return;
}
if ($valore === 'nuovo') { 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->numero = '';
$this->zonaId = ''; $this->zonaId = '';
$this->tipologiaId = ''; $this->tipologiaId = '';
@@ -127,34 +264,158 @@ class PiantineEditor extends Component
return; 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; return;
} }
$this->geometriaInAttesa = json_encode($geometry); $territorio = Territorio::find($valore);
$this->pannelloAperto = true; if (! $territorio) {
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
$this->caricaTerritorio($mappa->territorio);
return;
} }
public function scartaDisegno(): void $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 creaTerritorio(): void
{ {
$this->geometriaInAttesa = null; $mappa = $this->mappaAperta();
$this->aggiornaMappaJs();
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 ────────────────────────────────── // ─── Zona e tipologia create sul posto ──────────────────────────────────
public function creaZona() public function creaZona(): void
{ {
$this->nuovaZona = trim($this->nuovaZona);
$dati = $this->validate( $dati = $this->validate(
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]], ['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->zonaId = (string) $zona->id;
$this->nuovaZona = ''; $this->nuovaZona = '';
$this->updatedZonaId();
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata."); $this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
} }
public function creaTipologia() public function creaTipologia(): void
{ {
$this->nuovaTipologia = trim($this->nuovaTipologia);
$dati = $this->validate( $dati = $this->validate(
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]], ['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->tipologiaId = (string) $tipologia->id;
$this->nuovaTipologia = ''; $this->nuovaTipologia = '';
$this->updatedTipologiaId();
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata."); $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; $mappa = $this->mappaAperta();
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;
if (! $mappa) { if (! $mappa) {
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.'); return;
}
if ($motivo = $mappa->motivoNonPubblicabile()) {
$this->avvisaErrore($motivo);
return; return;
} }
try { $this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
$renderer->render($mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return;
} }
session()->flash('success', "Immagine generata per {$mappa->etichetta()}."); #[On('piantina-pubblicata')]
} public function pubblicata(int $mappaId): void
public function elimina()
{ {
$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) { if (! $mappa) {
return; return;
} }
$etichetta = $mappa->etichetta(); $etichetta = $mappa->etichetta();
$id = $mappa->id;
if ($mappa->image_path) { if ($mappa->image_path) {
Storage::disk('public')->delete($mappa->image_path); Storage::disk('public')->delete($mappa->image_path);
} }
$mappa->delete(); $mappa->delete();
$this->nuova(); $this->svuota();
session()->flash('success', "{$etichetta} eliminata."); $this->dispatch('piantina-rimossa', mappaId: $id);
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
} }
// ─── Dati per la mappa ────────────────────────────────────────────────── // ─── 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 [ return [
'area' => $area->area(), '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, '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', [ return view('livewire.piantine.piantine-editor', [
'mappa' => $mappa, 'mappa' => $mappa,
'areaCaricata' => $area->area() !== null, 'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
'datiMappaIniziali' => $this->datiMappa($area), 'opzioniTerritori' => $opzioniTerritori,
'territori' => Territorio::attivi()
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero']),
'zone' => Zona::orderBy('nome')->get(['id', 'nome']), 'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
'tipologie' => Tipologia::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; namespace App\Livewire\Piantine;
use App\Models\Territorio; use App\Livewire\Concerns\Avvisa;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione; use App\Models\TerritorioPdfVersione;
use App\Services\Piantine\PiantinaPdfService; use App\Services\Piantine\PiantinaPdfService;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea; use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Livewire\WithPagination;
/** /**
* Pagina di gestione delle piantine (solo amministratore). * Pagina di gestione delle piantine (solo amministratore).
* *
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano * È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno * con modifiche da pubblicare), l'area della città e lo storico dei PDF
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su * sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
* una mappa larga un terzo di schermo non si taglia un confine. * da qui si apre, si pubblica (con la stessa finestra di anteprima
* dell'editor) e si elimina.
*/ */
class PiantineManager extends Component class PiantineManager extends Component
{ {
use Avvisa;
use WithFileUploads; 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; public $kmlFile = null;
/** Piantina di cui è aperto il pannello "collega a un territorio". */ public function updatedFiltro(): void
public ?int $collegamentoAperto = null; {
if (! array_key_exists($this->filtro, self::FILTRI)) {
public ?int $territorioDaCollegare = null; $this->filtro = 'tutte';
}
}
// ─── Area città (KML) ─────────────────────────────────────────────────── // ─── Area città (KML) ───────────────────────────────────────────────────
public function caricaKml(PiantineArea $area) public function caricaKml(PiantineArea $area): void
{ {
$this->validate( $this->validate(
['kmlFile' => 'required|file|max:10240'], ['kmlFile' => 'required|file|max:10240'],
@@ -62,111 +84,42 @@ class PiantineManager extends Component
$this->kmlFile = null; $this->kmlFile = null;
activity()->causedBy(auth()->user())->log('piantine_kml_caricato'); 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 { if (! $mappa) {
$renderer->render($mappa); $this->avvisaErrore('La piantina non esiste più.');
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return; return;
} }
session()->flash('success', "Immagine generata per {$mappa->etichetta()}."); if ($motivo = $mappa->motivoNonPubblicabile()) {
} $this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
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.');
return; return;
} }
try { $this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
$pdfService->sostituisci($mappa->territorio, $mappa); }
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
/** 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; 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(); $etichetta = $mappa->etichetta();
if ($mappa->image_path) { if ($mappa->image_path) {
@@ -174,59 +127,106 @@ class PiantineManager extends Component
} }
$mappa->delete(); $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 ─────────────────────────────────────────────────── // ─── 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 ?? '?'; $numero = $versione->territorio?->numero ?? '?';
try { try {
$pdfService->ripristina($versione); $pdfService->ripristina($versione);
} catch (\Throwable $e) { } catch (\Throwable $e) {
session()->flash('error', $e->getMessage()); $this->avvisaErrore($e->getMessage());
return; 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)) { if (! $versione) {
Storage::disk('public')->delete($versione->pdf_path); return;
} }
Storage::disk('public')->delete($versione->pdf_path);
$versione->delete(); $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) 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', [ return view('livewire.piantine.piantine-manager', [
'config' => $area->config(), 'config' => $area->config(),
'piantine' => TerritorioMappa::with('territorio') 'elenco' => $elenco,
->get() 'totale' => $piantine->count(),
->sortBy(fn ($m) => [ 'conteggi' => $conteggi,
$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']),
'versioni' => TerritorioPdfVersione::with('territorio') 'versioni' => TerritorioPdfVersione::with('territorio')
->orderByDesc('sostituito_il') ->orderByDesc('sostituito_il')
->limit(30) ->orderByDesc('id')
->get(), ->paginate(15, pageName: 'versioni'),
'title' => 'Piantine dei territori', '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,84 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting; 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 class Privacy extends Component
{ {
/** Data dell'ultima revisione del testo dell'informativa. */
public const REVISIONE = '19 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',
'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',
'audit.view' => 'consultazione del registro delle attività',
'audit.export' => 'esportazione del registro delle attività',
];
public function render() 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', [ return view('livewire.privacy', [
'congregazione' => $settings->congregazione_nome ?? 'Congregazione', 'revisione' => self::REVISIONE,
'auditRetention' => $settings->audit_retention_days ?? 365, '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(),
'attivita' => Activity::count(),
'attivitaDal' => Activity::min('created_at'),
],
]); ]);
} }
} }

View File

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

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreCreate extends Component class ProclamatoreCreate extends Component
{ {
use RicordaRitorno;
public string $nome = ''; public string $nome = '';
public string $cognome = ''; public string $cognome = '';
public bool $attivo = true; public bool $attivo = true;
@@ -37,7 +40,7 @@ class ProclamatoreCreate extends Component
->log('created'); ->log('created');
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato."); 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() public function render()

View File

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

View File

@@ -2,17 +2,25 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreIndex extends Component class ProclamatoreIndex extends Component
{ {
use Avvisa;
use WithPagination; use WithPagination;
// Ricerca, filtro e ordinamento nell'indirizzo: si ritrovano tornando.
#[Url(except: '')]
public string $search = ''; public string $search = '';
#[Url(except: '')]
public string $filtroStato = ''; public string $filtroStato = '';
#[Url(except: 'cognome')]
public string $sortField = 'cognome'; public string $sortField = 'cognome';
#[Url(except: 'asc')]
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
protected $queryString = [ protected $queryString = [
@@ -59,12 +67,12 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
if ($proclamatore->assegnazioni()->aperte()->exists()) { 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; return;
} }
$proclamatore->delete(); $proclamatore->delete();
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino."); $this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
} }
public function anonimizza(int $id) public function anonimizza(int $id)
@@ -73,7 +81,7 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
$proclamatore->anonimizza(); $proclamatore->anonimizza();
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR)."); $this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
} }
public function render() public function render()

View File

@@ -2,12 +2,15 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
class ProclamatoreShow extends Component class ProclamatoreShow extends Component
{ {
use RicordaRitorno;
public Proclamatore $proclamatore; public Proclamatore $proclamatore;
public function mount(Proclamatore $proclamatore) public function mount(Proclamatore $proclamatore)

View File

@@ -2,11 +2,13 @@
namespace App\Livewire; namespace App\Livewire;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Component; use Livewire\Component;
class Profile extends Component class Profile extends Component
{ {
use Avvisa;
public string $name = ''; public string $name = '';
public string $email = ''; public string $email = '';
@@ -30,10 +32,22 @@ class Profile extends Component
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]); ]);
$user->update([ $user->fill([
'name' => $validated['name'], 'name' => $validated['name'],
'email' => $validated['email'], '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.
if ($emailCambiata) {
$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.'); session()->flash('profile_success', 'Profilo aggiornato con successo.');
@@ -67,7 +81,7 @@ class Profile extends Component
$this->reset(['current_password', 'password', 'password_confirmation']); $this->reset(['current_password', 'password', 'password_confirmation']);
session()->flash('password_success', 'Password aggiornata con successo.'); $this->avvisa('Password aggiornata con successo.');
} }
public function render() public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -11,24 +12,28 @@ use App\Models\Proclamatore;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Zona; use App\Models\Zona;
use App\Models\Tipologia; 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 class Registro extends Component
{ {
use WithPagination; use WithPagination;
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */ // Ricerca, filtri e ordinamento nell'indirizzo: si ritrovano tornando.
private const S13_RIGHE_PER_PAGINA = 21; #[Url(except: '')]
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
public string $search = ''; public string $search = '';
#[Url(except: '')]
public string $filtroAnno = ''; public string $filtroAnno = '';
#[Url(except: '')]
public string $filtroZona = ''; public string $filtroZona = '';
#[Url(except: '')]
public string $filtroTipologia = ''; public string $filtroTipologia = '';
#[Url(except: '')]
public string $filtroStato = ''; // aperte, chiuse public string $filtroStato = ''; // aperte, chiuse
#[Url(except: 'territorio_numero')]
public string $sortField = 'territorio_numero'; public string $sortField = 'territorio_numero';
#[Url(except: 'asc')]
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
// ─── Modal create/edit ────────────────────────────────────── // ─── Modal create/edit ──────────────────────────────────────
@@ -37,7 +42,6 @@ class Registro extends Component
public string $form_territorio_id = ''; public string $form_territorio_id = '';
public string $form_proclamatore_id = ''; public string $form_proclamatore_id = '';
public string $form_anno_id = '';
public string $form_assigned_at = ''; public string $form_assigned_at = '';
public string $form_returned_at = ''; public string $form_returned_at = '';
public bool $form_counted_in_campaign = false; public bool $form_counted_in_campaign = false;
@@ -101,7 +105,6 @@ class Registro extends Component
$this->editingId = null; $this->editingId = null;
$this->form_territorio_id = ''; $this->form_territorio_id = '';
$this->form_proclamatore_id = ''; $this->form_proclamatore_id = '';
$this->form_anno_id = '';
$this->form_assigned_at = now()->format('Y-m-d'); $this->form_assigned_at = now()->format('Y-m-d');
$this->form_returned_at = ''; $this->form_returned_at = '';
$this->form_counted_in_campaign = false; $this->form_counted_in_campaign = false;
@@ -118,7 +121,6 @@ class Registro extends Component
$this->editingId = $id; $this->editingId = $id;
$this->form_territorio_id = (string) $a->territorio_id; $this->form_territorio_id = (string) $a->territorio_id;
$this->form_proclamatore_id = (string) $a->proclamatore_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_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
$this->form_returned_at = $a->returned_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; $this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
@@ -134,28 +136,69 @@ class Registro extends Component
$this->validate([ $this->validate([
'form_territorio_id' => 'required|exists:territori,id', 'form_territorio_id' => 'required|exists:territori,id',
'form_proclamatore_id' => 'required|exists:proclamatori,id', 'form_proclamatore_id' => 'required|exists:proclamatori,id',
'form_anno_id' => 'required|exists:anni_teocratici,id', 'form_assigned_at' => 'required|date|before_or_equal:today',
'form_assigned_at' => 'required|date', 'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
'form_campaign_id' => 'nullable|exists:campagne,id', '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 = [ $data = [
'territorio_id' => $this->form_territorio_id, 'territorio_id' => $this->form_territorio_id,
'proclamatore_id' => $this->form_proclamatore_id, 'proclamatore_id' => $this->form_proclamatore_id,
'anno_teocratico_id' => $this->form_anno_id, 'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
'assigned_at' => $this->form_assigned_at, 'assigned_at' => $uscita,
'returned_at' => $this->form_returned_at ?: null, 'returned_at' => $rientro,
'counted_in_campaign' => $this->form_counted_in_campaign, // "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, 'campaign_id' => $this->form_campaign_id ?: null,
'note' => $this->form_note ?: null, 'note' => $this->form_note ?: null,
]; ];
$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) { if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data); $assegnazione = Assegnazione::findOrFail($this->editingId);
$prima = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
$assegnazione->update($data);
} else { } else {
$data['created_by'] = auth()->id(); $assegnazione = Assegnazione::create($data + ['created_by' => auth()->id()]);
Assegnazione::create($data); }
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; $this->showModal = false;
@@ -199,16 +242,15 @@ class Registro extends Component
} }
/** /**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del * Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
* territorio") compilato con le assegnazioni dell'anno di servizio scelto * modale; le regole di compilazione stanno in ModuloS13.
* nella modale.
* *
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo * 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 * espone in un unico file l'anagrafica completa di chi ha avuto quale
* territorio, dati personali che il ruolo assegnatario non deve poter * territorio, dati personali che il ruolo assegnatario non deve poter
* esportare in blocco. * esportare in blocco.
*/ */
public function downloadS13() public function downloadS13(ModuloS13 $modulo)
{ {
abort_if(!auth()->user()->can('settings.manage'), 403); abort_if(!auth()->user()->can('settings.manage'), 403);
@@ -224,134 +266,15 @@ class Registro extends Component
$this->showS13Modal = false; $this->showS13Modal = false;
$pdf = Pdf::loadView('pdf.registro-s13', [ $pdf = $modulo->genera($anno);
'annoLabel' => $anno->label,
'pagine' => $this->impaginaS13($this->righeS13($anno)),
])->setPaper('a4', 'portrait');
return response()->streamDownload( return response()->streamDownload(
fn () => print($pdf->output()), fn () => print($pdf),
'S-13-registro-assegnazioni-' . $anno->label . '.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 public function mount(): void
{ {
if ($this->filtroAnno === '') { if ($this->filtroAnno === '') {
@@ -425,8 +348,14 @@ class Registro extends Component
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(), 'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
'zone' => Zona::attive()->orderBy('nome')->get(), 'zone' => Zona::attive()->orderBy('nome')->get(),
'tipologie' => Tipologia::orderBy('nome')->get(), 'tipologie' => Tipologia::orderBy('nome')->get(),
'territori' => Territorio::attivi()->orderBy('numero')->get(), // Campi di scelta con ricerca della finestra di modifica. Ci sono
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(), // 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(), 'campagne' => Campagna::orderByDesc('created_at')->get(),
]); ]);
} }

View File

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

View File

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

View File

@@ -2,21 +2,27 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
class UsersIndex extends Component class UsersIndex extends Component
{ {
use Avvisa;
public string $name = ''; public string $name = '';
public string $email = ''; public string $email = '';
public string $password = ''; public string $password = '';
public string $password_confirmation = ''; public string $password_confirmation = '';
public string $selectedRole = ''; 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 = []; public array $availableRoles = [];
public ?int $editingUserId = null; public ?int $editingUserId = null;
@@ -79,7 +85,30 @@ class UsersIndex extends Component
if (! empty($this->availableRoles)) { if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0]; $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.');
}
} }
public function startEdit(int $userId): void public function startEdit(int $userId): void
@@ -115,6 +144,23 @@ class UsersIndex extends Component
$validated = $this->validate($this->editRules()); $validated = $this->validate($this->editRules());
$user = User::query()->findOrFail($this->editingUserId); $user = User::query()->findOrFail($this->editingUserId);
// 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->name = $validated['editName'];
$user->email = $validated['editEmail']; $user->email = $validated['editEmail'];
@@ -122,11 +168,25 @@ class UsersIndex extends Component
$user->password = $validated['editPassword']; $user->password = $validated['editPassword'];
} }
$emailCambiata = $user->isDirty('email');
$user->save(); $user->save();
$user->syncRoles([$validated['editSelectedRole']]); $user->syncRoles([$validated['editSelectedRole']]);
$this->cancelEdit(); $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 public function deleteUser(int $userId): void
@@ -135,12 +195,12 @@ class UsersIndex extends Component
$user = User::query()->with('roles')->findOrFail($userId); $user = User::query()->with('roles')->findOrFail($userId);
if (! $currentUser || $currentUser->id === $user->id) { if (! $currentUser || $currentUser->id === $user->id) {
session()->flash('error', 'Non puoi cancellare il tuo utente.'); $this->avvisaErrore('Non puoi cancellare il tuo utente.');
return; return;
} }
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) { 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; return;
} }
@@ -180,7 +240,7 @@ class UsersIndex extends Component
$this->cancelEdit(); $this->cancelEdit();
} }
session()->flash('success', 'Utente cancellato. I log sono stati preservati.'); $this->avvisa('Utente cancellato. I log sono stati preservati.');
} }
public function render() public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Jobs\ImportTerritoryPdfFolder; use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -9,20 +10,27 @@ use App\Models\Campagna;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\User; use App\Models\User;
use App\Models\Zona; use App\Models\Zona;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher; use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState; use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
class XmlExchange extends Component class XmlExchange extends Component
{ {
use Avvisa;
use WithFileUploads; use WithFileUploads;
public array $importStats = []; public array $importStats = [];
@@ -52,23 +60,113 @@ 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
{
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
}
/**
* 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();
if ($inCorso === null) {
return true;
}
$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([ $this->validate([
'pdfFolder' => ['required', 'array', 'min:1'], 'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]); ]);
$importId = (string) Str::uuid(); $importId = (string) Str::uuid();
$storedFiles = []; $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) { foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName(); $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)); $safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs( $storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId, 'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension, str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
'local' 'local'
); );
@@ -77,14 +175,34 @@ class XmlExchange extends Component
'stored_path' => $storedPath, 'stored_path' => $storedPath,
]; ];
} }
} catch (\Throwable $e) {
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
$this->pdfFolder = []; 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( $this->dispatchPdfImport(
$importId, $importId,
$storedFiles, $storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.' 'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
$ignorati,
$scartati
); );
return true;
} }
public function refreshPdfImportStatus(): void public function refreshPdfImportStatus(): void
@@ -114,8 +232,9 @@ class XmlExchange extends Component
* contenuti non possono essere ri-renderizzati dal server senza cancellare * contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso * le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita * meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e * perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
* non partecipa al posto unico: puo' girare mentre un backup lavora. * 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 protected function spedisciImportAllaConsole(): void
{ {
@@ -156,10 +275,10 @@ class XmlExchange extends Component
return in_array($this->pdfImportStatus, ['queued', 'running'], true); 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) $state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog); ->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
$this->currentPdfImportId = $importId; $this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued'; $this->pdfImportStatus = $state['status'] ?? 'queued';
@@ -172,13 +291,13 @@ class XmlExchange extends Component
$this->logPdfSpediti = 0; $this->logPdfSpediti = 0;
$this->refreshPdfImportStatus(); $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() public function downloadImportLogPdf()
{ {
if (empty($this->importStats)) { 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; return;
} }
@@ -236,16 +355,99 @@ class XmlExchange extends Component
return $this->datasetToXml($dataset, $source); 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 public function importXmlFromContent(string $content): array
{ {
$this->importStats = []; $this->importStats = [];
$this->importIssues = []; $this->importIssues = [];
$xml = @simplexml_load_string($content); $xml = $this->leggiXml($content);
if (! $xml) { if (is_string($xml)) {
return ['error' => 'Impossibile leggere il file 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'); $actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) { if (! $actorId) {
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.']; return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
@@ -264,7 +466,8 @@ class XmlExchange extends Component
'assegnazioni_saltate' => 0, 'assegnazioni_saltate' => 0,
]; ];
DB::transaction(function () use ($xml, $actorId, &$stats) { try {
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
Assegnazione::query()->delete(); Assegnazione::query()->delete();
Campagna::query()->delete(); Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete(); Territorio::query()->withTrashed()->forceDelete();
@@ -272,6 +475,13 @@ class XmlExchange extends Component
Tipologia::query()->delete(); Tipologia::query()->delete();
Zona::query()->delete(); Zona::query()->delete();
AnnoTeocratico::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(); Setting::query()->delete();
// The mass delete above fires no model events, so Setting's // The mass delete above fires no model events, so Setting's
@@ -299,6 +509,10 @@ class XmlExchange extends Component
]); ]);
} }
if ($posta !== []) {
Setting::instance()->update($posta);
}
// Users are UPSERTED by email, never wiped. Deleting the users // Users are UPSERTED by email, never wiped. Deleting the users
// table during a restore risks locking out the administrator // table during a restore risks locking out the administrator
// performing the import (e.g. a backup that predates their account, // performing the import (e.g. a backup that predates their account,
@@ -334,6 +548,10 @@ class XmlExchange extends Component
$roleNames[] = $roleName; $roleNames[] = $roleName;
} }
} }
$eAttore = $attore !== null && $user->id === $attore->id;
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
$roleNames[] = 'amministratore';
}
$user->syncRoles($roleNames); $user->syncRoles($roleNames);
// Password hash, remember_token, verification flag and // Password hash, remember_token, verification flag and
@@ -341,13 +559,23 @@ class XmlExchange extends Component
// hash is preserved exactly (the 'hashed' cast would re-validate // hash is preserved exactly (the 'hashed' cast would re-validate
// the bcrypt cost and could reject a hash made with a different // the bcrypt cost and could reject a hash made with a different
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps. // 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 = []; $meta = [];
$passwordHash = (string) ($userNode->password ?? ''); $passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '') { if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash; $meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
}
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 ?? '')); $meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? '')); $createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
@@ -401,6 +629,8 @@ class XmlExchange extends Component
'nome' => (string) $proclamatoreNode->nome, 'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome, 'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, '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); $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++; $stats['proclamatori_importati']++;
@@ -415,8 +645,8 @@ class XmlExchange extends Component
$label = (string) $annoNode->label; $label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([ $anno = AnnoTeocratico::create([
'label' => $label, 'label' => $label,
'start_date' => (string) $annoNode->start_date, 'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
'end_date' => (string) $annoNode->end_date, 'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
]); ]);
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode); $this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
$stats['anni_importati']++; $stats['anni_importati']++;
@@ -430,8 +660,8 @@ class XmlExchange extends Component
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) { foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
$campagna = Campagna::create([ $campagna = Campagna::create([
'descrizione' => (string) $campagnaNode->descrizione, 'descrizione' => (string) $campagnaNode->descrizione,
'start_date' => (string) $campagnaNode->start_date, 'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
'end_date' => (string) $campagnaNode->end_date, 'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
]); ]);
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode); $this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
$stats['campagne_importate']++; $stats['campagne_importate']++;
@@ -455,9 +685,14 @@ class XmlExchange extends Component
continue; continue;
} }
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) { // Zona e tipologia sono facoltative (le colonne sono nullable e
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')'); // un territorio resta senza quando la sua zona viene
continue; // 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 ?? ''); $pdfPath = (string) ($territorioNode->pdf_path ?? '');
@@ -465,8 +700,8 @@ class XmlExchange extends Component
$territorio = Territorio::create([ $territorio = Territorio::create([
'numero' => $numero, 'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId], 'zona_id' => $zoneMap[$legacyZonaId] ?? null,
'tipologia_id' => $tipologiaMap[$legacyTipologiaId], 'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
'confini' => (string) ($territorioNode->confini ?? ''), 'confini' => (string) ($territorioNode->confini ?? ''),
'note' => (string) ($territorioNode->note ?? ''), 'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
@@ -532,6 +767,8 @@ class XmlExchange extends Component
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1, 'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null, 'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1, 'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'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, 'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''), 'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $createdBy, 'created_by' => $createdBy,
@@ -541,6 +778,14 @@ class XmlExchange extends Component
$stats['assegnazioni_importate']++; $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 [ return [
'stats' => $stats, 'stats' => $stats,
@@ -576,21 +821,81 @@ class XmlExchange extends Component
'email' => (string) $u->email, 'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(), 'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '', '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(), 'roles' => $u->roles->pluck('name')->all(),
'created_at' => optional($u->created_at)->toIso8601String() ?? '', 'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '', 'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(), ])->all(),
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(), 'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(), 'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(), 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
'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(), 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(), 'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(), 'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'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(), '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', '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 private function legacySqlToDataset(string $sql): array
{ {
$rows = $this->extractInsertRows($sql); $rows = $this->extractInsertRows($sql);
@@ -880,8 +1185,12 @@ class XmlExchange extends Component
return $nullable ? null : null; 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 { try {
return Carbon::parse($raw)->toDateString(); return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return $nullable ? null : null; return $nullable ? null : null;
} }
@@ -923,7 +1232,6 @@ class XmlExchange extends Component
$this->addXmlText($node, 'email', (string) ($user['email'] ?? '')); $this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? '')); $this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? '')); $node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? '')); $node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? '')); $node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles'); $rolesNode = $node->addChild('roles');
@@ -1012,6 +1320,8 @@ class XmlExchange extends Component
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? '')); $node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0))); $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_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? '')); $this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
$this->addTimestampNodes($node, $assegnazione); $this->addTimestampNodes($node, $assegnazione);
@@ -1022,6 +1332,13 @@ class XmlExchange extends Component
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement 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); $child = $parent->addChild($name);
$dom = dom_import_simplexml($child); $dom = dom_import_simplexml($child);
$dom->textContent = $value; $dom->textContent = $value;
@@ -1062,7 +1379,7 @@ class XmlExchange extends Component
} }
try { try {
return Carbon::parse($raw)->toDateTimeString(); return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return null; return null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Territorio; use App\Models\Territorio;
@@ -11,16 +13,28 @@ use App\Models\Tipologia;
class TerritorioIndex extends Component class TerritorioIndex extends Component
{ {
use Avvisa;
use WithPagination; 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 = ''; public string $search = '';
#[Url(except: '')]
public string $filterZona = ''; public string $filterZona = '';
#[Url(except: '')]
public string $filterTipologia = ''; public string $filterTipologia = '';
#[Url(except: '')]
public string $filterStato = ''; public string $filterStato = '';
#[Url(except: '')]
public string $filterPriorita = ''; public string $filterPriorita = '';
#[Url(except: '')]
public string $filterPdf = ''; public string $filterPdf = '';
#[Url(except: '')]
public string $filterContenuti = ''; public string $filterContenuti = '';
#[Url(except: 'numero')]
public string $sortField = 'numero'; public string $sortField = 'numero';
#[Url(except: 'asc')]
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
protected $queryString = [ protected $queryString = [
@@ -127,7 +141,7 @@ class TerritorioIndex extends Component
->performedOn($territorio) ->performedOn($territorio)
->log('soft_deleted'); ->log('soft_deleted');
$territorio->delete(); $territorio->delete();
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino."); $this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
} }
public function render() public function render()

View File

@@ -2,12 +2,15 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Assegnazione; use App\Models\Assegnazione;
class TerritorioShow extends Component class TerritorioShow extends Component
{ {
use RicordaRitorno;
public Territorio $territorio; public Territorio $territorio;
public function mount(Territorio $territorio) public function mount(Territorio $territorio)

View File

@@ -20,6 +20,8 @@ class Assegnazione extends Model
'campaign_id', 'campaign_id',
'pdf_access_code', 'pdf_access_code',
'link_sent', 'link_sent',
'link_da_inviare',
'link_canale',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
@@ -32,6 +34,7 @@ class Assegnazione extends Model
'returned_at' => 'date', 'returned_at' => 'date',
'counted_in_campaign' => 'boolean', 'counted_in_campaign' => 'boolean',
'link_sent' => 'boolean', 'link_sent' => 'boolean',
'link_da_inviare' => 'boolean',
]; ];
} }
@@ -55,6 +58,44 @@ class Assegnazione extends Model
}); });
} }
/**
* 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 ───────────────────────────────────────── // ─── Relationships ─────────────────────────────────────────
public function territorio() public function territorio()
@@ -95,7 +136,8 @@ class Assegnazione extends Model
public function getGiorniAttribute(): int public function getGiorniAttribute(): int
{ {
$end = $this->returned_at ?? now(); $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 public function getIsApertaAttribute(): bool
@@ -173,9 +215,56 @@ class Assegnazione extends Model
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]); 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 ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────
@@ -197,16 +286,69 @@ class Assegnazione extends Model
// ─── Business Logic ───────────────────────────────────────── // ─── 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. * Check if a campaign prompt should be shown when returning this assignment.
* Returns the matching campaign or null. * Returns the matching campaign or null.
*/ */
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna 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) return Campagna::where('start_date', '<=', $returnDate)
->where('end_date', '>=', $this->assigned_at) ->where('end_date', '>=', $assignedAt)
->orderByDesc('start_date')
->first(); ->first();
} }
} }

View File

@@ -46,8 +46,13 @@ class Campagna extends Model
*/ */
public static function attiva(): ?static public static function attiva(): ?static
{ {
return static::where('start_date', '<=', now()) // Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
->where('end_date', '>=', now()) // 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(); ->first();
} }
@@ -60,6 +65,16 @@ class Campagna extends Model
->where('counted_in_campaign', true); ->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. * All assignments with assigned_at in this campaign's range.
*/ */
@@ -70,9 +85,11 @@ class Campagna extends Model
} }
/** /**
* Campaign coverage percentage. * Quota dei territori attivi percorsi durante la campagna.
* Numerator: assignments counted for campaign *
* Denominator: total active territories * 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 public function getPercentualePercorrenzaAttribute(): float
{ {
@@ -82,13 +99,17 @@ class Campagna extends Model
return 0.0; 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) 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

@@ -13,6 +13,9 @@ class Proclamatore extends Model
protected $table = 'proclamatori'; protected $table = 'proclamatori';
// 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']; protected $fillable = ['nome', 'cognome', 'attivo'];
protected function casts(): array protected function casts(): array
@@ -40,6 +43,34 @@ class Proclamatore extends Model
return trim($this->cognome . ' ' . $this->nome); 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
{
return array_filter([
'valore' => (string) $this->id,
'etichetta' => $this->nome_completo,
'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.
*/
public static function opzioniScelta(iterable $proclamatori): array
{
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();
}
public function assegnazioni() public function assegnazioni()
{ {
return $this->hasMany(Assegnazione::class, 'proclamatore_id'); return $this->hasMany(Assegnazione::class, 'proclamatore_id');

View File

@@ -20,8 +20,31 @@ class Setting extends Model
'pdf_viewer_show_download', 'pdf_viewer_show_download',
'audit_retention_days', 'audit_retention_days',
'setup_completed', '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 protected function casts(): array
{ {
return [ return [
@@ -33,6 +56,8 @@ class Setting extends Model
'home_limit_list' => 'integer', 'home_limit_list' => 'integer',
'assignment_link_ttl_hours' => 'integer', 'assignment_link_ttl_hours' => 'integer',
'audit_retention_days' => 'integer', 'audit_retention_days' => 'integer',
'mail_port' => 'integer',
'mail_password' => 'encrypted',
]; ];
} }

View File

@@ -61,9 +61,13 @@ class Territorio extends Model
public function assegnazioneCorrente() 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') return $this->hasOne(Assegnazione::class, 'territorio_id')
->whereNull('returned_at') ->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
->latestOfMany('assigned_at');
} }
public function ultimaAssegnazione() public function ultimaAssegnazione()
@@ -141,6 +145,24 @@ class Territorio extends Model
return false; return false;
} }
/**
* 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 ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────
public function scopeAttivi($query) public function scopeAttivi($query)
@@ -216,4 +238,24 @@ 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;
}
} }

View File

@@ -11,15 +11,30 @@ use Illuminate\Database\Eloquent\Model;
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il * cartografia, il numero si decide dopo. Finche' resta scollegato vale il
* campo `nome` come etichetta; una volta collegato comanda il numero del * campo `nome` come etichetta; una volta collegato comanda il numero del
* territorio, che e' il riferimento con cui lo chiamano tutti. * 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 class TerritorioMappa extends Model
{ {
public const STATO_BOZZA = 'bozza';
public const STATO_PUBBLICATA = 'pubblicata';
public const STATO_MODIFICATA = 'modificata';
protected $table = 'territorio_mappe'; protected $table = 'territorio_mappe';
protected $fillable = [ protected $fillable = [
'territorio_id', 'territorio_id',
'nome', 'nome',
'geojson', 'geojson',
'geojson_pubblicato',
'impronta_pubblicata',
'pdf_pubblicato',
'pubblicata_at',
'image_path', 'image_path',
'rendered_at', 'rendered_at',
]; ];
@@ -28,12 +43,19 @@ class TerritorioMappa extends Model
{ {
return [ return [
'rendered_at' => 'datetime', '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() 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 public function geometria(): ?array
{ {
$decoded = json_decode($this->geojson, true); $decoded = json_decode((string) $this->geojson, true);
return is_array($decoded) ? $decoded : null; 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 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 public function etichetta(): string
{ {
if ($this->collegata()) { if ($this->territorio !== null) {
return 'Terr. ' . $this->territorio->numero; return 'Terr. ' . $this->territorio->numero;
} }
@@ -68,4 +97,93 @@ class TerritorioMappa extends Model
return $nome !== '' ? $nome : 'Piantina #' . $this->id; 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,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@@ -9,7 +10,12 @@ use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
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; use HasFactory, Notifiable, HasRoles, LogsActivity;
@@ -41,4 +47,32 @@ class User extends Authenticatable
->logOnlyDirty() ->logOnlyDirty()
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
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
{
try {
$this->sendEmailVerificationNotification();
} catch (\Throwable $e) {
report($e);
return false;
}
return true;
}
} }

View File

@@ -2,8 +2,14 @@
namespace App\Providers; 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\ServiceProvider;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Livewire\Livewire; use Livewire\Livewire;
use RuntimeException; use RuntimeException;
@@ -17,6 +23,48 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void 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. // Pin generated URLs to APP_URL.
// //
// Behind a trusted proxy, Symfony derives the port from // 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) * manifest.json formato, data, conteggi (letto per validare il file)
* data.xml lo stesso XML dell'export "Esporta XML" * 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 * storage/<percorso> ogni file del disco `public`, con lo stesso percorso
* relativo che sta in pdf_path/thumbnail_path/logo_path * 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 class BackupArchive
{ {
@@ -29,11 +36,23 @@ class BackupArchive
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di * Versione del formato. L'import rifiuta i pacchetti piu' recenti di
* quello che sa leggere invece di ripristinarli a meta'. * 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_MANIFEST = 'manifest.json';
private const ENTRY_DATA = 'data.xml'; private const ENTRY_DATA = 'data.xml';
private const ENTRY_PIANTINE = 'piantine.json';
private const PREFIX_STORAGE = 'storage/'; 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 * 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. * Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
* *
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto). * @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 * @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'); $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); $totale = count($files);
$cartella = dirname($destinazione); $cartella = dirname($destinazione);
@@ -84,12 +123,14 @@ class BackupArchive
} }
$zip->addFromString(self::ENTRY_DATA, $xml); $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([ $zip->addFromString(self::ENTRY_MANIFEST, json_encode([
'format_version' => self::FORMAT_VERSION, 'format_version' => self::FORMAT_VERSION,
'application' => 'TerManager2', 'application' => 'TerManager2',
'generated_at' => now()->toIso8601String(), 'generated_at' => now()->toIso8601String(),
'app_url' => (string) config('app.url'), 'app_url' => (string) config('app.url'),
'files_count' => $totale, 'files_count' => $filePubblici,
'piantine_files_count' => $filePiantine,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
try { try {
@@ -110,12 +151,12 @@ class BackupArchive
try { try {
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use ( $this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
$disk, $files, $totale, $perLotto, $onProgress, $destinazione $files, $totale, $perLotto, $onProgress, $destinazione
) { ) {
$nelLotto = 0; $nelLotto = 0;
$fatti = 0; $fatti = 0;
foreach ($files as $relativePath) { foreach ($files as [$relativePath, $sorgente, $entry]) {
// addFile() legge dal disco durante close(): i PDF non // addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo. // passano mai interi per la memoria del processo.
// //
@@ -133,7 +174,7 @@ class BackupArchive
// raggiunto mai, e l'amministratore leggerebbe la stringa // raggiunto mai, e l'amministratore leggerebbe la stringa
// grezza di PHP. Stesso problema, e stessa scelta, del // grezza di PHP. Stesso problema, e stessa scelta, del
// close() avvolto in chiudi(). // close() avvolto in chiudi().
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) { if (! @$zip->addFile($sorgente, $entry)) {
throw new RuntimeException( throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: ' 'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.' . 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
@@ -157,7 +198,7 @@ class BackupArchive
} }
try { try {
$this->verificaArchivio($destinazione, $totale); $this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@unlink($destinazione); @unlink($destinazione);
@@ -178,7 +219,7 @@ class BackupArchive
* non dei contenuti — quindi e' un controllo a buon mercato che copre * non dei contenuti — quindi e' un controllo a buon mercato che copre
* troncamenti, chiusure a meta' e lotti persi. * 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(); $zip = new ZipArchive();
@@ -187,27 +228,31 @@ class BackupArchive
} }
$trovati = 0; $trovati = 0;
$piantineTrovati = 0;
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i); $nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) { if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
$trovati++; $trovati++;
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
$piantineTrovati++;
} }
} }
$haDati = $zip->locateName(self::ENTRY_DATA) !== false; $haDati = $zip->locateName(self::ENTRY_DATA) !== false;
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false; $haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
$zip->close(); $zip->close();
if (! $haDati || ! $haManifest) { if (! $haDati || ! $haManifest || ! $haPiantine) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.'); 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( throw new RuntimeException(sprintf(
'Il backup appena creato contiene %d file invece di %d: archivio scartato.', 'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
$trovati, $trovati + $piantineTrovati,
$fileAttesi $fileAttesi + $piantineAttesi
)); ));
} }
} }
@@ -294,8 +339,15 @@ class BackupArchive
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i * l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
* dati correnti sono ancora intatti. * 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 * @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 public function extract(string $zipPath, ?callable $onProgress = null): array
{ {
@@ -334,12 +386,35 @@ class BackupArchive
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.'); 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) { if ($zip->numFiles > self::MAX_ENTRIES) {
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.'); throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
} }
$totalBytes = 0; $totalBytes = 0;
$extracted = 0; $extracted = 0;
$estrattiPiantine = 0;
// Il totale si conta scorrendo i nomi, non come "numFiles meno le // Il totale si conta scorrendo i nomi, non come "numFiles meno le
// due entry di servizio": un archivio con una entry in piu' (una // due entry di servizio": un archivio con una entry in piu' (una
@@ -348,7 +423,7 @@ class BackupArchive
$daEstrarre = 0; $daEstrarre = 0;
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($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++; $daEstrarre++;
} }
} }
@@ -361,11 +436,12 @@ class BackupArchive
continue; continue;
} }
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) { $prefisso = $this->prefissoDi($entryName, $prefissi);
if ($prefisso === null) {
continue; continue;
} }
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE)); $relativePath = substr($entryName, strlen($prefisso));
$safePath = $this->safeRelativePath($relativePath); $safePath = $this->safeRelativePath($relativePath);
if ($safePath === null) { if ($safePath === null) {
@@ -375,7 +451,8 @@ class BackupArchive
// La dimensione dichiarata si legge *prima* di estrarre: e' // La dimensione dichiarata si legge *prima* di estrarre: e'
// l'unico momento in cui si puo' rifiutare un'entry che, una // l'unico momento in cui si puo' rifiutare un'entry che, una
// volta decompressa, riempirebbe il disco. // 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) { if ($size > self::MAX_ENTRY_BYTES) {
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(
@@ -390,7 +467,7 @@ class BackupArchive
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.'); throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
} }
$destination = $staging . '/' . $safePath; $destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
$directory = dirname($destination); $directory = dirname($destination);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { 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.'); 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($stream);
fclose($target); $chiuso = fclose($target);
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++; $extracted++;
$onProgress && $onProgress($extracted, $daEstrarre); }
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$zip->close(); $zip->close();
@@ -425,63 +530,180 @@ class BackupArchive
$zip->close(); $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 * La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
* righe del database, che il ripristino ha appena riscritto per intero. * righe del database, che il ripristino ha appena riscritto per intero.
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati * Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge. * 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)) { foreach ($this->vociDi($radice) as $voce) {
return 0; $this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
} }
$restored = 0; // Una sottocartella assente nello staging vuol dire "nessun
$totale = iterator_count(new \RecursiveIteratorIterator( // file": la radice resta vuota, come nell'installazione
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) // salvata.
)); $nuovi = $estratto['staging'] . '/' . $nome;
$onProgress && $onProgress(0, $totale); foreach ($this->vociDi($nuovi) as $voce) {
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
}
}
} catch (\Throwable $e) {
$this->annullaScambio($scambio, $e);
$iterator = new \RecursiveIteratorIterator( throw $e;
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) }
return $scambio;
}
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
public function confermaScambio(array $scambio): void
{
$this->deleteDirectory($scambio['accantonati']);
}
/**
* 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 . '"');
}
}
} 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
); );
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
} }
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)); $this->deleteDirectory($scambio['accantonati']);
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
continue;
} }
$disk->writeStream($relativePath, $handle); /**
* 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 (is_resource($handle)) { if ($formato >= 2) {
fclose($handle); $radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
} }
$restored++; return $radici;
$onProgress && $onProgress($restored, $totale);
} }
return $restored; /** @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; namespace App\Services;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
/** /**
@@ -23,6 +24,18 @@ class BackupOperationState
public const TIPO_RESTORE = 'restore'; public const TIPO_RESTORE = 'restore';
public const TIPO_RESET = 'reset'; 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. */ /** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
public const FASE_ATTESA_DOWNLOAD = 'attesa-download'; public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
@@ -48,6 +61,16 @@ class BackupOperationState
private const CHIAVE_CORRENTE = 'backup-op:corrente'; private const CHIAVE_CORRENTE = 'backup-op:corrente';
private const TTL_CORRENTE = 3600; 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 public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{ {
$state = [ $state = [
@@ -89,16 +112,78 @@ class BackupOperationState
$stato = $this->get($id); $stato = $this->get($id);
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto // 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)) { if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null; return null;
} }
return $id; 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 public function get(string $id): ?array
{ {
return Cache::get($this->key($id)); return Cache::get($this->key($id));
@@ -213,9 +298,18 @@ class BackupOperationState
*/ */
private function liberaCorrente(string $id): void private function liberaCorrente(string $id): void
{ {
// 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) { if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE); 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

@@ -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 ($immagine === null) {
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) { 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.'); 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) { if ($dimensioni === false) {
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.'); throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
} }
@@ -92,7 +101,7 @@ class PiantinaPdfService
($h - $imgH) / 2, ($h - $imgH) / 2,
$imgW, $imgW,
$imgH, $imgH,
$this->tipoImmagine($mappa->image_path) $this->tipoImmagine($immagine)
); );
return $pdf->Output('S'); return $pdf->Output('S');
@@ -179,13 +188,26 @@ class PiantinaPdfService
*/ */
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void 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'); $disk = Storage::disk('public');
$this->archiviaCorrente($territorio, $origine);
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf'; $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([ $territorio->update([
'pdf_path' => $nuovoPath, 'pdf_path' => $nuovoPath,
@@ -235,8 +257,11 @@ class PiantinaPdfService
/** /**
* Sposta il PDF attuale del territorio (se esiste) nello storico. * 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'); $disk = Storage::disk('public');

View File

@@ -3,6 +3,7 @@
namespace App\Services\Piantine; namespace App\Services\Piantine;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use RuntimeException; use RuntimeException;
@@ -14,6 +15,11 @@ use RuntimeException;
*/ */
class PiantinaRenderer class PiantinaRenderer
{ {
/**
* Rendering e archiviazione come immagine della piantina.
*
* @return string il percorso sul disco pubblico
*/
public function render(TerritorioMappa $mappa): string public function render(TerritorioMappa $mappa): string
{ {
$geometry = $mappa->geometria(); $geometry = $mappa->geometria();
@@ -22,8 +28,22 @@ class PiantinaRenderer
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.'); 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'; $formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
try {
$response = Http::timeout((int) config('piantine.renderer_timeout')) $response = Http::timeout((int) config('piantine.renderer_timeout'))
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [ ->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
'geometry' => $geometry, 'geometry' => $geometry,
@@ -35,6 +55,11 @@ class PiantinaRenderer
'format' => $formato, 'format' => $formato,
'quality' => (int) config('piantine.qualita'), '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()) { if (! $response->successful()) {
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status()); $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.'); 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 return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
// 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);
} }
$path = $this->percorso($mappa, $formato); /**
Storage::disk('public')->put($path, $immagine); * 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.');
}
if ($mappa->image_path && $mappa->image_path !== $path) {
$disco->delete($mappa->image_path);
}
$mappa->update([ $mappa->update([
'image_path' => $path, 'image_path' => $path,
@@ -65,11 +103,6 @@ class PiantinaRenderer
return $path; 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. */ /** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
private function immagineValida(string $dati, string $formato): bool 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 * attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
* restano al loro posto. * restano al loro posto.
* *
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo * Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non * impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal * ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
* KML originale — che quindi conviene conservare fuori di qui. * contiene, e ripristinandolo l'area in uso resta com'e'.
*/ */
class PiantineArea 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

@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
public function __construct( public function __construct(
protected TerritorioPdfImportState $stateService, 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)); $state = $this->stateService->initialize($importId, count($storedFiles));
$this->stateService->appendLog($importId, $initialLog); $this->stateService->appendLog($importId, $initialLog);
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); 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; return $this->stateService->get($importId) ?? $state;
} }
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
{ {
$importId = (string) Str::uuid(); $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( $zipStoredPath = $zipFile->storeAs(
'bulk-territori-imports/' . $importId, 'bulk-territori-imports/' . $importId,
'archivio-' . $importId . '.zip', 'archivio-' . $importId . '.zip',
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open($zipAbsolutePath) !== true) { if ($zip->open($zipAbsolutePath) !== true) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Impossibile aprire il file ZIP.'); throw new RuntimeException('Impossibile aprire il file ZIP.');
} }
$storedFiles = []; $storedFiles = [];
$ignorati = [];
$entryIndex = 0; $entryIndex = 0;
try { try {
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
continue; continue;
} }
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') { if (self::daIgnorare($entryName)) {
$ignorati[] = basename($entryName);
continue; continue;
} }
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
} }
if ($storedFiles === []) { if ($storedFiles === []) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Lo ZIP non contiene file PDF validi.'); throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
} }
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
$importId, $importId,
$storedFiles, $storedFiles,
$actorId, $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; 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', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->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 // X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
// set an arbitrary Host and have route()/url() emit links pointing at // set an arbitrary Host and have route()/url() emit links pointing at
// an attacker's domain — including the public assignment PDF links, // an attacker's domain — including the public assignment PDF links,
@@ -39,7 +46,16 @@ return Application::configure(basePath: dirname(__DIR__))
// Dopo il ripristino di un backup ogni account resta bloccato finche' // Dopo il ripristino di un backup ogni account resta bloccato finche'
// non imposta una password nuova. In coda al gruppo web, quindi la // non imposta una password nuova. In coda al gruppo web, quindi la
// sessione e' gia' avviata e l'utente risolto quando gira. // 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: [ $middleware->web(append: [
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\RequirePasswordChange::class, \App\Http\Middleware\RequirePasswordChange::class,
]); ]);

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

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* I proclamatori non hanno più email né telefono.
*
* Servivano solo a mandare il link della piantina; ora lo manda chi assegna
* il territorio, scegliendo il destinatario dai propri contatti (WhatsApp
* senza numero, oppure il menu "Condividi" del telefono). Meno dati personali
* da conservare. In produzione, al momento della decisione (19 ottobre
* 2026), nessun proclamatore li aveva compilati.
*/
return new class extends Migration
{
public function up(): void
{
$colonne = array_values(array_filter(['email', 'telefono'], fn ($c) => Schema::hasColumn('proclamatori', $c)));
if ($colonne !== []) {
Schema::table('proclamatori', fn (Blueprint $table) => $table->dropColumn($colonne));
}
}
public function down(): void
{
// Le colonne tornano vuote: i valori non si recuperano.
Schema::table('proclamatori', function (Blueprint $table) {
$table->text('email')->nullable()->after('cognome');
$table->text('telefono')->nullable()->after('email');
});
}
};

View File

@@ -18,6 +18,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
environment: environment:
@@ -46,6 +50,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped restart: unless-stopped
user: www-data user: www-data
env_file: .env env_file: .env
@@ -71,6 +79,11 @@ services:
- --tries=3 - --tries=3
- --timeout=600 - --timeout=600
- --max-time=3600 - --max-time=3600
# Al riavvio (ogni deploy) il worker riceve SIGTERM e finisce il job in
# corso prima di uscire; Docker pero' di default aspetta 10 secondi e poi
# lo uccide. Un ripristino o un import interrotto cosi' lascia i dati a
# meta'. 16 minuti coprono il job piu' lungo (900 s).
stop_grace_period: 16m
# Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'. # Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"] test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"]
@@ -84,6 +97,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped restart: unless-stopped
user: www-data user: www-data
env_file: .env env_file: .env
@@ -166,9 +183,11 @@ services:
- ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env} - ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env}
- --maxmemory - --maxmemory
- 256mb - 256mb
# Qui vivono sessioni e cache: meglio sfrattare chiavi che esaurire memoria. # Qui vivono sessioni, cache e la coda. volatile-lru sfratta solo le
# chiavi con scadenza (sessioni, cache): con allkeys-lru, a memoria
# piena, potevano sparire anche i job in coda, senza errori.
- --maxmemory-policy - --maxmemory-policy
- allkeys-lru - volatile-lru
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks:
@@ -200,6 +219,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
# Senza questo si costruirebbe l'immagine di produzione, che essendo # Senza questo si costruirebbe l'immagine di produzione, che essendo
# installata senza dipendenze di sviluppo non contiene phpunit. # installata senza dipendenze di sviluppo non contiene phpunit.
target: test target: test

View File

@@ -38,8 +38,16 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public). # Territory files (PDFs, archived versions, thumbnails, map images) are NOT
# Served directly by nginx rather than round-tripping through PHP. # public: anyone could download /storage/territori-pdf/territorio-12-34.pdf
# without logging in, the names are guessable, and the public cache header
# below let Cloudflare keep them for a week. Logged-in users get them from
# TerritorioFileController, publishers from the expiring assignment link.
location ^~ /storage/territori- {
return 404;
}
# What is left on the public disk (the congregation logo).
location /storage/ { location /storage/ {
expires 7d; expires 7d;
add_header Cache-Control "public"; add_header Cache-Control "public";

View File

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

View File

@@ -78,6 +78,21 @@ ok "Storage directories ready."
KEY_FILE="/var/www/html/storage/app/.app_key" KEY_FILE="/var/www/html/storage/app/.app_key"
if [ -n "${APP_KEY:-}" ]; then if [ -n "${APP_KEY:-}" ]; then
# The persisted copy is the backup of the key the data was encrypted
# with. If the environment now carries a different one (a typo in
# Dokploy, a value pasted from another install), overwriting the file
# would destroy the only copy of the right key: refuse to start instead.
# A deliberate rotation keeps the old key aside and needs an explicit flag.
if [ -s "$KEY_FILE" ] && [ "$(cat "$KEY_FILE")" != "$APP_KEY" ]; then
if [ "${APP_KEY_ROTATE:-}" = "1" ]; then
BACKUP_FILE="${KEY_FILE}.prev-$(date +%Y%m%d-%H%M%S)"
cp "$KEY_FILE" "$BACKUP_FILE"
warn "APP_KEY_ROTATE=1: the previous key was saved to $BACKUP_FILE."
warn "Data encrypted with it stays unreadable until re-encrypted."
else
die "APP_KEY in the environment differs from the key persisted in $KEY_FILE. Starting would make the encrypted personal data unreadable. Restore the previous APP_KEY in the environment, or set APP_KEY_ROTATE=1 if the change is deliberate (the old key is kept aside)."
fi
fi
ok "APP_KEY provided via environment." ok "APP_KEY provided via environment."
printf '%s' "$APP_KEY" > "$KEY_FILE" printf '%s' "$APP_KEY" > "$KEY_FILE"
elif [ -s "$KEY_FILE" ]; then elif [ -s "$KEY_FILE" ]; then

View File

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

View File

@@ -3,7 +3,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build" "build": "vite build",
"test:js": "node --test tests/js/"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5", "@tailwindcss/forms": "^0.5",

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4a45a8"/><text x="16" y="21.5" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">T2</text></svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -557,6 +557,123 @@ html { scroll-behavior: smooth; }
background: var(--tm-superficie-alt); background: var(--tm-superficie-alt);
} }
/* ─── Assegnazione appena fatta: il link da mandare ──────────────────────── */
.tm-da-inviare {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 48rem;
margin-bottom: 20px;
padding: 18px 20px;
border: 2px solid var(--tm-accento);
border-radius: var(--tm-raggio);
background: var(--tm-accento-tenue);
box-shadow: var(--tm-ombra-2);
}
/* Lo stesso riquadro come finestra sopra la pagina (invio del link dopo
l'assegnazione): sfondo pieno, perché sotto c'è il velo scuro. */
.tm-da-inviare-finestra {
width: 100%;
max-width: 34rem;
margin-bottom: 0;
background: var(--tm-superficie, #fff);
}
.tm-da-inviare-titolo {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--tm-accento-scuro);
}
.tm-da-inviare-link {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.tm-da-inviare-link code {
flex: 1 1 260px;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
font-size: 13px;
color: var(--tm-testo);
word-break: break-all;
cursor: text;
}
.tm-da-inviare-azioni { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.tm-da-inviare-scelta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--tm-bordo);
}
.tm-da-inviare-scelta p { margin: 0; font-size: 14px; font-weight: 600; color: var(--tm-testo); }
.tm-da-inviare-scelta > div { display: flex; flex-wrap: wrap; gap: 8px; }
/* ─── Ricerca e filtri sopra un elenco ───────────────────────────────────── */
.tm-filtri {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.tm-filtri-cerca {
flex: 1 1 220px;
min-width: 0;
padding: 7px 11px;
border: 1px solid var(--tm-bordo-forte);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
font-size: 14px;
color: var(--tm-testo);
}
.tm-filtri-cerca:focus {
outline: 2px solid var(--tm-accento);
outline-offset: 1px;
}
.tm-filtri-schede {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tm-filtri-scheda {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--tm-bordo);
border-radius: 999px;
background: var(--tm-superficie);
font-size: 13px;
font-weight: 500;
color: var(--tm-testo-medio);
cursor: pointer;
}
.tm-filtri-scheda:hover { border-color: var(--tm-bordo-forte); }
.tm-filtri-scheda:focus-visible {
outline: 2px solid var(--tm-accento);
outline-offset: 2px;
}
.tm-filtri-scheda-attiva {
border-color: var(--tm-accento);
background: var(--tm-accento-tenue);
color: var(--tm-accento-scuro);
}
.tm-filtri-scheda .tm-conteggio {
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
}
/* ─── Etichette di stato ──────────────────────────────────────────────────── */ /* ─── Etichette di stato ──────────────────────────────────────────────────── */
.tm-tag { .tm-tag {
@@ -989,9 +1106,9 @@ html { scroll-behavior: smooth; }
.tm-editor-barra { .tm-editor-barra {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
flex: none; flex: none;
padding: 9px 14px; padding: 8px 14px;
background: var(--tm-superficie); background: var(--tm-superficie);
border-bottom: 1px solid var(--tm-bordo); border-bottom: 1px solid var(--tm-bordo);
box-shadow: var(--tm-ombra-1); box-shadow: var(--tm-ombra-1);
@@ -1026,6 +1143,8 @@ html { scroll-behavior: smooth; }
gap: 9px; gap: 9px;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
}
.tm-editor-titolo h1 {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -1034,6 +1153,11 @@ html { scroll-behavior: smooth; }
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.tm-editor-salvato {
font-size: 12px;
color: var(--tm-testo-tenue);
white-space: nowrap;
}
.tm-editor-azioni { .tm-editor-azioni {
display: flex; display: flex;
@@ -1042,10 +1166,26 @@ html { scroll-behavior: smooth; }
flex: none; flex: none;
} }
.tm-editor-mappa { /* Le tre zone: elenco, mappa, pannello. Elenco e pannello galleggiano sopra
la mappa invece di restringerla: se togliessero spazio, aprirli per cambiare
un nome sposterebbe il disegno sotto il puntatore. */
.tm-editor-corpo {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
/* Lo spazio che elenco e pannello aperti tolgono ai lati: barra degli
strumenti e controlli della mappa stanno nella parte libera, invece di
finire sotto (o sopra) un pannello. */
--libero-sx: 12px;
--libero-dx: 12px;
}
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(270px, 100vw - 24px) + 24px); }
.tm-editor-corpo.con-pannello { --libero-dx: calc(min(370px, 100vw - 24px) + 24px); }
.tm-mappa .leaflet-left { left: calc(var(--libero-sx) - 12px); }
.tm-mappa .leaflet-right { right: calc(var(--libero-dx) - 12px); }
.tm-editor-mappa {
position: absolute;
inset: 0;
} }
.tm-mappa { .tm-mappa {
position: absolute; position: absolute;
@@ -1056,7 +1196,7 @@ html { scroll-behavior: smooth; }
.tm-editor-nota { .tm-editor-nota {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 14px; bottom: 18px;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 500; z-index: 500;
max-width: min(92vw, 460px); max-width: min(92vw, 460px);
@@ -1072,16 +1212,132 @@ html { scroll-behavior: smooth; }
} }
.tm-editor-nota a { color: inherit; font-weight: 600; } .tm-editor-nota a { color: inherit; font-weight: 600; }
/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse /* ── Barra degli strumenti di disegno ── */
spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il .tm-strumenti {
puntatore. */
.tm-editor-pannello {
position: absolute; position: absolute;
top: 12px; top: 12px;
right: 12px; left: var(--libero-sx);
right: var(--libero-dx);
z-index: 650;
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
margin-inline: auto;
padding: 5px;
border: 1px solid var(--tm-bordo);
border-radius: 12px;
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow-x: auto;
}
.tm-strumenti:empty { display: none; }
.tm-strumento {
flex: none;
min-height: 34px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--tm-testo-medio);
white-space: nowrap;
cursor: pointer;
transition: background-color .15s ease, color .15s ease;
}
.tm-strumento:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); }
.tm-strumento:focus-visible { outline: 2px solid var(--tm-accento); outline-offset: 1px; }
.tm-strumento-principale { background: var(--tm-accento); color: #fff; }
.tm-strumento-principale:hover { background: var(--tm-accento-scuro); color: #fff; }
.tm-strumenti-modo {
flex: none;
padding: 0 8px 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--tm-pericolo);
}
/* ── Elenco ── */
.tm-editor-elenco {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px; bottom: 12px;
z-index: 600; z-index: 600;
width: min(360px, calc(100vw - 24px)); width: min(270px, calc(100vw - 24px));
display: flex;
flex-direction: column;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-editor-elenco-cerca {
flex: none;
padding: 10px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-editor-elenco-cerca input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--tm-bordo-forte);
border-radius: 8px;
font: inherit;
font-size: 13.5px;
}
.tm-editor-elenco-righe {
flex: 1 1 auto;
margin: 0;
padding: 6px;
list-style: none;
overflow-y: auto;
}
.tm-editor-elenco-voce {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: none;
font: inherit;
font-size: 13.5px;
text-align: left;
color: var(--tm-testo);
cursor: pointer;
}
.tm-editor-elenco-voce:hover { background: var(--tm-superficie-alt); }
.tm-editor-elenco-voce.is-selezionata { background: var(--tm-accento-tenue); color: var(--tm-accento-scuro); font-weight: 600; }
.tm-editor-elenco-stato {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--tm-testo-tenue);
}
.tm-stato-bozza, .tm-stato-modificata { color: var(--tm-attesa); }
.tm-stato-pubblicata { color: var(--tm-ok); }
.tm-editor-elenco-vuoto {
padding: 14px 10px;
font-size: 13px;
color: var(--tm-testo-tenue);
}
/* ── Pannello della piantina ── */
.tm-editor-pannello {
position: absolute;
top: 12px;
right: 12px;
bottom: 12px;
z-index: 600;
width: min(370px, calc(100vw - 24px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--tm-bordo); border: 1px solid var(--tm-bordo);
@@ -1090,7 +1346,6 @@ html { scroll-behavior: smooth; }
box-shadow: var(--tm-ombra-3); box-shadow: var(--tm-ombra-3);
overflow: hidden; overflow: hidden;
} }
.tm-editor-pannello[hidden] { display: none; }
.tm-editor-pannello-testata { .tm-editor-pannello-testata {
display: flex; display: flex;
@@ -1098,7 +1353,7 @@ html { scroll-behavior: smooth; }
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
flex: none; flex: none;
padding: 13px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--tm-bordo); border-bottom: 1px solid var(--tm-bordo);
} }
.tm-editor-pannello-testata h2 { .tm-editor-pannello-testata h2 {
@@ -1130,7 +1385,7 @@ html { scroll-behavior: smooth; }
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 10px; gap: 10px;
padding-top: 16px; padding-top: 16px;
border-top: 1px solid var(--tm-bordo); border-top: 1px solid var(--tm-bordo);
@@ -1141,14 +1396,164 @@ html { scroll-behavior: smooth; }
line-height: 1.55; line-height: 1.55;
color: var(--tm-testo-tenue); color: var(--tm-testo-tenue);
} }
.tm-stato-piantina p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--tm-testo-medio); }
.tm-azioni-riga {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.tm-azioni-riga:empty { display: none; }
.tm-passi {
margin: 0;
padding-left: 20px;
font-size: 13.5px;
line-height: 1.6;
color: var(--tm-testo-medio);
}
.tm-collegamento-piccolo {
align-self: flex-start;
padding: 0;
border-top: 0;
border-left: 0;
border-right: 0;
background: none;
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.tm-solo-stretto { display: none; }
/* Tablet: l'elenco si apre a richiesta e copre la mappa per il tempo della
scelta. */
@media (max-width: 1099px) {
.tm-editor-elenco { width: min(300px, calc(100vw - 24px)); }
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(300px, 100vw - 24px) + 24px); }
}
/* Telefono e tablet in verticale: il pannello sale dal basso. */
@media (max-width: 720px) { @media (max-width: 720px) {
.tm-editor-barra { flex-wrap: wrap; } .tm-editor-barra { flex-wrap: wrap; }
.tm-editor-titolo { order: 3; flex-basis: 100%; } .tm-editor-titolo { order: 3; flex-basis: 100%; }
.tm-editor-pannello { top: auto; height: min(70dvh, 460px); } .tm-editor-azioni .tm-btn span { display: none; }
.tm-solo-stretto { display: inline-flex; }
.tm-editor-pannello {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: min(62dvh, 520px);
border-radius: var(--tm-raggio) var(--tm-raggio) 0 0;
}
/* Elenco e pannello qui coprono la mappa per intero o dal basso: la
barra resta in alto, sopra il foglio del pannello. */
.tm-editor-corpo,
.tm-editor-corpo.con-elenco,
.tm-editor-corpo.con-pannello { --libero-sx: 12px; --libero-dx: 12px; }
/* A destra dei pulsanti dello zoom, e su più righe invece di uscire
dallo schermo. */
.tm-strumenti { left: 58px; flex-wrap: wrap; }
.tm-mappa .leaflet-left { left: 0; }
/* Il foglio copre crediti e scala della mappa finché è aperto, invece di
averli stampati sopra i campi. */
.tm-editor-pannello { z-index: 1001; }
} }
/* ─── Avvisi galleggianti (solo telaio a schermo intero) ───────────────────── */ /* Dita, non puntatore: vertici e maniglie di leaflet-draw più grandi, così
si prendono senza zoomare al massimo. */
@media (pointer: coarse) {
.leaflet-editing-icon,
.leaflet-touch-icon {
width: 22px !important;
height: 22px !important;
margin-left: -11px !important;
margin-top: -11px !important;
}
.tm-strumento { min-height: 42px; padding: 8px 14px; }
}
/* ─── Finestra di pubblicazione ───────────────────────────────────────────── */
.tm-modale-sfondo {
position: fixed;
inset: 0;
z-index: 800;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(28, 26, 23, .45);
}
.tm-modale {
display: flex;
flex-direction: column;
width: 100%;
max-width: 460px;
max-height: calc(100dvh - 32px);
border-radius: var(--tm-raggio);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-modale-larga { max-width: 820px; }
.tm-modale-testata {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-modale-testata h2 { margin: 0; font-size: 15px; font-weight: 600; }
.tm-modale-corpo {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
overflow-y: auto;
}
.tm-modale-piede {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--tm-bordo);
}
.tm-anteprima-pdf {
width: 100%;
height: min(62dvh, 640px);
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie-alt);
}
.tm-attesa {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 4px;
}
.tm-attesa-titolo { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
.tm-attesa-giro {
flex: none;
width: 22px;
height: 22px;
border: 3px solid var(--tm-accento-tenue);
border-top-color: var(--tm-accento);
border-radius: 50%;
animation: tm-giro .9s linear infinite;
}
@keyframes tm-giro { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .tm-attesa-giro { animation-duration: 3s; } }
.tm-nota-errore {
border-color: #edc9c6;
background: var(--tm-pericolo-tenue);
color: var(--tm-pericolo);
}
/* ─── Avvisi galleggianti (componente <x-avvisi>) ───────────────────── */
.tm-avvisi { .tm-avvisi {
position: fixed; position: fixed;
@@ -1254,3 +1659,165 @@ html { scroll-behavior: smooth; }
text-align: center; text-align: center;
} }
} }
/* ─── Campo di scelta con ricerca (<x-scelta>) ──────────────────────────────
Al posto delle tendine di territori e proclamatori. Pensato anche per il
telefono: testo a 16px (sotto, iOS ingrandisce la pagina al tocco), voci
alte almeno 44px, elenco che scorre dentro mezzo schermo. I selettori
battono `.tm-campo input[type="text"]` dell'editor delle piantine. */
.tm-scelta {
position: relative;
width: 100%;
}
.tm-scelta-campo {
position: relative;
display: flex;
align-items: center;
}
.tm-scelta input.tm-scelta-input {
width: 100%;
min-height: 44px;
padding: 8px 76px 8px 12px;
border: 1px solid var(--tm-bordo-forte);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
color: var(--tm-testo);
font-family: inherit;
font-size: 16px;
line-height: 1.3;
transition: border-color .15s ease, box-shadow .15s ease;
}
@media (min-width: 640px) {
.tm-scelta input.tm-scelta-input { min-height: 40px; font-size: 14px; }
}
.tm-scelta input.tm-scelta-input:focus {
outline: none;
border-color: var(--tm-accento);
box-shadow: 0 0 0 3px var(--tm-accento-tenue);
}
.tm-scelta input.tm-scelta-input:disabled {
padding-right: 12px;
background: var(--tm-superficie-alt);
color: var(--tm-testo-medio);
}
.tm-scelta input.tm-scelta-input::placeholder { color: var(--tm-testo-tenue); }
.tm-scelta-pulisci,
.tm-scelta-apri {
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-top: -18px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--tm-testo-tenue);
cursor: pointer;
}
.tm-scelta-pulisci { right: 38px; }
.tm-scelta-apri { right: 2px; }
.tm-scelta-pulisci:hover,
.tm-scelta-apri:hover { color: var(--tm-testo); background: var(--tm-superficie-alt); }
.tm-scelta-pulisci svg,
.tm-scelta-apri svg { width: 18px; height: 18px; transition: transform .15s ease; }
.tm-scelta-apri svg.tm-scelta-giu { transform: rotate(180deg); }
.tm-scelta-elenco {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(50vh, 20rem);
margin: 0;
padding: 4px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
list-style: none;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
}
.tm-scelta-voce {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 8px;
row-gap: 2px;
min-height: 44px;
padding: 10px 12px;
border-radius: 8px;
color: var(--tm-testo);
font-size: 15px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@media (min-width: 640px) {
.tm-scelta-voce { min-height: 36px; padding: 7px 10px; font-size: 14px; }
}
.tm-scelta-voce-attiva { background: var(--tm-accento-tenue); }
.tm-scelta-voce-scelta .tm-scelta-etichetta { font-weight: 700; color: var(--tm-accento-scuro); }
.tm-scelta-voce-speciale { color: var(--tm-testo-medio); }
.tm-scelta-voce-spenta { cursor: not-allowed; color: var(--tm-testo-tenue); }
.tm-scelta-voce-spenta.tm-scelta-voce-attiva { background: var(--tm-superficie-alt); }
.tm-scelta-dettaglio { font-size: 13px; color: var(--tm-testo-tenue); }
.tm-scelta-motivo { flex-basis: 100%; font-size: 12px; color: var(--tm-attesa); }
.tm-scelta-forse {
padding: 1px 6px;
border-radius: 999px;
background: var(--tm-attesa-tenue);
color: var(--tm-attesa);
font-size: 11px;
font-weight: 600;
}
.tm-scelta-vuoto {
padding: 12px;
color: var(--tm-testo-tenue);
font-size: 14px;
}
/* ─── Elenco dei territori assegnati (pagina Assegna) ───────────────────────
Classi brevi al posto delle catene di utility: le righe sono ~80 e ogni
byte si moltiplica (ElencoAssegnati, AssegnaPrestazioniTest). */
.tm-assegnato { padding: 16px 20px; }
.tm-assegnato svg { width: 16px; height: 16px; fill: none; stroke: currentColor; flex: none; }
.tm-assegnato-testa {
display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between;
gap: 8px 24px;
}
.tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; }
.tm-assegnato-numero:hover { text-decoration: underline; }
.tm-assegnato-zona {
margin-left: 6px; padding: 2px 6px; border-radius: 4px;
background: #f3f4f6; color: #9ca3af; font-size: 12px;
}
.tm-assegnato-info { color: #6b7280; font-size: 12px; }
.tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; }
.tm-assegnato-giorni {
padding: 2px 8px; border-radius: 999px;
background: #f3f4f6; color: #6b7280; font-weight: 600;
}
.tm-assegnato-giorni.tm-g-presto { background: #fffbeb; color: #d97706; }
.tm-assegnato-giorni.tm-g-tardi { background: #fef2f2; color: #dc2626; }
.tm-assegnato-sospeso {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
margin-top: 12px; padding: 6px 12px; border: 1px solid #fde68a; border-radius: 6px;
background: #fffbeb; color: #92400e; font-size: 12px; font-weight: 600;
}
.tm-assegnato-link { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.tm-assegnato-link > * { flex: none; }
.tm-assegnato-link code {
flex: 1 1 0; min-width: 0; padding: 8px 12px;
border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb;
color: #4b5563; font-size: 12px; word-break: break-all; user-select: all; cursor: text;
}
.tm-assegnato-senza { margin-top: 8px; color: #d1d5db; font-size: 12px; }
/* Pulsante copia: la spunta al posto dell'icona per due secondi. */
.tm-copia svg + svg, .tm-copia.tm-copiato svg:first-child { display: none; }
.tm-copia.tm-copiato svg + svg { display: block; color: #16a34a; }

View File

@@ -1 +1,181 @@
import './bootstrap'; import './bootstrap';
import { proponi, normalizza } from './scelta';
// ─── Campo di scelta con ricerca (<x-scelta>) ───────────────────────────────
//
// Al posto della tendina: si scrive e sotto compaiono le proposte più vicine
// (logica in ./scelta.js). Campo vuoto o appena aperto: la tendina completa.
// Il valore scelto arriva a Livewire con x-modelable + wire:model.
document.addEventListener('alpine:init', () => {
// Elenco filtrato nel browser: ogni riga ha data-cerca (testo già senza
// accenti, minuscolo); valgono tutte le parole scritte, in qualsiasi
// ordine. Nessuna richiesta al server mentre si scrive.
window.Alpine.data('elencoCercabile', () => ({
q: '',
condivisibile: !! navigator.share,
// Condividi il link della riga (data-messaggio): il menu del telefono
// fa scegliere app e contatto. A condivisione fatta il link risulta
// inviato; se la si annulla non cambia niente.
condividi(pulsante) {
const riga = pulsante.closest('[data-link]');
if (!riga || !navigator.share) return;
navigator.share({ title: riga.dataset.titolo, text: riga.dataset.messaggio })
.then(() => this.$wire.segnaCondiviso(Number(riga.dataset.id)))
.catch(() => {});
},
corrisponde(testo) {
const parole = normalizza(this.q).split(/\s+/).filter(Boolean);
return parole.every((p) => (testo ?? '').includes(p));
},
get nessuno() {
if (normalizza(this.q) === '') return false;
return ![...this.$root.querySelectorAll('[data-cerca]')].some((riga) => this.corrisponde(riga.dataset.cerca));
},
// Copia il link della riga (il <code> accanto al pulsante); il pulsante
// mostra la spunta per due secondi (classe tm-copiato). Un metodo solo
// invece dello stesso codice ripetuto in ogni riga.
copia(pulsante) {
const testo = pulsante.closest('[data-link]')?.querySelector('code')?.textContent.trim() ?? '';
navigator.clipboard?.writeText(testo).then(() => {
pulsante.classList.add('tm-copiato');
setTimeout(() => pulsante.classList.remove('tm-copiato'), 2000);
}).catch(() => {});
},
}));
// vuoto: il valore di "nessuna scelta" per la proprietà Livewire collegata
// ('' per le stringhe, null per i ?int: '' su un ?int sarebbe un errore).
window.Alpine.data('sceltaRicerca', (opzioni, vuoto = '') => ({
opzioni,
vuoto,
valore: null,
testo: '',
aperto: false,
evidenziato: 0,
init() {
this.testo = this.etichettaDi(this.valore);
// Valore cambiato da Livewire (reset del form, scelta da codice).
this.$watch('valore', (v) => { if (!this.aperto) this.testo = this.etichettaDi(v); });
},
scelta() {
return this.opzioni.find((o) => String(o.valore) === String(this.valore ?? '')) ?? null;
},
etichettaDi(v) {
if (v === null || v === undefined || v === '') {
const vuota = this.opzioni.find((o) => o.speciale && o.valore === '');
return vuota ? vuota.etichetta : '';
}
const o = this.opzioni.find((x) => String(x.valore) === String(v));
return o ? o.etichetta : '';
},
// Finché nel campo c'è l'etichetta della scelta attuale, si vede la
// tendina completa: si cerca solo da quando si comincia a scrivere.
get ricerca() {
return this.testo === this.etichettaDi(this.valore) ? '' : this.testo;
},
get risultati() {
return proponi(this.opzioni, this.ricerca);
},
apri() {
if (this.aperto) return;
this.aperto = true;
const i = this.risultati.findIndex((o) => String(o.valore) === String(this.valore ?? ''));
this.evidenziato = Math.max(0, i);
this.$nextTick(() => this.mostraEvidenziato());
},
chiudi() {
this.aperto = false;
this.testo = this.etichettaDi(this.valore);
},
alterna() {
if (this.aperto) { this.chiudi(); return; }
this.$refs.campo.focus();
this.apri();
},
scrivendo() {
this.aperto = true;
this.evidenziato = 0;
},
sposta(passo) {
if (!this.aperto) { this.apri(); return; }
const n = this.risultati.length;
if (n === 0) return;
this.evidenziato = (this.evidenziato + passo + n) % n;
this.$nextTick(() => this.mostraEvidenziato());
},
mostraEvidenziato() {
this.$refs.elenco?.querySelector('[data-evidenziato="1"]')?.scrollIntoView({ block: 'nearest' });
},
// daTocco: scelta col dito o col mouse. Si toglie il focus, così sul
// telefono si chiude la tastiera; con frecce e Invio il focus resta.
scegli(opzione, daTocco = false) {
if (!opzione || opzione.motivo) return;
this.valore = opzione.valore;
this.aperto = false;
this.testo = opzione.etichetta;
// Per chi vuole reagire alla scelta senza passare dal server
// (l'anteprima della miniatura in Assegna).
this.$dispatch('scelta', { valore: opzione.valore, opzione });
if (daTocco) this.$refs.campo.blur();
},
scegliEvidenziato() {
if (this.aperto) this.scegli(this.risultati[this.evidenziato]);
},
svuota() {
this.valore = this.vuoto;
this.testo = '';
this.$dispatch('scelta', { valore: this.vuoto, opzione: null });
this.$refs.campo.focus();
this.aperto = true;
this.evidenziato = 0;
},
}));
});
// ─── Ritorno alla pagina di partenza ────────────────────────────────────────
//
// I link con data-ritorno (schede e operazioni) portano con sé ?da=<percorso>:
// la pagina di partenza con i suoi filtri, oppure il `da` che la pagina
// corrente aveva già ricevuto, così si risale sempre all'origine del percorso
// (elenco → scheda → Rientra → torna all'elenco). Il server lo valida e lo usa
// dopo conferma o annulla: App\Support\Ritorno, trait RicordaRitorno.
//
// Si calcola al momento dell'uso e non al caricamento: filtri e pagina degli
// elenchi cambiano l'indirizzo (#[Url] di Livewire) senza ricaricare.
function ritornoCorrente() {
const qui = new URL(window.location.href);
return qui.searchParams.get('da') || (qui.pathname + qui.search);
}
function aggiornaLink(link) {
const destinazione = new URL(link.getAttribute('href'), window.location.origin);
if (destinazione.origin !== window.location.origin) return;
destinazione.searchParams.set('da', ritornoCorrente());
link.setAttribute('href', destinazione.pathname + destinazione.search + destinazione.hash);
}
// Fase di cattura: si arriva prima di wire:navigate, che legge l'href al
// pointerdown (precaricamento) e al click. contextmenu e focusin coprono
// "apri in una nuova scheda" e la tastiera.
['pointerdown', 'click', 'contextmenu', 'focusin'].forEach((evento) => {
document.addEventListener(evento, (e) => {
const link = e.target instanceof Element ? e.target.closest('a[data-ritorno][href]') : null;
if (link) aggiornaLink(link);
}, true);
});

View File

@@ -1,10 +1,22 @@
// Editor delle piantine (pagina /piantine/editor, solo amministratore). // Editor delle piantine (pagina /piantine/editor, solo amministratore).
// //
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine // Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più
// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il // l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i
// componente Livewire attraverso eventi: // gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti
// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa' // grandi e con il testo, che si usano anche al tablet.
// Livewire → JS: 'piantine-dati-aggiornati' //
// Modalità, sempre esplicite:
// vista si guarda, si sceglie una piantina (clic sulla mappa o in elenco)
// disegno si traccia una piantina NUOVA
// ridisegno si traccia da capo il confine di quella aperta (con conferma)
// vertici si trascinano i vertici di quella aperta
//
// Con il componente Livewire si parla per eventi:
// JS → Livewire: 'piantina-selezionata-mappa', 'geometria-modificata'
// Livewire → JS: 'piantina-aggiornata', 'piantina-rimossa',
// 'piantina-selezionata', 'editor-disegna', 'editor-indirizzo'
// I dati di tutte le piantine si chiedono una volta sola ($wire.datiMappa());
// dopo arriva solo la piantina cambiata.
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
@@ -12,12 +24,13 @@ import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css'; import 'leaflet-draw/dist/leaflet.draw.css';
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false }; const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
// Una piantina collegata a un territorio è "a posto"; una scollegata è un const STILI = {
// lavoro ancora aperto, e si vede senza dover aprire il pannello. pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 },
const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 }; modificata: { color: '#a06a22', weight: 2, fillColor: '#c98b3a', fillOpacity: 0.1 },
const STILE_SCOLLEGATA = { color: '#b4762a', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 }; bozza: { color: '#a06a22', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 },
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, fillColor: '#605cb2', fillOpacity: 0.18 }; };
const STILE_IN_ATTESA = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 }; const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 };
const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
function initPiantine() { function initPiantine() {
const contenitore = document.getElementById('piantine-map'); const contenitore = document.getElementById('piantine-map');
@@ -26,143 +39,297 @@ function initPiantine() {
} }
contenitore.dataset.inizializzato = '1'; contenitore.dataset.inizializzato = '1';
let dati = { area: null, piantine: [], selezionata: null }; const radice = contenitore.closest('[wire\\:id]');
try { const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
} catch {
// si parte comunque con la mappa vuota
}
// Etichette in italiano per la barra di disegno.
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il confine di una piantina';
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
L.drawLocal.draw.handlers.polygon.tooltip = { L.drawLocal.draw.handlers.polygon.tooltip = {
start: 'Clicca sulla mappa per iniziare il confine.', start: 'Tocca o clicca sulla mappa per il primo punto del confine.',
cont: 'Continua a cliccare lungo il confine.', cont: 'Continua lungo il confine.',
end: 'Clicca sul primo punto per chiudere il poligono.', end: 'Chiudi sul primo punto, o premi «Concludi».',
}; };
L.drawLocal.edit.handlers.edit.tooltip = { text: 'Trascina i vertici per correggere il confine.', subtext: '' };
const mappa = L.map(contenitore, { zoomControl: true }); const mappa = L.map(contenitore, { zoomControl: true });
L.control.scale({ imperial: false }).addTo(mappa); L.control.scale({ imperial: false }).addTo(mappa);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mappa); }).addTo(mappa);
const gruppo = L.featureGroup().addTo(mappa);
const layers = new Map(); // mappaId → { dati, layer }
let layerArea = null; let layerArea = null;
const layerSalvate = L.featureGroup().addTo(mappa); let selezionata = null;
let layerInAttesa = null; let modo = 'vista';
let gestoreDisegno = null;
let poligonoInModifica = null;
mappa.addControl(new L.Control.Draw({ // ── Disegno delle piantine ──────────────────────────────────────────────
draw: {
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA }, function stileDi(dati) {
polyline: false, if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
rectangle: false, return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
circle: false, }
circlemarker: false,
marker: false, function disegna(dati) {
}, rimuovi(dati.mappaId, false);
edit: false, if (!dati.geometry) return;
let layer;
try {
layer = L.geoJSON({ type: 'Feature', properties: {}, geometry: dati.geometry }, { style: stileDi(dati) });
} catch {
return; // una geometria rovinata non deve fermare le altre
}
layer.bindTooltip(`${dati.etichetta} · ${dati.statoEtichetta}`, { sticky: true });
layer.on('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: dati.mappaId });
});
gruppo.addLayer(layer);
layers.set(dati.mappaId, { dati, layer });
}
function rimuovi(mappaId, aggiornaElenco = true) {
const voce = layers.get(mappaId);
if (voce) {
gruppo.removeLayer(voce.layer);
layers.delete(mappaId);
}
if (aggiornaElenco) disegnaElenco();
}
function ristila() {
for (const { dati, layer } of layers.values()) {
layer.setStyle(stileDi(dati));
}
}
function inquadra(mappaId) {
const voce = layers.get(mappaId);
if (voce) mappa.fitBounds(voce.layer.getBounds().pad(0.25));
}
// ── Elenco con ricerca ──────────────────────────────────────────────────
const elenco = document.getElementById('piantine-elenco');
const cerca = document.getElementById('piantine-cerca');
function disegnaElenco() {
if (!elenco) return;
const filtro = (cerca?.value ?? '').trim().toLowerCase();
const voci = [...layers.values()].map((v) => v.dati)
.filter((d) => !filtro || d.etichetta.toLowerCase().includes(filtro))
.sort((a, b) => a.etichetta.localeCompare(b.etichetta, 'it', { numeric: true }));
elenco.replaceChildren(...voci.map((d) => {
const riga = document.createElement('li');
const pulsante = document.createElement('button');
pulsante.type = 'button';
pulsante.className = 'tm-editor-elenco-voce' + (d.mappaId === selezionata ? ' is-selezionata' : '');
pulsante.setAttribute('aria-current', d.mappaId === selezionata ? 'true' : 'false');
const nome = document.createElement('span');
nome.textContent = d.etichetta;
const stato = document.createElement('span');
stato.className = 'tm-editor-elenco-stato tm-stato-' + d.stato;
stato.textContent = d.statoEtichetta;
pulsante.append(nome, stato);
pulsante.addEventListener('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: d.mappaId });
});
riga.append(pulsante);
return riga;
})); }));
function stileDi(piantina, selezionata) { if (!voci.length) {
if (selezionata) return STILE_SELEZIONATA; const vuoto = document.createElement('li');
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA; vuoto.className = 'tm-editor-elenco-vuoto';
vuoto.textContent = filtro ? 'Nessuna piantina trovata.' : 'Nessuna piantina ancora.';
elenco.append(vuoto);
}
} }
function ridisegna(nuoviDati, adattaVista) { cerca?.addEventListener('input', disegnaElenco);
dati = nuoviDati;
if (layerArea) { // ── Barra degli strumenti ───────────────────────────────────────────────
mappa.removeLayer(layerArea);
layerArea = null; const barra = document.getElementById('piantine-strumenti');
function pulsante(testo, azione, principale = false) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tm-strumento' + (principale ? ' tm-strumento-principale' : '');
b.textContent = testo;
b.addEventListener('click', azione);
return b;
} }
function aggiornaBarra() {
if (!barra) return;
const comandi = [];
if (modo === 'vista') {
comandi.push(pulsante('Disegna nuova piantina', () => avviaDisegno(false), selezionata === null));
if (selezionata !== null && layers.has(selezionata)) {
comandi.push(pulsante('Modifica vertici', avviaVertici));
comandi.push(pulsante('Ridisegna confine', () => {
if (window.confirm('Ridisegnare da capo il confine di questa piantina? Quello attuale viene sostituito.')) {
avviaDisegno(true);
}
}));
}
} else if (modo === 'disegno' || modo === 'ridisegno') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = modo === 'disegno' ? 'Nuova piantina' : 'Nuovo confine';
comandi.push(titolo);
comandi.push(pulsante('Togli ultimo punto', () => gestoreDisegno?.deleteLastVertex()));
comandi.push(pulsante('Concludi', () => gestoreDisegno?.completeShape(), true));
comandi.push(pulsante('Annulla', fermaDisegno));
} else if (modo === 'vertici') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = 'Trascina i vertici';
comandi.push(titolo);
comandi.push(pulsante('Fine modifica', fermaVertici, true));
}
barra.replaceChildren(...comandi);
}
// ── Disegno ─────────────────────────────────────────────────────────────
let ridisegno = false;
function avviaDisegno(diQuellaAperta) {
fermaVertici(false);
ridisegno = diQuellaAperta;
modo = diQuellaAperta ? 'ridisegno' : 'disegno';
gestoreDisegno = new L.Draw.Polygon(mappa, { allowIntersection: false, showArea: false, shapeOptions: STILE_DISEGNO });
gestoreDisegno.enable();
aggiornaBarra();
}
function fermaDisegno() {
gestoreDisegno?.disable();
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
mappa.on(L.Draw.Event.CREATED, (evento) => {
const geometry = evento.layer.toGeoJSON().geometry;
const nuova = !ridisegno;
fermaDisegno();
componente().dispatch('geometria-modificata', { geometry, nuova });
});
// Il gestore si spegne da solo anche con Esc: la barra deve seguirlo.
mappa.on(L.Draw.Event.DRAWSTOP, () => {
if (modo === 'disegno' || modo === 'ridisegno') {
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
});
// ── Modifica dei vertici ────────────────────────────────────────────────
function avviaVertici() {
const voce = layers.get(selezionata);
if (!voce) return;
poligonoInModifica = voce.layer.getLayers()[0];
if (!poligonoInModifica?.editing) return;
poligonoInModifica.editing.enable();
modo = 'vertici';
aggiornaBarra();
}
function fermaVertici(salva = true) {
if (!poligonoInModifica) {
if (modo === 'vertici') { modo = 'vista'; aggiornaBarra(); }
return;
}
const poligono = poligonoInModifica;
poligonoInModifica = null;
const modificato = poligono.edited;
poligono.editing.disable();
modo = 'vista';
aggiornaBarra();
if (salva && modificato) {
componente().dispatch('geometria-modificata', { geometry: poligono.toGeoJSON().geometry, nuova: false });
}
}
// ── Eventi dal server ───────────────────────────────────────────────────
function seleziona(mappaId, centra) {
if (modo === 'vertici') fermaVertici();
selezionata = mappaId ?? null;
ristila();
disegnaElenco();
aggiornaBarra();
if (centra && selezionata !== null) inquadra(selezionata);
}
function registra() {
window.Livewire.on('piantina-aggiornata', ({ piantina }) => {
disegna(piantina);
disegnaElenco();
aggiornaBarra();
});
window.Livewire.on('piantina-rimossa', ({ mappaId }) => {
if (selezionata === mappaId) selezionata = null;
rimuovi(mappaId);
aggiornaBarra();
});
window.Livewire.on('piantina-selezionata', ({ mappaId, inquadra: centra }) => seleziona(mappaId, centra));
window.Livewire.on('editor-disegna', () => avviaDisegno(false));
window.Livewire.on('editor-indirizzo', ({ url }) => {
if (window.location.href !== url) window.history.replaceState(window.history.state, '', url);
});
}
async function carica() {
const dati = await componente().datiMappa();
if (dati.area) { if (dati.area) {
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa); layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
} }
selezionata = dati.selezionata ?? null;
dati.piantine.forEach(disegna);
disegnaElenco();
aggiornaBarra();
layerSalvate.clearLayers(); if (selezionata !== null && layers.has(selezionata)) {
let layerSelezionata = null; inquadra(selezionata);
} else if (layerArea) {
for (const piantina of dati.piantine) { mappa.fitBounds(layerArea.getBounds().pad(0.08));
const selezionata = dati.selezionata === piantina.mappaId; } else if (layers.size) {
const layer = L.geoJSON( mappa.fitBounds(gruppo.getBounds().pad(0.08));
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
{ style: stileDi(piantina, selezionata) }
);
layer.bindTooltip(piantina.etichetta, { sticky: true });
layer.on('click', () => {
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
});
layerSalvate.addLayer(layer);
if (selezionata) {
layerSelezionata = layer;
}
}
// Aprire una piantina dall'elenco deve portarci sopra; un salvataggio
// no, altrimenti la vista salta via mentre si sta ancora lavorando.
if (adattaVista && layerSelezionata) {
mappa.fitBounds(layerSelezionata.getBounds().pad(0.25));
return;
}
if (!adattaVista) {
return;
}
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
if (riferimento) {
mappa.fitBounds(riferimento.getBounds().pad(0.08));
} else { } else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
} }
// Arrivati da "Nuovo territorio": si comincia subito a disegnare.
if (componente().creaTerritorio) avviaDisegno(false);
} }
ridisegna(dati, true); mappa.setView([41.9, 12.5], 6);
mappa.on(L.Draw.Event.CREATED, (evento) => { const avvia = () => { registra(); carica(); };
if (layerInAttesa) { if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) {
mappa.removeLayer(layerInAttesa); avvia();
} } else {
layerInAttesa = evento.layer; document.addEventListener('livewire:initialized', avvia, { once: true });
layerInAttesa.setStyle(STILE_IN_ATTESA);
layerInAttesa.addTo(mappa);
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
});
let registrato = false;
document.addEventListener('livewire:init', registraEventi);
if (window.Livewire) {
registraEventi();
} }
function registraEventi() { // Pannelli che si aprono e chiudono cambiano le misure del contenitore:
if (registrato) { // senza, Leaflet resta convinto di quelle di partenza e le tessere si
return; // disallineano dal puntatore.
} new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore);
registrato = true;
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi, inquadra }) => {
// Dopo un salvataggio il poligono provvisorio è diventato
// definitivo (o è stato scartato): via il tracciato rosso.
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
layerInAttesa = null;
}
ridisegna(nuovi, Boolean(inquadra));
});
}
// La mappa nasce dentro un contenitore che il pannello laterale può
// coprire: senza questo, Leaflet resta convinto delle misure di partenza e
// le tessere si disallineano dal puntatore.
window.addEventListener('resize', () => mappa.invalidateSize());
setTimeout(() => mappa.invalidateSize(), 60);
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {

101
resources/js/scelta.js Normal file
View File

@@ -0,0 +1,101 @@
// ─── Scelta con ricerca: la logica, senza DOM ───────────────────────────────
//
// Le proposte per quello che si scrive in un campo di scelta (territorio,
// proclamatore). Funzioni pure, provate da tests/js/scelta.test.mjs.
//
// Un'opzione è { valore, etichetta, dettaglio?, chiavi: [stringhe], motivo?,
// speciale? }:
// chiavi i campi in cui cercare, in ordine di importanza (cognome prima
// del nome, numero prima della zona)
// motivo l'opzione c'è ma non si può scegliere ("già assegnato a …"): si
// mostra solo cercando, mai nella tendina completa
// speciale sempre in cima e mai filtrata ("— nessun territorio —")
//
// Punteggio di ogni parola scritta, più basso = più vicino:
// 0 uguale · 1 inizia con · 2 contiene · 3 a una lettera di distanza
// ("forse cercavi"), più 0.1 per ogni posizione della chiave.
// Devono trovare posto tutte le parole scritte, in qualsiasi ordine
// ("mario ros" trova Rossi Mario).
export function normalizza(testo) {
return (testo ?? '')
.toString()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
/** Distanza di modifica tra due parole, fermandosi appena supera `limite`. */
export function distanza(a, b, limite = 2) {
if (Math.abs(a.length - b.length) > limite) return limite + 1;
let precedente = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
const riga = [i];
let minimo = i;
for (let j = 1; j <= b.length; j++) {
const costo = a[i - 1] === b[j - 1] ? 0 : 1;
riga[j] = Math.min(precedente[j] + 1, riga[j - 1] + 1, precedente[j - 1] + costo);
minimo = Math.min(minimo, riga[j]);
}
if (minimo > limite) return limite + 1;
precedente = riga;
}
return precedente[b.length];
}
const PERFETTO = 0, INIZIA = 1, CONTIENE = 2, VICINO = 3;
function punteggioParola(parola, chiavi) {
let migliore = Infinity;
chiavi.forEach((chiave, posizione) => {
if (chiave === '') return;
let p = Infinity;
if (chiave === parola) p = PERFETTO;
else if (chiave.startsWith(parola) || chiave.split(/\s+/).some((w) => w.startsWith(parola))) p = INIZIA;
else if (chiave.includes(parola)) p = CONTIENE;
else if (parola.length >= 4) {
// Errori di battitura: una lettera (due dalle 7 lettere in su), su
// parole intere. Mai sui numeri brevi: "35" non deve trovare "36".
const tolleranza = parola.length >= 7 ? 2 : 1;
if (chiave.split(/\s+/).some((w) => distanza(w, parola, tolleranza) <= tolleranza)) p = VICINO;
}
if (p !== Infinity) migliore = Math.min(migliore, p + posizione * 0.1);
});
return migliore;
}
/**
* Le opzioni da proporre per `testo`.
* Campo vuoto: la tendina completa (speciali e sceglibili, nell'ordine dato).
* Altrimenti: le opzioni in cui trovano posto tutte le parole, dalla più
* vicina; `forse` segna quelle trovate solo per un errore di battitura.
*/
export function proponi(opzioni, testo, massimo = 50) {
const speciali = opzioni.filter((o) => o.speciale);
const normali = opzioni.filter((o) => !o.speciale);
const parole = normalizza(testo).split(/\s+/).filter(Boolean);
if (parole.length === 0) {
return [...speciali, ...normali.filter((o) => !o.motivo)].map((o) => ({ ...o, forse: false }));
}
const trovate = [];
normali.forEach((opzione, ordine) => {
const chiavi = (opzione.chiavi ?? [opzione.etichetta]).map(normalizza);
let totale = 0;
let forse = false;
for (const parola of parole) {
const p = punteggioParola(parola, chiavi);
if (p === Infinity) return;
if (p >= VICINO) forse = true;
totale += p;
}
trovate.push({ opzione, totale, ordine, forse });
});
trovate.sort((a, b) => a.totale - b.totale || a.ordine - b.ordine);
return [...speciali.map((o) => ({ ...o, forse: false })),
...trovate.slice(0, massimo).map((t) => ({ ...t.opzione, forse: t.forse }))];
}

Binary file not shown.

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Link scaduto — TerManager2</title> <title>Link scaduto — TerManager2</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
<title>Territorio {{ $assignment->territorio?->numero }}</title> <title>Territorio {{ $assignment->territorio?->numero }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -48,9 +48,19 @@
sanno a quale account associare la credenziale che salvano. --}} sanno a quale account associare la credenziale che salvano. --}}
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}"> <input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
@unless($forced)
{{-- Dopo un ripristino non si chiede: la password attuale e'
quella del backup, che si puo' non ricordare. --}}
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
<input type="password" name="current_password" id="current_password" required autofocus autocomplete="current-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
@endunless
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label> <label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
<input type="password" name="password" id="password" required autofocus autocomplete="new-password" <input type="password" name="password" id="password" required @if($forced) autofocus @endif autocomplete="new-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p> <p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
</div> </div>

View File

@@ -0,0 +1,50 @@
<x-layouts.guest title="Conferma il tuo indirizzo email">
<div class="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-5">
<div>
<h1 class="text-xl font-bold text-gray-900">Conferma il tuo indirizzo email</h1>
<p class="mt-1 text-sm text-gray-500">
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
</p>
</div>
@if(session('success'))
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-800">
{{ session('error') }}
</div>
@endif
<p class="text-sm text-gray-700">
Per usare TerManager2 devi confermare che questo indirizzo è tuo: apri il link nell'email che ti
abbiamo mandato. Se non l'hai ricevuta, o il link è scaduto, chiedine una nuova.
</p>
@unless($postaFunzionante)
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Il server di posta non è ancora configurato: l'email non può partire.
@can('settings.manage')
<a href="{{ route('posta.edit') }}" class="font-semibold underline">Configuralo qui</a>.
@else
Avvisa un amministratore.
@endcan
</div>
@endunless
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-btn variante="primary" type="submit" class="w-full">Inviami l'email di conferma</x-btn>
</form>
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 pt-1 border-t border-gray-100">
<span>L'indirizzo è sbagliato? <a href="{{ route('profile') }}" class="text-indigo-700 underline">Correggilo nel profilo</a>.</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-btn variante="ghost" dimensione="sm" type="submit">Esci</x-btn>
</form>
</div>
</div>
</x-layouts.guest>

View File

@@ -0,0 +1,50 @@
{{--
Avvisi galleggianti: raccolgono l'evento "avviso" che i componenti
Livewire lanciano con il trait Avvisa, e che arriva anche dopo un'azione
che ridisegna solo il componente.
sessione: true per mostrare anche i messaggi flash della richiesta (il
telaio a schermo intero, che non ha una fascia messaggi sua). Il telaio
normale li mostra già in testa alla pagina, quindi lì resta false.
Gli avvisi di conferma si chiudono da soli; quelli di errore restano
finché non li si chiude, perché vanno letti.
--}}
@props(['sessione' => false])
@php
$iniziali = [];
if ($sessione && session()->has('success')) {
$iniziali[] = ['tipo' => 'ok', 'testo' => session('success')];
}
if ($sessione && session()->has('error')) {
$iniziali[] = ['tipo' => 'errore', 'testo' => session('error')];
}
@endphp
<div class="tm-avvisi" role="status" aria-live="polite"
x-data="{
avvisi: [],
prossimo: 0,
aggiungi(tipo, testo) {
const id = ++this.prossimo;
this.avvisi.push({ id, tipo, testo });
if (tipo !== 'errore') setTimeout(() => this.togli(id), 4500);
},
togli(id) { this.avvisi = this.avvisi.filter(a => a.id !== id); },
}"
x-init="@js($iniziali).forEach(a => aggiungi(a.tipo, a.testo))"
@avviso.window="aggiungi($event.detail.tipo, $event.detail.testo)">
<template x-for="avviso in avvisi" :key="avviso.id">
<div class="tm-avviso" :class="avviso.tipo === 'errore' ? 'tm-avviso-errore' : 'tm-avviso-ok'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0">
<svg x-show="avviso.tipo !== 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<svg x-show="avviso.tipo === 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span x-text="avviso.testo"></span>
<button type="button" @click="togli(avviso.id)" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
</template>
</div>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title> <title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
@@ -17,13 +18,16 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between"> <div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<x-btn tono="velato" icona class="lg:hidden" {{-- Nascosto dal contenitore: vedi il profilo qui sotto. --}}
<div class="lg:hidden">
<x-btn tono="velato" icona
x-data x-on:click="$dispatch('toggle-sidebar')" x-data x-on:click="$dispatch('toggle-sidebar')"
aria-label="Apri il menu di navigazione"> aria-label="Apri il menu di navigazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px"> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg> </svg>
</x-btn> </x-btn>
</div>
@php $settings = \App\Models\Setting::first(); @endphp @php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path) @if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)"> <img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
@@ -35,14 +39,21 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{{-- Il contenitore si nasconde, non il pulsante: .tm-btn sta
fuori dai layer di Tailwind e il suo display vince
su "hidden", così su telefono nome ed email
spingevano "Esci" fuori dallo schermo. Il profilo
resta raggiungibile dal menu. --}}
<div class="hidden sm:block">
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo" <x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
class="hidden sm:flex {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}"> class="{{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="flex flex-col leading-tight text-left"> <span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium">{{ auth()->user()->name }}</span> <span class="text-sm font-medium">{{ auth()->user()->name }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span> <span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span> </span>
</x-btn> </x-btn>
</div>
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<x-btn tono="velato" dimensione="sm" type="submit"> <x-btn tono="velato" dimensione="sm" type="submit">
@@ -212,6 +223,11 @@
</main> </main>
</div> </div>
</div> </div>
{{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della
richiesta restano nella fascia in testa al contenuto, qui sopra. --}}
<x-avvisi />
@livewireScripts @livewireScripts
</body> </body>
</html> </html>

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