Le piantine non dipendono piu' da un territorio esistente: territorio_id e' nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome` fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese, create sul posto quando mancano. Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import XML svuota `territori` in blocco e finora si portava via tutte le piantine. Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato. /piantine resta la gestione — area, generazione, collegamenti, storico. Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il nome della zona, non un campo duplicato) e i confini scritti nello spazio centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza toccare pdf_path ne' lo storico. Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine, come la ODbL richiede. Rimossa la rigenerazione massiva: job, stato in cache e interfaccia. Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro significati, carattere servito dall'applicazione e non da Google Fonts, via il gradiente della testata. Aggiunto il link "salta al contenuto", che mancava. Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla 1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta, e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire rispondeva 401. 294 test verdi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1292 lines
76 KiB
Markdown
1292 lines
76 KiB
Markdown
# 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: **28 agosto 2026**.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 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` tiene area, generazione, collegamenti e
|
||
storico; `/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, e quel disegno finisce stampato.
|
||
|
||
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si
|
||
rigenera un territorio alla volta dall'elenco.
|
||
|
||
**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'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
|
||
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
|
||
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
|
||
dopo significa trovarselo già nello storico.
|
||
|
||
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
||
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: staging → database → file.** I file si estraggono
|
||
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
|
||
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi
|
||
`importXmlFromContent()` in transazione; poi la sostituzione dei file. Il
|
||
disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe
|
||
appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna
|
||
schermata mostra e nessuna cancellazione raggiunge.
|
||
|
||
- **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.
|
||
|
||
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è
|
||
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto:
|
||
renderla pubblica solo per poterla testare avrebbe allargato la superficie
|
||
del componente per comodità del test.
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
## 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` |
|
||
| `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" compreso il caso in cui l'ultima del giro precedente è ancora aperta, esclusione di territori disattivati e cestinati, filtro sull'anno di servizio, ordinamento naturale dei numeri |
|
||
| `tests/Feature/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
|
||
|
||
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma
|
||
nulla a livello di database impedisce due assegnazioni aperte sullo stesso
|
||
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`,
|
||
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi.
|
||
|
||
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna
|
||
generata che imponga una sola assegnazione aperta per territorio.
|
||
|
||
### 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. Il backup non contiene l'area KML delle piantine
|
||
|
||
`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e
|
||
`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non
|
||
dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a
|
||
mano dal KML originale, che quindi conviene conservare fuori di qui.
|
||
|
||
I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave
|
||
esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che
|
||
fa ogni import XML — scollegati, ma recuperabili.
|
||
|
||
Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che
|
||
tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è
|
||
stato fatto perché il KML è un file che l'utente ha già sul suo computer: la
|
||
perdita è un fastidio, non un dato irrecuperabile.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
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
|
||
```
|