671 lines
37 KiB
Markdown
671 lines
37 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: **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 `<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.
|
|
|
|
- **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à.
|
|
|
|
### 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
|
|
```
|