# 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: **25 luglio 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` |
| Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
---
## 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.
---
## 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`.
### 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 `
`).
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 ` | ` 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.
- **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/`, 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à.
### 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 |
---
## Cosa resta aperto
In ordine di priorità.
### 1. Nessun test automatico
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste** e non
c'è `phpunit.xml`. Ogni verifica è manuale. È il debito più rilevante: le regole
di business (anno teocratico, stato territorio, priorità automatica, percentuale
campagne) sono esattamente il tipo di logica che va protetta da test — e a
maggior ragione backup, ripristino e azzeramento, dove un errore non si vede
finché non è troppo tardi.
Tre blocchi concreti da rimuovere prima di poter scrivere il primo test:
1. **PHPUnit non è nell'immagine.** `docker/php/Dockerfile` installa con
`--no-dev` (giusto, per la produzione), quindi `vendor/phpunit` nel container
non c'è e `docker compose exec app ./vendor/bin/phpunit` non funziona.
Servono o un `composer install` locale sull'host, o uno stage `dev` nel
Dockerfile che tenga le dipendenze di sviluppo.
2. **Manca la connessione SQLite.** `config/database.php` ridefinisce
`connections` e lascia solo `mysql`. Per usare SQLite in memoria va
riaggiunta la connessione `sqlite`.
3. **Manca `phpunit.xml`.** L'autoload `Tests\` in `composer.json` c'è già, il
resto no.
Dove conviene cominciare, in ordine di rapporto valore/attrito — i primi due
non hanno bisogno del database e quindi si sbloccano risolvendo solo il punto 1:
- `Services/BackupArchive` con `Storage::fake('public')`: round-trip
crea → verifica → estrai, rifiuto dello zip slip, rifiuto del formato futuro,
fallimento su archivio incompleto.
- `Services/BackupOperationState` con il driver di cache `array`: il posto
unico, il rilascio alla chiusura, il puntatore orfano, il non-rilascio da
parte di un'operazione vecchia.
- `Livewire/Registro::righeS13()`: il ciclo da quattro e la colonna del
riporto, con i casi 2/3/4/5/8/9 assegnazioni (questi sì, con database).
### 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.
---
## Trappole da conoscere
**`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
# 1. L'immagine builda
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: l'unica differenza è il profilo `dev`
(che aggiunge Mailpit) 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
```
|