Files
termanager2/HANDOFF.md
Francesco Picone f1f340803a ++ scheda territorio: aperta dalla Home non mostra lo storico delle assegnazioni
I link della Home portano ?dalla_home=1; la scheda nasconde lo storico
(e non lo carica), con "Mostra lo storico delle assegnazioni" per aprirlo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:52:00 +02:00

1850 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Handoff — TerManager2
Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **20 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
---
## Stato attuale
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
redeploy, login, tutte le pagine, code e scheduler.
| Aspetto | Stato |
|-----------------------------|-------|
| Deploy Dokploy (Compose) | ✅ verificato |
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
| Redeploy senza interventi | ✅ ~25 secondi |
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale |
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
di avanzamento reali e console di log (commit `f909791`, deploy verificato
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
su develop; la produzione non è stata toccata e resta sul suo deploy
precedente, senza quella variabile.
**Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu
Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap,
immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico
versioni. Architettura e agganci sono descritti in [Decisioni
architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
Al primo deploy il compose costruisce anche l'immagine del renderer; non
servono variabili nuove in Dokploy.
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine
si sono staccate dai territori (una piantina vive anche scollegata, il territorio
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98%
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste).
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti**
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
porta](#http_host-a-nginx-deve-conservare-la-porta).
Il 30/07 sono state fatte due cose.
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
dell'immagine di produzione e non girano su un deploy: sono uno strumento
locale.
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
aprendo la pagina.
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
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.
### Gruppi di servizio (19 ottobre 2026, solo su dev)
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
e non vede nient'altro. Scelte dell'utente:
- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo**
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
compreso.
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata".
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
esclude `gruppo_servizio_id`).
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**,
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel
registro e nella scheda del territorio).
- Al **rientro** il territorio esce dal pacchetto e torna in reparto
(hook `updated` in `Assegnazione`, vale da qualunque pagina).
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
pacchetti li gestisce **solo l'amministratore**.
- Utenza: password temporanea da cambiare al primo accesso, **senza
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
sorvegliante). *Dal 20 ottobre l'email è fittizia, creata dall'app — vedi
sotto.*
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi. I territori già dati restano ai membri.
Si riparte con un gruppo nuovo. *Dal 20 ottobre gruppo e utenza del
sorvegliante si eliminano — vedi sotto.*
Decisioni successive dell'utente (20 ottobre 2026):
- **Sorvegliante non più attivo ⇒ gruppo sciolto** e territori non dati
rilasciati, come "Togli il ruolo". La regola sta nel modello
(`Proclamatore::booted`, eventi `updated` su `attivo` e `deleted`), così
vale dall'elenco, dalla modifica, dal cestino e dall'anonimizzazione; il
motivo finisce nel registro (`gruppo_sciolto`, proprietà `motivo`).
L'elenco chiede conferma prima di disattivare o cestinare un sorvegliante;
la modifica lo avvisa sotto la spunta "Attivo". Il cestino e
l'anonimizzazione erano prima bloccati per il sorvegliante: li ho allineati
alla stessa regola. Un membro non attivo resta membro (non riceve
territori); nel cestino esce dal gruppo.
- **Dettagli nel profilo**: la scheda del proclamatore (`ProclamatoreShow`)
mostra il gruppo che guida con i numeri (`GruppoServizio::riepilogo()`),
l'utenza (email solo all'amministratore, password temporanea o no,
ultimo accesso dal registro), oppure il gruppo di cui è membro. Il
*Profilo* del sorvegliante mostra il suo gruppo con gli stessi numeri e
"Torna al mio gruppo".
- **Il gruppo sciolto non resta nello storico**: `sciogli()` lo elimina
(colonna `sciolto_at` tolta dalla migrazione `2026_10_20_000020`, che
elimina anche i gruppi già sciolti). I territori solo nel pacchetto tornano
in reparto (conferma esplicita dell'utente); quelli già dati restano ai
membri come assegnazioni qualsiasi, che conservano il nome del gruppo
(`assegnazioni.gruppo_nome`, "via gruppo …" in registro e scheda
territorio): **confermato dall'utente il 20 ottobre 2026**. Dopo lo
scioglimento si torna all'elenco dei gruppi; nella scheda del proclamatore
non c'è più "In passato sorvegliante di".
- **Email fittizia, niente nome utente** (seconda decisione del 20 ottobre,
che sostituisce il nome utente ricavato dal nome del gruppo fatto qualche
ora prima): creando il gruppo l'email non si chiede,
`GruppiServizio::emailFittizia()` la ricava da iniziale del nome e cognome,
minuscoli, senza accenti, spazi né apostrofi, sul dominio
`GruppiServizio::DOMINIO_EMAIL` = `termanager.it` (Francesco Fontana →
`ffontana@termanager.it`, D'Angelo → `fdangelo@…`, numero in coda se già
presa) — la stessa convenzione degli altri utenti. Il login è di nuovo solo
per email. Il profilo del sorvegliante mostra l'email ma non la fa
cambiare. Il nome del gruppo è unico.
- **Sciolto il gruppo, l'utenza del sorvegliante si elimina** ("torna
proclamatore normale, non tenere traccia"): `User::eliminaConservandoStorico()`
— la stessa eliminazione della pagina *Utenti*, ora in un metodo solo — passa
le assegnazioni che aveva fatto o fatto rientrare a chi scioglie (o al primo
amministratore) e lascia le sue voci nel registro a nome "Sorvegliante del
gruppo …", senza email. Niente più utenze disattivate: via
`users.disattivato_at` e i controlli in `LoginController` e
`LimitaSorvegliante`; una sessione aperta resta senza utente.
`Proclamatore::anonimizza()` non tocca più utenze: se era sorvegliante, il
salvataggio scioglie il gruppo e l'utenza sparisce.
- Migrazione `2026_10_20_000030_sorveglianti_con_email_fittizia`: elimina le
utenze già disattivate (assegnazioni al primo amministratore, registro a
nome "Ex sorvegliante di gruppo"), dà l'email fittizia a chi non l'aveva,
toglie `users.username` e `users.disattivato_at`, rende di nuovo
obbligatoria `users.email`. XML: niente più `<username>` né
`<disattivato_at>`; da un file vecchio non si importano le utenze
disattivate né i gruppi con `<sciolto_at>`.
- **Primo accesso del sorvegliante**: la pagina del cambio password obbligato
diceva sempre "Ripristino da backup eseguito" (era l'unico motivo
previsto). Ora `users.password_temporanea` (migrazione `000040`, che la
accende per i sorveglianti che non hanno ancora cambiato la password)
distingue la password provvisoria data dall'amministratore — creazione del
gruppo, "Nuova password temporanea" — e la pagina dice "Password
temporanea". Si spegne a password cambiata (pagina obbligatoria o
profilo); il ripristino e l'import XML la spengono per tutti, perché lì il
motivo è il ripristino.
- **Bug: ogni clic del sorvegliante finiva in 403.** In Livewire 3.7 la
rotta delle azioni si chiama `default.livewire.update`, e
`LimitaSorvegliante` lasciava passare solo `livewire.*`: ora `*livewire.*`.
I test non lo vedevano perché `Livewire::test()` non passa dai middleware
delle rotte; ora un test chiama la rotta vera.
- **Priorità e filtri** (richiesta dell'utente): la regola della Home ("Da
assegnare": prioritari — spunta o giacenza oltre `giorni_giacenza_prioritari`
— poi giacenza, poi numero) è ora `Territorio::ordinaPerPriorita()`, usata
dalla Home, dall'aggiunta al pacchetto (`GruppoShow`: ricerca, zona,
tipologia, solo prioritari, "Seleziona tutti quelli mostrati") e da "Il mio
gruppo" (prima i da dare in ordine di priorità, poi i già dati dal più
vecchio; filtri stato, zona, tipologia, solo prioritari, nell'indirizzo).
Gli attributi calcolati si chiamano `giacenza_giorni` e `in_priorita`
(erano `home_giorni_giacenza` e `home_is_prioritario`, anche nel PDF).
- "Il mio gruppo" mostra sotto il titolo nome e cognome del sorvegliante.
### Storico assegnazioni in linea (20 ottobre 2026)
Richiesta dell'utente (screenshot della scheda territorio): le colonne dello
storico cambiavano posizione da un anno all'altro, perché ogni anno era una
tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio
e in quella del proclamatore, è **una tabella sola** con un'intestazione,
colonne a larghezza fissa (`table-fixed`, `w-[..%]`), e l'anno come riga di
separazione `tr.tm-riga-gruppo` (in `app.css`, su telefono resta un titolo e
non diventa una scheda). Ordine esplicito: anni dal più recente
(`sortKeysDesc`), dentro l'anno per data di assegnazione. Nella scheda del
proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del
territorio, e c'è il badge "via gruppo …". Test: `StoricoAssegnazioniTest`.
Poi, su richiesta dell'utente: **aperta dalla Home la scheda del territorio
non mostra lo storico**. I link della Home ("Da assegnare", "Da rientrare")
portano `?dalla_home=1` e `TerritorioShow::$storicoNascosto` salta anche la
query; resta "Mostra lo storico delle assegnazioni". Non si usa il `?da=` del
ritorno per capirlo: si trasmette di pagina in pagina, e un territorio aperto
da *Assegna* (a sua volta aperta dalla Home) sembrerebbe venire dalla Home.
### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
Richiesta dell'utente ("un tasto che va alla registrazione", dallo screenshot
della pagina *Assegna* con l'elenco filtrato per proclamatore): in
`ElencoAssegnati` ogni riga ha **Rientro** (solo con `territori.return`), che
apre la registrazione del rientro e, con `data-ritorno`, torna all'elenco.
Anche le righe senza PDF lo hanno. L'elenco ha un tetto di peso per riga
(`AssegnaPrestazioniTest`, 2,6 KB) e il pulsante lo sforava di 21 byte: per
starci l'indirizzo è relativo (`route(..., false)`, `data-ritorno` lo
gestisce) e il pulsante sta sulla stessa riga del `<div>`, senza a capo né
commenti dentro il ciclo (i commenti Blade spariscono, gli spazi no).
- **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta`
("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco
(più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa
alla scheda; nelle opzioni di `<x-scelta>` come `dettaglio` (Assegna,
Registro), non come `motivo`: assegnare direttamente a un membro resta
possibile. In "Il mio gruppo" la nota non c'è (`opzioniScelta(...,
conGruppo: false)`).
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
`users`, come per ogni utente.
Dove sta:
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`
(tolto dalla `000030`), permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto.
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo,
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
rimanda al gruppo; il resto è 403.
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni (id e nome del gruppo) e legame utenza ↔
proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.
- Test: `tests/Feature/GruppiServizioTest.php`. Eseguiti sul server **prima**
del push, su una copia della checkout di dev con i commit portati da un
`git bundle` (la checkout di Dokploy è shallow: il bundle deve partire dal
commit che ha, `git log -1` lì dentro).
Non provato nel browser. **In produzione non c'è**: l'utente ha chiesto
(20 ottobre 2026) di non portarlo ancora in produzione; si aspetta il suo ok.
### 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
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato
solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.**
L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro
il container. Su Dokploy, senza sorgente sull'host, significava build a runtime
a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
Da qui la ristrutturazione descritta sotto.
---
## Dove gira davvero
Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal
repository Gitea `francesco/termanager2` con **autoDeploy attivo**.
| Ambiente | Branch | Dominio | Compose (`appName`) |
|--------------|--------|-------------------------------------|----------------------------|
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` |
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` |
Conseguenze pratiche, tutte imparate sul campo:
- **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione,
che segue `main`. È la separazione su cui contare quando si vuole provare
qualcosa senza rischi.
- **Un "Rebuild deployment" NON scarica il codice nuovo**: ricostruisce il
checkout già presente e ci mette una ventina di secondi. Per prendere l'ultimo
commit serve un deploy vero (`compose.deploy` via API, o *Deploy*
nell'interfaccia). Un rebuild che finisce troppo in fretta è il segnale che si
sta ricostruendo il codice di prima.
- **Le variabili d'ambiente stanno in Dokploy, non nel repository.** Il `.env`
dell'ambiente lo genera l'interfaccia: una variabile aggiunta a
`.env.example` non arriva da sola sui deployment, va aggiunta a mano lì. È
successo con `MAX_UPLOAD_MB`, presente in develop e — deliberatamente — non in
produzione.
- **Verifica che il codice servito sia quello giusto** confrontando l'impronta
degli asset: `public/build/assets/app-*.css` e `-*.js` hanno il nome derivato
dal contenuto, quindi se coincide con quello della propria build, il frontend
in linea è lo stesso sorgente. È il controllo più rapido dopo un deploy, e non
richiede di autenticarsi.
---
## Decisioni architetturali
Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il
contesto per ridiscuterla è qui.
### L'immagine è autosufficiente
`vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e
copiati nell'immagine finale. I container di produzione **non montano il
sorgente** e non eseguono build all'avvio.
Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend,
serve un rebuild dell'immagine, non un semplice restart.
### nginx e php-fpm nello stesso container
Con container separati, nginx avrebbe bisogno di una copia di `public/` e del
volume storage solo per servire file statici. Per questa scala è complessità
senza beneficio. Sono coordinati da supervisor
(`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due
muore definitivamente — altrimenti resterebbe "up" senza servire nulla.
### La configurazione arriva dall'ambiente, non da `.env` riscritto
Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel
legge direttamente l'ambiente di processo, e `docker-compose.yml` si limita a
`env_file: .env` senza ridichiarare nulla.
Il `.env` è quindi l'**unica** fonte di configurazione. Una versione precedente
fissava nel compose i valori dettati dalla topologia (`DB_HOST: mariadb` e
simili) perché vincessero su un `.env` sbagliato, ma richiedeva gli anchor YAML
(`x-app-env`, `<<:`) ed è stata scartata a favore della leggibilità.
Il rovescio della medaglia: `DB_HOST` e `REDIS_HOST` devono valere `mariadb` e
`redis`, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il
container non trova più il database. È documentato in `.env.example` e nel
README, ma è il punto fragile di questa scelta.
Nota: perché la configurazione da ambiente funzioni, `www.conf` ha
`clear_env = no`. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe
nessuna variabile.
### Fail-fast sulle variabili mancanti
Lo stack si rifiuta di partire senza `APP_URL`, `DB_PASSWORD`,
`DB_ROOT_PASSWORD`, `REDIS_PASSWORD`. Le password sono validate
dall'interpolazione compose (`${VAR:?}`), `APP_URL` dall'entrypoint (dove il
messaggio d'errore è più chiaro).
Deliberato: un errore immediato è meglio di un database in produzione con
password `secret`.
### Le piantine sono una sotto-app: renderer esterno, agganci minimi
Le **Piantine** (solo amministratore) generano le mappe dei territori. Si carica
il KML dell'area della città, si disegnano i confini su OpenStreetMap (Leaflet)
nell'editor a schermo intero, e da ogni disegno si ottiene un'immagine ad alta
definizione e — a scelta — la cartolina sul modulo ufficiale S-12
(`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce il PDF del
territorio. Il PDF precedente finisce nella tabella `territorio_pdf_versioni`
ed è ripristinabile dalla pagina di gestione.
Il rendering vero e proprio **non sta nell'applicazione**: lo fa il container
`map-renderer` (`docker/renderer/`, Node + sharp), che scarica le tessere OSM
(cache persistente nel volume `tile_cache`, User-Agent identificante,
attribuzione impressa sull'immagine come da licenza), le cuce, disegna il
confine e attenua l'esterno. L'app gli parla solo via HTTP sulla rete interna
(`config/piantine.php`, default `http://map-renderer:3000`: nessuna variabile
da aggiungere in Dokploy). Se il container è giù, l'unica cosa che smette di
funzionare è la generazione delle immagini; il resto del gestionale non lo sa
nemmeno.
Gli agganci al core sono deliberatamente pochi: la rotta nel gruppo
`settings.manage`, la voce di menu, e l'aggiornamento di
`pdf_path`/`thumbnail_path` sul territorio quando si sostituisce un PDF (stesso
percorso `territori-pdf/` + `TerritorioThumbnailService` dell'upload manuale).
Le geometrie vivono nella tabella separata `territorio_mappe`, l'area KML in
`storage/app/piantine/`: rimuovere la sotto-app significa togliere questi
pezzi, non disfare il gestionale.
**Il disegno viene prima del numero.** Una piantina non ha bisogno di un
territorio: `territorio_mappe.territorio_id` è nullable e il collegamento si fa
(e si disfa) quando si vuole, con `nome` come etichetta finché manca. Si taglia
l'area guardando le strade e solo dopo si decide di chi è — e se quel territorio
non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
`territori` come fa ogni import XML, non porta più via i disegni.
**Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
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`).
**Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
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 è
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
pagina 1 è il modulo con Località, numero e i **confini scritti** nello spazio
centrale (il corpo del testo si restringe da solo finché ci sta), pagina 2 è la
piantina sola al 98% della pagina.
**La "Località" del modulo è il nome della zona.** Era un campo globale, poi un
campo della piantina, e in entrambi i casi era un doppione: la zona esiste già,
dice la stessa cosa («Palermo Montepellegrino») ed è quella che si aggiorna
quando la congregazione riorganizza il lavoro. Due posti dove scrivere lo stesso
nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
marca i territori collegati senza zona: senza, l'intestazione della cartolina
esce vuota e ce se ne accorge in stampa.
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
- Il renderer compone alle tessere dello zoom più alto che entri in
`max_side × 2` e poi riduce con Lanczos. Le etichette OSM sono disegnate
dentro la tessera a dimensione fissa: salire di uno zoom le rende più
definite, e ridurre dopo porta quella nitidezza dentro l'immagine finale.
- Chiede al renderer le **proporzioni della pagina** (`aspetto`): un territorio
quasi quadrato su una cartolina orizzontale, tenendo le sue proporzioni,
lascerebbe due fasce bianche larghe un quinto di foglio. Allargando il
ritaglio si mostrano più dintorni invece di carta vuota.
- JPEG a qualità 86 invece di PNG: 0,9 MB invece di 5 per territorio. Con 274
territori è la differenza fra qualche centinaio di MB e diversi GB.
L'attribuzione OSM si dimensiona sull'immagine finale, non su una misura fissa:
22 px su 3400 sarebbero meno di un millimetro stampato, cioè presenti nel file
ma invisibili a chi tiene la cartolina — e la ODbL la vuole leggibile. Per lo
stesso motivo il tratto del confine è espresso in pixel dell'immagine finale e
riportato alla scala di composizione: deve dire dove passa il confine senza
cancellare la via che ci corre sotto, che è proprio quella che serve leggere.
### Il sistema visivo sta nei token, non nelle viste
`resources/css/app.css` **ridefinisce le scale `gray` e `indigo` di Tailwind**
dentro `@theme`. È la cosa che sorprende di più aprendo il file, ed è
deliberata: le trentasette viste sono scritte con le utility (`text-gray-600`,
`bg-indigo-50`), e cambiare la scala le riallinea tutte insieme. L'alternativa
— riscrivere le classi vista per vista — a ogni file dimenticato lascia in giro
il grigio di prima accanto a quello nuovo, e nessuno se ne accorge finché non
apre proprio quella pagina.
Da qui tre conseguenze che vanno sapute prima di metterci mano:
- **I grigi sono caldi.** Quelli di serie di Tailwind tirano al blu e, sommati a
un accento indaco, davano all'interfaccia una dominante fredda. Questi elenchi
diventano moduli di carta che passano di mano: un fondo caldo li avvicina a
quello che sono. Non mescolare grigi freddi qui dentro.
- **L'accento è uno.** Indaco = azione, verde = fatto, ambra = in sospeso,
rosso = distrugge dati. Se una cosa non rientra in queste quattro, è grigia.
Prima le tinte erano cinque e decorative.
- **Il carattere è servito dall'applicazione** (`@fontsource-variable/figtree`,
dipendenza npm), non da Google Fonts. Un gestionale che tiene nomi e indirizzi
di persone non deve chiamare un dominio di terze parti a ogni pagina caricata,
e così funziona anche senza rete.
I componenti `tm-*` (pulsanti, console, card, campi, elenchi, editor) stanno
tutti qui e **fuori da `@layer`**, quindi vincono sulle utility: un pulsante non
si può ritoccare a metà con le classi. Chi aggiunge una schermata usa questi,
non catene di utility nuove.
### `HTTP_HOST` a nginx deve conservare la porta
`docker/nginx/default.conf` rimette `fastcgi_param HTTP_HOST $http_host;` dopo
l'`include fastcgi_params`. Non e' ridondanza: dalla 1.26 il `fastcgi_params` di
sistema usa `$host`, che la porta la butta via. Con `APP_URL` su porta non
standard — cioe' lo sviluppo locale su `:8090` — Laravel ricostruisce l'URL
della richiesta senza porta mentre `URL::forceRootUrl()` genera i link con, e
ogni URL firmato non valida piu': **tutti** gli upload di file rispondono 401,
perche' l'endpoint di Livewire aborta sulla firma. In produzione `APP_URL` non
ha porta e le due forme coincidono, quindi il guasto si vede solo in locale.
Inoltrare l'header grezzo e' sicuro perche' `trustHosts()` in `bootstrap/app.php`
rifiuta gia' gli Host che non siano quello di `APP_URL`, `localhost` o
`127.0.0.1`.
### Modifica/cancellazione delle assegnazioni: solo amministratore
L'unico punto in cui un'assegnazione **esistente** si modifica o si cancella è
il CRUD del Registro (`Livewire/Registro`: `openCreate`/`openEdit`/`save`/
`askDelete`/`deleteConfirmed`), blindato su `settings.manage` a due livelli —
`abort_if(..., 403)` nel componente e `@can('settings.manage')` in
`registro.blade.php`, che nasconde pulsanti e modali. `settings.manage` ce l'ha
solo l'`amministratore`.
Di conseguenza gli altri ruoli **non** possono alterare le assegnazioni: la
pagina Assegna solo ne crea (`territori.assign`), la pagina Rientra solo le
chiude (`territori.return`). Nello specifico l'`assistente` (`registro.view` +
`territori.assign` + `territori.return`) vede il Registro, crea nuove
assegnazioni e le fa rientrare, ma non le modifica né le elimina.
Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su
`syncPermissions()` più sotto), un permesso `settings.manage` concesso a mano
all'`assistente` in passato sopravviverebbe a ogni re-seed. La migration
`2026_07_25_000001_revoke_settings_manage_from_assistente_role` chiude questo
drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente`
se presente. È idempotente (no-op sul baseline) e volutamente irreversibile —
il `down()` non ripristina il permesso.
Stesso principio per le **campagne**: creazione e visualizzazione restano su
`campagne.manage` (che ha anche l'assistente), ma **modifica ed eliminazione
sono riservate all'amministratore** (`settings.manage`). Il gate è su tre
livelli: la route `campagne/{campagna}/edit` è nel gruppo `settings.manage`;
`CampagnaEdit::mount()`/`save()` e `CampagnaIndex::deleteCampagna()` chiamano
`$this->authorize('settings.manage')` (l'index è raggiungibile da chi ha
`campagne.manage`, quindi il delete va protetto nel metodo, non solo in route);
i pulsanti Modifica/Elimina in `campagna-index`/`campagna-show` sono sotto
`@can('settings.manage')`. Nota: `Campagna` non ha soft delete — l'eliminazione
è definitiva.
Per i **territori** vale un modello leggermente diverso, perché la
visualizzazione va aperta a tutti gli operativi: lista (`territori.index`) e
dettaglio (`territori.show`) sono ora su `territori.assign` (che hanno admin,
assistente e operatore); creazione/modifica/cestino restano su `territori.manage`
(solo admin). Attenzione a due punti:
- **Ordine delle rotte.** Le rotte letterali `territori/create` e
`territori/cestino` devono restare registrate PRIMA del wildcard
`territori/{territorio}`, altrimenti verrebbero catturate dalla rotta di
dettaglio.
- **`TerritorioIndex` perde la copertura del middleware persistente.** Il suo
componente è ora servito da una rotta rilassata (`territori.assign`), quindi i
suoi metodi di mutazione (`toggleActive`, `togglePriority`, `deleteTerritorio`)
NON sono più protetti dal middleware di route e hanno un
`$this->authorize('territori.manage')` interno — senza, un operativo potrebbe
invocarli via `/livewire/update` col pulsante nascosto. Per difesa in
profondità lo stesso check è stato aggiunto anche a `TerritorioCreate::save`,
`TerritorioEdit::save`/`removePdf`, `TerritorioCestino::restore`/`forceDelete`
(che restano comunque su rotte `territori.manage`). I pulsanti
Modifica/toggle/Elimina/Nuovo/Cestino in `territorio-index`/`territorio-show`
sono sotto `@can('territori.manage')`; il link "Dettaglio" resta visibile a
tutti. Il menu laterale mostra "Territori" a chi ha `territori.assign`.
### Sola lettura per proclamatori e campagne (non-admin)
Stessa impostazione dei territori estesa ad altre due sezioni:
- **Proclamatori**: `index` e `show` restano su `proclamatori.manage` (l'assistente
li vede in sola lettura); `create`/`edit`/`cestino` e tutte le mutazioni
(`ProclamatoreIndex::toggleActive`/`deleteProclamatore`/`anonimizza`,
`ProclamatoreCreate::save`, `ProclamatoreEdit::save`,
`ProclamatoreCestino::restore`/`forceDelete`) richiedono `settings.manage`.
`ProclamatoreIndex` è servito da una rotta rilassata, quindi i suoi metodi di
mutazione hanno il check interno; i pulsanti in `proclamatore-index`/`-show`/
`-cestino` sono sotto `@can('settings.manage')`. Le rotte letterali
create/cestino restano prima del wildcard `{proclamatore}`.
- **Campagne**: la **creazione** è ora admin-only come già modifica/eliminazione
— `CampagnaCreate::save` chiama `authorize('settings.manage')`, la rotta
`campagne/create` è nel gruppo `settings.manage` (registrata prima del
wildcard) e il pulsante "Nuova Campagna" è sotto `@can('settings.manage')`.
`index`/`show` restano su `campagne.manage` (sola lettura per l'assistente).
### Tabelle-elenco responsive su mobile (portrait)
Le liste principali usano una sola "politica" CSS invece di layout duplicati:
la classe `table-cards` (in `resources/css/app.css`) sotto `640px` nasconde il
`thead` e trasforma ogni riga in una scheda, con ogni cella resa come
"ETICHETTA … valore" (l'etichetta viene dall'attributo `data-label` della `<td>`).
Un telefono in portrait sta sotto la soglia → schede; ruotato in landscape la
larghezza supera i 640px → torna la tabella normale (per questo landscape "non ha
problemi"). Convenzioni: ogni `<td>` dati ha `data-label="..."`; la cella azioni
ha classe `cell-actions` (niente label, occupa la riga a piena larghezza); le
celle `colspan` (stato vuoto, form di modifica utente inline) restano `block` a
piena larghezza. Applicata a tutte le liste e sottopagine con tabelle: territori,
proclamatori, campagne, registro, utenti, audit-log, i due cestini, le tabelle
"storico assegnazioni" di territorio-show/proclamatore-show, "territori
conteggiati" di campagna-show e le due tabelle di esito import in xml-exchange
(una a classi Tailwind, una a stili inline — `border:none !important` nella regola
gestisce anche i bordi inline). La pagina *Assegnazioni* era già ottimizzata.
### Scadenza link PDF unificata con la soglia rientro
La "Validità link PDF (mesi)" è stata rimossa: il link condivisibile ora scade
esattamente quando l'assegnazione raggiunge la **soglia rientro**
(`giorni_per_smarrito`), cioè `assigned_at + giorni_per_smarrito` — la stessa
data di `Assegnazione::data_consegna_presunta` e del chip "Scadenza" nel viewer.
`AssignmentPdfController::linkScaduto` e `ShortPdfLinkController` calcolano ora
`addDays(giorni_per_smarrito)` invece di `addMonths(ttl)`; la scadenza vale solo
per i non autenticati e il link muore comunque al rientro (`is_aperta`). Rimossi
il campo Impostazioni e la proprietà `assignment_link_ttl_months` in
`SettingsEdit`. **La colonna `assignment_link_ttl_hours` resta nel DB** (niente
migration distruttiva): è ormai vestigiale per la logica ma l'export/import XML
continua a leggerla/scriverla senza effetti.
### Ricerca rapida nella lista link (pagina Assegna)
La sezione "Territori Assegnati" (i link PDF) ha un unico campo che filtra per
numero territorio, zona e proclamatore. Il filtro è **in memoria** (proprietà
`linkSearch`, collection `assegnazioniAperteVisibili`) perché nome/cognome del
proclamatore sono cifrati e non filtrabili via SQL — stesso approccio del
Registro. La sezione resta visibile anche a risultato vuoto (mostra "Nessun
risultato"), perché l'`@if` esterno guarda la lista non filtrata.
### Assegnare un territorio ne azzera il flag "prioritario"
`prioritario` è un flag **manuale**; `is_prioritario` è true se il flag è acceso
*oppure* se la giacenza supera la soglia — ma la parte a soglia vale solo per i
territori *in reparto* (`scopePrioritari`/`getIsPrioritarioAttribute` filtrano su
`inReparto`). Quindi un territorio assegnato può risultare prioritario **solo**
per via del flag manuale. Regola: alla creazione di un'assegnazione **aperta** il
flag viene spento. È implementata come evento `Assegnazione::created` (in
`booted()`), guardato su `returned_at === null` e `prioritario === true`, così
copre ogni via di creazione (pagina Assegna, CRUD del Registro, import XML) senza
duplicare la logica. Interazione con l'import XML: per i backup creati dopo questa
regola è un no-op (nessun territorio assegnato è prioritario); su backup vecchi
con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e
quale.
### Profilo utente, viewer PDF, privacy admin-only
Tre ritocchi minori ma con qualche implicazione:
- **Pagina profilo** (`Profile` → rotta `profilo`, nome `profile`) accessibile a
ogni utente loggato (nel gruppo `auth`, nessun permesso): l'utente corrente può
modificare il proprio nome/cognome ed email (`updateProfile`) e cambiare la
password (`updatePassword`, con verifica della password attuale via regola
`current_password`; il cast `hashed` cifra al salvataggio). Dopo il salvataggio
dei dati `updateProfile` fa `redirect(navigate:true)` sulla stessa pagina, così
header (nel layout, fuori dal componente) e card mostrano subito i nuovi valori.
Il link al profilo è nell'header su desktop e, su mobile, in fondo alla sidebar
(`lg:hidden`, dopo un separatore) — sull'header è `hidden sm:flex`. Non esiste
un campo "username" né "cognome" separato: `name` è il nome completo
(etichettato "Nome e cognome") e l'email fa da identificativo. La qualifica
(ruolo) non è modificabile dal profilo — resta in gestione all'admin.
- **Viewer PDF pubblico**: oltre a territorio/proclamatore/data mostra ora
*giorni assegnato*, *consegna prevista* e *scadenza*. Sono attributi calcolati
su `Assegnazione` (`data_consegna_presunta`, `giorni_alla_scadenza`) basati
sulla soglia `giorni_per_smarrito` — la stessa oltre cui `Territorio::stato`
diventa `da_rientrare`. Il viewer è servito solo per assegnazioni aperte, ma
gli accessor gestiscono comunque il caso `returned_at` (ritornano null).
- **Privacy solo admin**: la rotta `privacy` è passata dal solo `auth` al gruppo
`permission:settings.manage` e il link in sidebar è sotto
`@can('settings.manage')`.
### Backup completo `.zip`, ripristino, azzeramento
L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un
ripristino su volumi vuoti lascia ogni territorio che punta a un file
inesistente — link pubblici a 404 e miniature rotte. Il pacchetto `.zip`
(`Services/BackupArchive`, `Http/Controllers/Settings/BackupController`) mette
insieme `manifest.json`, `data.xml` (lo stesso XML dell'export) e `storage/…`
con **tutti** i file del disco `public`. L'export XML resta, etichettato come
parziale.
Scelte non ovvie:
- **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file
base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci
sta. `backup/file/{op}` e `backup/restore` sono due rotte normali nel gruppo
`settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
per la memoria del processo.
- **Tutto in coda, con avanzamento reale.** Backup, ripristino e azzeramento
sono job (`Jobs/CreateBackupArchive`, `RestoreBackupArchive`,
`ResetApplication`) che scrivono su `Services/BackupOperationState`, e
`Livewire/Settings/BackupManager` lo interroga con `wire:poll`. In coda
perché un'installazione con qualche centinaio di PDF supera i 300 secondi di
`fastcgi_read_timeout` e il browser vedrebbe cadere la connessione a metà
compressione, senza modo di distinguerlo da un errore.
La barra conta unità di lavoro completate, non tempo. Per la compressione
questo costa qualcosa: `ZipArchive` non scrive niente finché non si chiama
`close()`, quindi contare le `addFile()` porterebbe al 100% in un istante per
poi restare fermo per tutta la scrittura. `BackupArchive::create()` chiude e
riapre l'archivio a lotti (`FLUSH_MASSIMI = 20`): si paga una ricopiatura
delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio
ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è
`totale/20`, così la ricopiatura resta limitata a ~10 volte la dimensione
dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono
cicli nostri, lì l'avanzamento è per-file senza artifici.
- **L'azzeramento ha due tempi, separati dal download.** Il pulsante nella zona
pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in
`attesa-download`; la cancellazione parte da un secondo clic esplicito
(`confermaAzzeramento`), che rifiuta se lo stato non registra un
`servito_at` e se non riceve la password dell'amministratore. Il primo
tentativo faceva partire il reset dalla coda dello
`streamDownload`, "dopo l'ultimo byte" — **non funziona**: con
`fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo
disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi
prosegue anche se il browser ha chiuso la connessione al primo byte. Un
download annullato azzerava lo stesso. Il server può verificare di aver
*spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore,
e per questo la conferma è umana.
- **La password si richiede al secondo clic, e non passa dallo snapshot.** La
sessione è già autenticata, quindi la password non serve a stabilire *chi*
sia: serve a stabilire che davanti allo schermo ci sia ancora quella persona
— una scheda lasciata aperta su una postazione condivisa è lo scenario contro
cui protegge. Viaggia come **parametro** di `confermaAzzeramento($password)`,
non come proprietà `wire:model`: le proprietà pubbliche di Livewire vengono
serializzate nello snapshot e rispedite al browser a ogni risposta, e una
password non ha motivo di fare quel viaggio di ritorno.
Attenzione al dettaglio che costa un pomeriggio: la proprietà
`$passwordConferma` **esiste comunque, e resta sempre vuota**. Livewire
scarta dalla risposta ogni errore la cui chiave non corrisponda a una
proprietà del componente (`SupportValidation::dehydrate()` filtra su
`Utils::hasProperty`), quindi senza quella riga `addError('passwordConferma',
…)` sparisce in silenzio: il pulsante sembra non fare nulla, mentre il
controllo sta funzionando. Non collegarla al campo con `wire:model`, o la
password torna nello snapshot.
Cinque tentativi sbagliati in 5 minuti bloccano l'azione (`RateLimiter`,
chiave per utente). Senza, la richiesta di password su una sessione dirottata
sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account:
nessuno resta chiuso fuori dall'applicazione.
- **Una sola operazione alla volta, con blocco che si autoripara.** Backup,
ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso
disco: due in parallelo — due schede, due amministratori — non si rallentano
soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto
unico; lo controllano sia il componente Livewire sia
`BackupController::upload()`, che sono due porte d'ingresso separate (la
seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e
`corrente()` lo considera libero se l'operazione puntata non esiste più o è
terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il
worker uccide il job per timeout o errore fatale, cioè proprio i casi che
`handle()` non può intercettare — che chiude l'operazione e libera il posto
invece di lasciare una barra ferma per un'ora.
- **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()`
legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo
faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di
conferma diventava irraggiungibile: nessuno poteva più né confermarlo né
annullarlo, e il posto restava occupato fino alla scadenza.
- **L'esito del ripristino si legge senza consumarlo.** Il job non ha una
sessione in cui lasciare un flash, quindi deposita il messaggio in cache a
nome di chi ha avviato il ripristino e la schermata di cambio password lo
mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un
`pull`: quando è il middleware a dirottare una richiesta di Livewire, il
client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio
— per poi rifare la stessa GET con `window.location`, che troverebbe la cache
già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del
proprio ripristino.
- **Il tetto degli upload è una variabile sola, `MAX_UPLOAD_MB`.** Prima viveva
in tre posti scollegati — `client_max_body_size` in
`docker/nginx/default.conf`, `upload_max_filesize`/`post_max_size` in
`docker/php/php.ini`, e la regola `max:` nella validazione — e bastava che
uno restasse indietro perché il più piccolo vincesse nel modo peggiore.
Adesso `entrypoint.sh` genera i primi due (`conf.d/upload-limit.conf` e
`conf.d/zz-upload-limit.ini`) e `config/app.php` alimenta la terza.
**Non rimettere `client_max_body_size` nel blocco `server` di
`default.conf`**: una direttiva lì dentro vince su quella generata a livello
http e reintroduce in silenzio proprio il disallineamento che questa
disposizione esiste per evitare. La validazione tiene un 2% di margine sotto
il valore di nginx, così a fermare un pacchetto fuori misura è Laravel, che
sa spiegarsi, e non nginx.
Il sintomo, quando il limite viene superato, non assomiglia affatto a un
errore di dimensione: nginx risponde **413 dopo aver accettato zero byte** e
chiude. Il browser ha appena iniziato a spedire, la barra si pianta a una
percentuale bassa e nessun evento utile arriva all'`XMLHttpRequest` — pagina
muta, con l'aria di essersi bloccata. Da qui i tre presidi nella vista:
controllo della dimensione *prima* di caricare, `xhr.timeout` con i relativi
gestori di `timeout`/`abort`/`error` (senza, un caricamento fermo non produce
più nulla per sempre), e il codice HTTP dentro il messaggio, perché 413 e 419
non hanno un corpo JSON da cui ricavare una spiegazione.
- **La console di log non è un vezzo diagnostico.** Quasi tutti i modi in cui un
caricamento muore sono muti: Cloudflare o nginx chiudono la connessione e
nessun evento del browser porta con sé una spiegazione. Il pannello *Console*
nella scheda di ripristino registra file scelto e dimensione, limite del
server, avanzamento ogni 10%, codice HTTP della risposta, errori, timeout e
interruzioni; sotto, i log lato server dell'operazione. Un battito ogni 15
secondi scrive «nessun avanzamento da N s», che è l'unico modo di distinguere
*lento* da *fermo*. Il pulsante *Copia tutto* esiste perché il primo passo di
ogni diagnosi è farsi mandare quel testo.
- **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
disco è l'unico passo che può fallire per cause esterne (permessi sul
volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un
errore sui file lascerebbe database azzerato e disco pieno di PDF orfani.
Collegato: `svuotaDiscoPubblico()` ricontrolla il disco dopo la cancellazione
e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale.
Flysystem non solleva se una `unlink` fallisce, e basta un file scritto da un
processo con un altro utente (l'entrypoint lavora da root, php-fpm e il
worker da www-data) perché l'operazione si dichiari riuscita lasciando il
disco popolato.
- **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di
cui non ci si accorge che era rotto finché non serve, quindi
`BackupArchive::create()` non si fida di aver scritto bene: controlla il
valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se
il file è sparito o è illeggibile — l'archivio finirebbe con un buco
silenzioso), e alla fine riapre l'archivio e verifica che contenga
`data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non
torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun
backup che un backup che sembra buono.
Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito
l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or
uninitialized Zip object`. Una chiusura di cortesia dentro un `catch`
sostituiva il messaggio vero con quello, e l'amministratore leggeva una
stringa incomprensibile al posto di «permesso negato su questo file». Da qui
`chiudi()`, che cattura `getStatusString()` prima che vada perso, e i
`close()` di pulizia avvolti in un try/catch che non può mascherare l'errore
originale.
- **Eliminare definitivamente un territorio porta via anche i suoi file.**
`TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e
miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da
nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono
*prima* della riga, così se la cancellazione fallisce il territorio resta nel
cestino e l'operazione si può ritentare.
- **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
appena creata ha un amministratore; cancellarli tutti lascerebbe
l'applicazione senza nessuno che possa accedere fino al riavvio del container
(l'admin iniziale lo ricrea `entrypoint.sh` da `INITIAL_ADMIN_*`, che in
esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta
via Eloquent, non con un delete di massa: è il trait `HasRoles` di Spatie a
staccare ruoli e permessi nell'evento `deleting`, che un delete di massa non
fa scattare. Il registro attività si svuota **per ultimo** — quelle
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
sopravvive, quella dell'azzeramento stesso.
- **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026
(`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il
`piantine.json` del formato 2) prima di toccare qualcosa; poi una sola
`DB::transaction` contiene import XML, tabelle delle piantine, blocco degli
account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`:
solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa
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
numero di entry, dimensione della singola entry e totale decompresso, pochi
MB possono riempire il volume (zip bomb). I nomi delle entry sono
normalizzati e ogni `..` o percorso assoluto viene rifiutato (zip slip) —
senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione.
- **Dopo un restore tutti cambiano password.** Il pacchetto contiene gli hash
bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack
offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il
restore accende `password_change_required` su **tutte** le righe di `users`
(amministratore che sta ripristinando compreso) e azzera i `remember_token`,
che sono credenziali persistenti e stanno pure loro nel backup. Il
middleware `RequirePasswordChange` (in coda al gruppo `web`, quindi copre
anche `/livewire/update` — Livewire segue le risposte redirezionate) dirotta
su `password.change` tutto tranne quella pagina, il suo invio e il logout.
- **La schermata di cambio password non è Livewire e non chiede la password
attuale.** Non è Livewire perché deve restare raggiungibile mentre il
middleware respinge anche le POST di Livewire, e distinguere quale
componente sta dietro una `/livewire/update` è fragile; due rotte HTTP si
escludono per nome. Non chiede la password attuale perché il restore ha
appena sovrascritto gli hash con quelli del backup: la "password attuale" di
un account è quella dell'epoca del backup, che l'utente — l'amministratore
per primo — può benissimo non ricordare. La garanzia è la sessione: a quella
pagina ci si arriva già autenticati.
### Export/Import XML: backup completo e round-trip
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include
ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`),
i campi settings prima omessi (`public_base_url`, `logo_path`,
`pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete
(`deleted_at`) di proclamatori/territori, i path file dei territori
(`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni
(`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`,
`pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità.
L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così
un export si reimporta senza perdite.
Tre scelte non ovvie:
- **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si
basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione
SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import
**perdeva territori e assegnazioni** (riferimenti non risolti). Ora
`addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale.
- **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità
vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users`
durante un restore rischierebbe di **estromettere l'amministratore** che sta
importando (backup che precede il suo account, o file parziale). Gli account
esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene
eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è
senza perdite e non blocca fuori nessuno.
- **Hash password e timestamp scritti col query builder.** Dopo il `save()` del
modello, password/`remember_token`/`email_verified_at`/`created_at`/
`updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash
esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un
hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i
timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità.
### La pagina "Backup e import": cinque zone e una sola console
La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato,
la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che
ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a
tre regole.
**Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`,
`2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha
un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test
verifica che le cinque ancore esistano *e che siano in quell'ordine nel
sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico.
**Il "focus" della pagina.** Premuto un pulsante — o scelta una voce
dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare
(`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la
richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il
cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona
è marcato `data-zona-focus` ed è quello che lo riceve.
**Una sola console, agganciata in fondo alla finestra.** Prima la stessa
operazione si raccontava in tre punti diversi: un ripristino cominciava nel
riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio"
richiudibile nel pannello di avanzamento, in cima. Per capire dove si era
fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di
tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti
i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è
interrotto il flusso di righe — un log che esce dallo schermo appena si scorre
non serve proprio nel momento in cui servirebbe.
Dettagli non ovvi della console:
- **Le righe del server viaggiano già scremate.** Il conto di quante ne sono
state spedite lo tiene il componente Livewire (`logSpediti`), non il browser:
la coda intera dentro ogni risposta significherebbe sessanta righe poco meno
di due volte al secondo, e per l'import PDF — la cui coda non ha tetto —
l'intera cronologia a ogni poll.
- **Per ogni operazione c'è un solo componente che la racconta**
(`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo
stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi
le stesse righe, e la console le mostrerebbe due volte.
- **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un
evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero
`window.location.href` due volte, e il pacchetto verrebbe scaricato due volte.
L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo
stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo:
il segno "download già inviato" sta nello stato condiviso, quindi chi segue
l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto
e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca
la cancellazione.
- **Lo scorrimento automatico si sospende se si torna indietro a leggere.**
Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è
il modo più rapido di rendere inutile un log che scorre. Compare invece
"Torna in fondo".
- **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in
fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa.
- **Il JavaScript della console sta in un `<script>`, non dentro `x-data`.** È
la via d'uscita dalla trappola documentata più sotto: dentro un `<script>` le
virgolette doppie e gli apostrofi non hanno alcuna limitazione. Non sta in
`resources/js/app.js` perché quel bundle è un modulo ES, che il browser rimanda
a dopo il parsing del documento, mentre Alpine arriva col pacchetto di Livewire
in fondo al `<body>` ed è già avviato a quel punto: una `Alpine.data()`
registrata dopo l'avvio non viene applicata agli elementi già inizializzati. Un
`<script>` classico nella pagina viene eseguito prima, sempre — e c'è un test
che verifica proprio che la definizione preceda `livewire.js`, perché è un
rapporto che guardando i due file non si vede.
**Un solo sistema di pulsanti** — vedi la sezione dedicata qui sotto.
### Un solo pulsante per tutta l'applicazione
`resources/css/app.css` definisce `.tm-btn`;
`resources/views/components/btn.blade.php` è l'unico posto da cui si usa. Nessuna
vista scrive quelle classi a mano, e nessuna vista definisce più un pulsante per
conto proprio.
Da dove veniamo: **quattro vocabolari in circolazione insieme** — `style=`
inline con i colori scritti a mano (più `onmouseover`/`onmouseout` per simulare
l'hover), catene di utility Tailwind ripetute a memoria, `.btn-action*` in sei
tinte e `.btn-primary-*` in due. La stessa azione cambiava aspetto da una pagina
all'altra, l'ordine di *Salva* e *Annulla* si invertiva fra un form e l'altro, e
**nessuno** di quei pulsanti aveva uno stato di focus: chi naviga da tastiera non
vedeva dove si trovava.
Il sistema ha due assi ortogonali, non una tavolozza:
| Asse | Valori | Regola |
|------|--------|--------|
| `variante` | `primary` | l'azione principale. Una sola per schermata o zona. |
| | `secondary` | tutto il resto: dettaglio, modifica, scarica, filtra. |
| | `danger` | **solo** dove si cancellano o si sovrascrivono dati. |
| | `ghost` | annulla, chiudi, pulisci. |
| `tono` | `solido` | il pieno (default), per le azioni isolate. |
| | `tenue` | la tinta chiara, per le righe di elenco. |
| | `velato` | per i comandi sopra una superficie colorata: solo la testata. |
più `dimensione="sm"` per le righe e `icona` per i pulsanti di sola icona.
Le decisioni non ovvie prese convertendo:
- **`tenue` esiste per le liste.** In una riga i pulsanti sono tanti quanti i
record: cinquanta pulsanti pieni sono rumore, e il rosso pieno smette di
significare "attenzione" quando compare cinquanta volte di fila.
- **Il rosso è tornato a significare una cosa sola.** *Rientra* era rosso pieno
quanto un *Elimina*, in tre pagine diverse; anche *Scarica PDF* nella scheda
"Da rientrare" della dashboard lo era, solo perché lo è la scheda che lo
contiene. Nessuno dei due cancella niente.
- **Ordine unico: azione principale prima, uscita dopo.** Valeva già in metà dei
form, l'opposto nell'altra metà, e nelle modali del Registro c'era la terza
variante. Ora è lo stesso ovunque, form e modali comprese.
- **`icona` richiede `aria-label`.** I pulsanti di sola icona avevano solo un
`title`, che i lettori di schermo annunciano in modo inaffidabile: un pulsante
senza nome accessibile è un pulsante che non si può usare senza vederlo.
- **`velato` non è una quinta variante inventata.** La testata ha il suo
gradiente, e lì un pulsante bianco o indaco sarebbe una toppa: il colore lo dà
lo sfondo. Ha anche un anello di focus bianco, perché quello blu su indaco non
si distingue.
Attenzione a una trappola di Blade: dentro un tag di componente si scrive
`:disabled="$condizione"`, **non** `@disabled($condizione)`. Le direttive non
vengono compilate lì dentro: il tag `<x-btn>` resta letterale nel PHP generato
mentre il `</x-btn>` viene chiuso, e la vista muore con «syntax error, unexpected
token endif».
A tenere insieme tutto questo ci sono due test, perché una convenzione senza
guardia dura fino alla prossima pagina scritta copiando una vecchia:
- `tests/Unit/StileUnificatoTest.php` legge i sorgenti delle viste e rifiuta il
ritorno di `.btn-action*`, `.btn-primary-*`, `onmouseover`/`onmouseout`, degli
sfondi scritti a mano su `<button>`/`<a>`, e delle classi `tm-btn` messe
direttamente in una vista invece che dal componente.
- `tests/Feature/PagineApplicativeTest.php` apre **ogni** pagina e verifica che
non sia rimasto un `<x-btn` da compilare — perché un componente non compilato
non solleva: semplicemente il pulsante non c'è.
### Perché l'azzeramento è un componente a sé
`Settings/ZonaPericolosa` è nato da un problema di sola disposizione: la zona
pericolosa deve stare **in fondo** alla pagina, mentre backup e ripristino stanno
in cima, e i tre erano lo stesso componente. Reso da `BackupManager` l'azzeramento
finiva a metà pagina, fra il ripristino e l'import — l'ultimo posto in cui si
vuole trovare un pulsante che cancella tutto.
La logica condivisa non è stata duplicata: sta nel trait
`Livewire/Concerns/SegueOperazioneBackup` (posto unico, aggancio all'operazione
in corso, download automatico, righe verso la console). Duplicare `postoLibero()`
avrebbe significato duplicare la regola su cui si regge il fatto che due
operazioni non si sovrascrivano: è l'ultimo posto dove ha senso avere due copie.
Conseguenza da conoscere: **due componenti pollano lo stesso stato**. Il
proprietario a 750ms (5s in attesa-download), l'altro a 3s — non ha righe da
raccontare, ma deve accorgersi che il posto si è liberato, altrimenti il suo
pulsante resterebbe disabilitato fino al ricaricamento della pagina, con l'aria
di essersi rotto. Si avvisano anche a evento (`operazione-avviata`), indirizzato
con `->to()` e non gridato: senza, tornerebbe pure al mittente.
Il secondo tempo dell'azzeramento (verifica del backup, password, conferma)
compare adesso **dentro la zona**, dove è stato premuto il primo. Prima usciva
nel pannello di avanzamento in cima, a schermate di distanza dal pulsante che lo
aveva avviato.
### La suite di test ha un'immagine sua
Le dipendenze di sviluppo non stanno nell'immagine di produzione, e non devono
cominciare a starci: `--no-dev` è la ragione per cui il runtime non porta con sé
PHPUnit, Faker e Ignition. Da qui uno stage `test` nel `Dockerfile`, che
condivide con `runtime` lo stage `base` — stesse estensioni, stesso `php.ini` —
e si distingue solo per il `vendor/` completo. Un test che passa lì dentro non
può passare grazie a un ambiente diverso da quello vero.
Le scelte che vale la pena conoscere:
- **`runtime` deve restare l'ultimo stage del file.** `docker build` senza
`--target` costruisce l'ultimo, ed è così che lo invocano sia i tre servizi
del compose (nessuno dichiara `target`) sia Dokploy. Uno stage aggiunto in
fondo diventerebbe **in silenzio** l'immagine di produzione. Per questo `test`
sta *prima* di `runtime` pur essendo stato aggiunto dopo. L'avvertenza è anche
nel Dockerfile, sopra lo stage.
- **La suite non dipende da niente.** `phpunit.xml` impone SQLite in memoria,
cache `array` e code `sync`, e il servizio compose non ha `depends_on` né
`env_file`. Gira a stack spento, non tocca `db_data` né `storage_data`, e
soprattutto non può puntare per sbaglio al database di sviluppo: un `.env`
ereditato dal container è esattamente il modo in cui una suite di test
cancella dati veri.
Il rovescio: **SQLite non è MariaDB**. Resta un solo punto non verificabile
qui, l'ordinamento di `Registro::render()`, che usa `CAST(... AS UNSIGNED)`:
va provato sullo stack vero. Gli altri due sono stati tolti di mezzo perché
costavano poco e in cambio rendevano testabile una pagina intera — la
migrazione `2026_04_08_123500`, il cui `GREATEST(...)` faceva proprio morire
la costruzione dello schema, e la media di percorrenza in `Home`, che usava
`DATEDIFF`/`YEAR` e teneva fuori dalla suite la pagina più visitata
dell'applicazione. Entrambe danno lo stesso risultato di prima.
- **Il sorgente non è montato, e il `--build` non è facoltativo.** Con i bind
mount la stessa suite passa da ~15 secondi a ~2 minuti (ogni bootstrap di
Laravel legge una decina di file attraverso il layer di Docker Desktop), e in
cambio si guadagna solo di non ricostruire. Il comando canonico è
`docker compose --profile test run --build --rm test`: senza `--build` si
eseguono i test dell'immagine precedente, che possono essere di due modifiche
fa. Stesso genere di trappola del "Rebuild deployment" su Dokploy.
- **`APP_KEY` di test è fissa e sta in chiaro in `phpunit.xml`.** Nome e cognome
dei proclamatori hanno il cast `encrypted`: senza chiave nemmeno un test che
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.
- **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e
PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano
direttamente; il componente `Registro` si limita a validare l'anno e
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
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
attende e non tocca lo schema. Due container in gara su `artisan migrate` è
il modo tipico di ritrovarsi con uno schema applicato a metà.
### `APP_KEY` risolta una volta sola all'avvio
Prima veniva rigenerata da `AppServiceProvider::boot()` a ogni richiesta se
mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
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
Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| Bug | Effetto | Dove |
|-----|---------|------|
| `key:generate` a runtime | Rotazione silenziosa della chiave → **perdita irreversibile** dei dati cifrati | `AppServiceProvider` |
| Import PDF da cartella mai accodato | Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata | `Livewire/Settings/XmlExchange` |
| Permessi non riapplicati su Livewire | Un permesso revocato restava attivo fino al reload della pagina | `AppServiceProvider` (`addPersistentMiddleware`) |
| Host header injection | Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari | `bootstrap/app.php`, `nginx/default.conf` |
| Default TTL link mai convertito | Link PDF validi 2 anni invece di 1 mese | migrazione `2026_07_18_000001` |
| `syncPermissions()` nel seeder | Un re-seed azzerava i permessi personalizzati dei ruoli | `RolesAndPermissionsSeeder` |
| `campagna_id` manipolabile | Rientro conteggiato su una campagna arbitraria | `Livewire/Assegnazioni/Rientra` (`#[Locked]`) |
| `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` |
| Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` |
| Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` |
| Export XML senza `legacy_id` | Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | `Livewire/Settings/XmlExchange` (`addLegacyIdAttribute`) |
| Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti |
| `addFile()` senza controllo esito | Backup con un file mancante, **silenzioso**: ci si accorge solo al ripristino | `Services/BackupArchive` |
| Esito del ripristino con `Cache::pull` | L'amministratore non vedeva **mai** il riepilogo: la `fetch` che segue il 302 lo consumava prima della navigazione | `Auth/PasswordChangeController` |
| Nessun blocco fra operazioni | Backup, ripristino e azzeramento potevano sovrapporsi sullo stesso database e disco | `Services/BackupOperationState` (`corrente()`) |
| Job ucciso = operazione eterna | Timeout o crash del worker lasciavano lo stato "in corso" e il posto occupato | `failed()` sui tre job di backup |
| Cancellazione file senza verifica | «N file cancellati» con i file ancora sul disco (permessi) | `BackupArchive::svuotaDiscoPubblico()` |
| 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` |
| 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` |
| `@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()` |
---
## Cosa resta aperto
In ordine di priorità.
### 0. Il ripristino da browser è limitato a 100 MB — decisione in sospeso
**È il punto da cui riprendere.** Cloudflare rifiuta i caricamenti oltre 100 MB
(vedi *Trappole da conoscere*), quindi il ripristino dall'interfaccia non
funziona per un pacchetto con molte mappe. L'applicazione è a posto: il muro sta
davanti.
Da decidere, in ordine di rapidità:
1. Togliere il dominio da Cloudflare (DNS-only), anche solo su un sottodominio
dedicato all'amministrazione — gratis e immediato.
2. Caricare puntando direttamente all'IP dell'origine, scavalcando Cloudflare.
3. Ripristino dai volumi (procedura nel README): nessun limite, serve accesso
al server.
4. Piano Cloudflare Business: 200 MB, spesso ancora insufficienti.
Resta da accertare **quanto pesa davvero il pacchetto** che si vuole
ripristinare: sotto i 100 MB il problema non sarebbe la dimensione ma il timeout
di 100 secondi di Cloudflare, e la soluzione sarebbe un'altra.
### 1. Copertura dei test ancora parziale
L'impalcatura ora c'è (vedi *La suite di test ha un'immagine sua*) e i tre
blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
`test` nel Dockerfile con le dipendenze di sviluppo, connessione `sqlite` in
`config/database.php`, `phpunit.xml`. Si lancia con:
```bash
docker compose --profile test run --build --rm test
```
**Coperto oggi — 294 test, tutti verdi:**
| File | Cosa protegge |
|------|---------------|
| `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/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/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/Unit/StileUnificatoTest.php` | Che i vocabolari di stile paralleli non rientrino: `.btn-action*`, `.btn-primary-*`, l'hover simulato con `onmouseover`, gli sfondi scritti a mano sui pulsanti, le classi `tm-btn` messe in una vista invece che dal componente |
| `tests/Feature/PiantineManagerTest.php` | La pagina di gestione: accesso riservato, KML accettato e rifiutato, collegamento e scollegamento di una piantina, rifiuto di un territorio che ne ha gia' una, cancellare il territorio **non** cancella il disegno (la regressione che la cascata causava a ogni import XML), niente PDF senza territorio, e l'anteprima che non tocca `pdf_path` ne' lo storico |
| `tests/Feature/PiantineEditorTest.php` | L'editor: piantina salvata senza territorio, territorio creato dalla scheda con zona/tipologia/confini, aggiornamento di uno esistente, zona e tipologia create sul posto e lasciate selezionate, numero e zona duplicati respinti, ridisegno che sostituisce senza duplicare, geometria non valida ignorata |
| `tests/Unit/PiantinaPdfServiceTest.php` | La cartolina: due pagine, la Localita' presa dalla zona e i confini davvero presenti nel PDF (estratti decomprimendo i flussi, non solo "non ha sollevato eccezioni"), il giro completo dello storico — sostituzione, ripristino reversibile, file sparito |
| `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata |
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
Quello che questi test **non** possono coprire: Alpine e il comportamento della
console — apertura automatica, scorrimento, evidenziazione delle zone — girano
nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
**Scoperto ancora, in ordine di quanto costerebbe sbagliarsi:**
- `Services/BackupArchive::extract()` sui **limiti anti zip-bomb**: numero di
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
l'ordine staging → database → file, il `failed()` che libera il posto, la
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
`password_change_required` acceso su tutte le righe. È la logica il cui
errore si scopre solo quando è troppo tardi.
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
priorità automatica, percentuale di percorrenza delle campagne.
- Il permesso come contratto: che un `assistente` **non** possa modificare o
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
verificherebbe tutto insieme.
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.
### 2. Race su doppia assegnazione — chiusa il 09/10/2026
`Assegna::save()` e il salvataggio del Registro controllano e scrivono dentro
una transazione con `lockForUpdate()` sulla riga del territorio, e rifiutano
ogni periodo che si accavalla a un altro (`Assegnazione::sovrappostaA()`). Non
c'è ancora un vincolo nel database: l'import XML e i dati storici possono
contenere sovrapposizioni, che il controllo segnala solo quando si modifica
una di quelle righe.
### 3. Scrittura durante il rendering
`Assegnazione::shortPdfUrl()` genera e salva il codice d'accesso al primo
utilizzo, quindi una GET del registro può produrre decine di INSERT. Meglio
generare il codice alla creazione dell'assegnazione (observer) e far restituire
`null` al metodo quando manca.
### 4. Ricerca in memoria sui campi cifrati
Nome e cognome dei proclamatori sono cifrati, quindi la ricerca carica l'intera
tabella e filtra in PHP (`ProclamatoreIndex`, `Registro`). Accettabile sotto
qualche centinaio di record, non oltre. Se dovesse servire scalare: hash
deterministico ricercabile affiancato al campo cifrato.
### 5. Endpoint PDF pubblici senza throttle
`/p/{code}` e le rotte del visualizzatore sono senza autenticazione né rate
limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a
~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non
verrebbe né rallentato né registrato. Un `throttle:30,1` costerebbe una riga.
### 6. Restano da uniformare i colori che non sono pulsanti
I pulsanti sono a posto in tutta l'applicazione (vedi *Un solo pulsante per
tutta l'applicazione*), ma i **colori decorativi** sono ancora scritti a mano,
esadecimale per esadecimale, dentro gli attributi `style=`: le piastrelle delle
icone e le fasce delle schede nella dashboard, gli sfondi delle intestazioni di
tabella, i pannelli delle modali del Registro, i riquadri dei messaggi nel
layout.
Non sono un problema di accessibilità come lo erano i pulsanti — nessuno di
questi elementi si può mettere a fuoco — ma sono lo stesso genere di debito: lo
stesso verde compare in quattro tonalità leggermente diverse, e cambiare la
tinta dell'applicazione oggi significa cercare a mano tutte le occorrenze.
**Dal 28/08 metà del lavoro è fatta**: i token esistono (`:root` e `@theme` in
`app.css`, vedi *Il sistema visivo sta nei token*), e le scale `gray`/`indigo`
di Tailwind sono ridefinite, quindi tutto ciò che usa le utility è già
allineato. Resta da togliere gli `style=` con gli esadecimali scritti a mano e
farli citare i token. Il test `StileUnificatoTest` è il posto dove aggiungere la
guardia, quando si farà.
Fuori perimetro per scelta: `resources/views/pdf/*` le impagina dompdf, che di
Tailwind e delle nostre classi non sa niente. `StileUnificatoTest` esclude anche
`resources/views/vendor/`, che oggi non esiste — le viste di paginazione
pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
`pagination::`. L'esclusione resta a guardia di future viste di terze parti.
### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026
Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe`
e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
---
## Trappole da conoscere
**Cloudflare taglia i caricamenti a 100 MB, e `MAX_UPLOAD_MB` non c'entra
niente.** I domini pubblici passano da Cloudflare, che sul piano free rifiuta i
corpi di richiesta oltre **100 MB**. Rifiuta sulla base del `Content-Length`,
quindi il `413` arriva dopo che il browser ha spedito appena un megabyte: la
barra di caricamento si pianta a una percentuale bassa e — senza i gestori
aggiunti alla console — non compare alcun messaggio. È **il** motivo per cui il
ripristino di un pacchetto grande sembra bloccarsi senza spiegazione.
Misurato il 29/07/2026 su `demo-termanager.pyconetwork.it`:
| Payload | Esito |
|---------|----------------------------------------------------|
| 150 MB | `413 Payload Too Large`, `Server: cloudflare`, 2,7 s |
| 100 MB | `524` (timeout dell'origine dopo 100 s di Cloudflare) |
Il pacchetto **non raggiunge mai nginx**, quindi alzare `MAX_UPLOAD_MB` non
sposta nulla: quel valore governa solo ciò che accade *dopo* Cloudflare. Le
strade per superarlo sono togliere il dominio da Cloudflare (DNS-only, anche
solo su un sottodominio d'amministrazione), caricare puntando direttamente
all'IP dell'origine, oppure usare il ripristino dai volumi. Il piano Business
arriva a 200 MB, che per un archivio con tutte le mappe è spesso ancora poco.
Nota sul `524`: anche restando sotto i 100 MB, Cloudflare chiude dopo 100
secondi se l'origine non ha ancora risposto. Un caricamento lento può quindi
fallire pur essendo dentro il limite di dimensione.
**Il JavaScript che vive dentro un attributo HTML.** Vale ancora per i blocchi
`x-data` di `backup-manager`, `zona-pericolosa` e della zona Import PDF, che
gestiscono i caricamenti. La console, che era il blocco più grosso, è stata
portata fuori in un `<script>` proprio per uscirne (vedi *La pagina "Backup e
import"*); il resto resta soggetto alla regola.
`x-data` finisce alla **prima virgoletta doppia**, comprese quelle dentro un
commento JavaScript: un banale `// un generico "errore"` ha chiuso l'attributo a
metà e riversato tutto il codice restante a video come testo — è successo due
volte. Nello stesso blocco, un `\'` scritto a mano nel sorgente Blade *chiude* la
stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
da nessuna parte; apostrofi italiani solo dentro backtick.
**In una vista Blade la forma in linea `@php(...)` non va messa prima di un
blocco `@php … @endphp`.** Blade estrae i blocchi `@php … @endphp` per primi,
con un'espressione che parte dalla prima `@php` che trova: se è quella in
linea, ingoia tutto fino al primo `@endphp` e la vista non compila più
("unexpected token class"). Successo in *Il mio gruppo* il 19/10/2026. E
vale anche dentro i commenti `{{-- --}}`, che Blade toglie *dopo*: lì la
parola `@php` non va scritta.
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
**`APP_KEY` è il singolo punto di perdita dati.** I nomi dei proclamatori sono
cifrati con questa chiave. Il backup del database senza la chiave è inutile per
quei campi. Se non la imposti, l'entrypoint ne genera una e la salva nel volume
`storage_data`, stampandola nei log: se quel volume viene perso, i dati sono
irrecuperabili.
**`APP_URL` deve combaciare col dominio servito.** L'applicazione rifiuta
richieste con `Host` diverso (protezione host header injection) e restituisce
400 senza spiegazioni. È la causa più comune di "il deploy è andato ma il sito
non si apre". `localhost` e `127.0.0.1` sono ammessi perché li usano gli health
check interni.
**Il volume `storage_data` non è solo cache.** Contiene i PDF dei territori, le
miniature e la chiave di fallback. Non va trattato come rigenerabile.
**Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente
riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine.
**L'export XML contiene dati sensibili.** Il file di export include gli hash
delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro.
Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli
hash. La pagina di export lo segnala, ma il controllo resta umano — la
funzione è già riservata all'amministratore (`settings.manage`).
---
## Come verificare che tutto funzioni
Dopo una modifica sostanziale, questo giro copre i percorsi critici:
C'è un solo `docker-compose.yml`, usato sia in produzione sia in locale. In
locale serve creare una volta la rete esterna attesa da Dokploy.
```bash
cp .env.example .env
# nel .env: APP_URL=http://localhost:8080, APP_ENV=local,
# SESSION_SECURE_COOKIE=false, e le tre password
docker network create dokploy-network
# 0. La suite passa (non serve nient'altro acceso, ~15 s)
docker compose --profile test run --build --rm test
# 1. L'immagine builda
# Senza --target si costruisce l'ultimo stage, che e' `runtime`: se questo
# comando producesse un'immagine con phpunit dentro, qualcuno ha aggiunto
# uno stage in fondo al Dockerfile.
docker build -f docker/php/Dockerfile -t termanager2-check .
# 2. Stack pulito da zero
docker compose --profile dev down -v
docker compose --profile dev up -d --build
docker compose --profile dev ps # tutti i servizi healthy
# 3. L'applicazione risponde
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up # 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/login # 200
# 4. Il redeploy è idempotente
docker compose up -d --force-recreate app
docker compose logs app | grep -E "Migrations|Seed"
# atteso: "Migrations applied" + "Seed already applied"
# 5. La cifratura funziona
docker compose exec app php artisan tinker --execute="
\$p = App\Models\Proclamatore::create(['nome'=>'Test','cognome'=>'Verifica','attivo'=>true]);
\$raw = DB::table('proclamatori')->where('id',\$p->id)->value('nome');
echo \$raw === 'Test' ? 'ERRORE: dati in chiaro' : 'OK: cifrato';
"
```
È lo stesso file che gira in produzione: le uniche differenze sono i profili
`dev` (che aggiunge Mailpit) e `test` (che aggiunge la suite, effimera) e i
valori nel `.env`.
---
## Documentazione
| Documento | Contenuto |
|------------------------------------|--------------------------------------------|
| [README.md](README.md) | Deploy, configurazione, uso, riferimento |
| `HANDOFF.md` (questo file) | Stato, decisioni, debito tecnico |
La specifica funzionale originale (`TerManager2_v2.md`) è stata rimossa dal
working tree: descriveva funzionalità poi cambiate — in particolare un wizard
di primo avvio successivamente eliminato in favore della pagina Impostazioni —
e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide
(modello dati, regole di business, RBAC) sono confluite nel README.
Resta consultabile nella storia del repository:
```bash
git show 701f479:TerManager2_v2.md
```