Files
termanager2/HANDOFF.md
Francesco Picone 2e1da51080 ++ HANDOFF: 612 test verdi, RichiesteTest nell'elenco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:37:28 +02:00

125 KiB
Raw Blame History

Handoff — TerManager2

Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi README.md; qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto.

Ultimo aggiornamento: 21 ottobre 2026 — si riprende da Stato attuale.


Stato attuale

⚠️ 21 ottobre 2026 — PHP da aggiornare. Produzione e demo girano su PHP 8.3.35, vulnerabile a CVE-2026-109618/109619 (esecuzione di codice da remoto). Il Dockerfile ora richiede la 8.3.36, che esce alle 14:00 UTC del 21/10; nel frattempo c'è una mitigazione nel php.ini. Vedi Sicurezza: PHP.

Produzione (main → compose termanager2-core-production) in esercizio su Dokploy dal 19 ottobre 2026. Ultimo rilascio: d3314a9 (proclamatori senza contatti, il link si copia o si condivide). Backup fatti prima dei rilasci in /etc/dokploy/termanager2-backups/ sul server.

Demo (dev → compose termanager2-core-demo, demo-termanager.pyconetwork.it): avanti di una ventina di commit, tutti provati con la suite sul server e pubblicati sulla demo. Non ancora in produzione — l'utente ha chiesto di aspettare il suo ok:

  • gruppi di servizio: sorvegliante con email fittizia, pacchetto di territori, "Il mio gruppo", scioglimento; migrazioni 2026_10_20_000010 … 000040;
  • priorità e filtri nel pacchetto, con la regola di priorità della Home in un metodo solo (Territorio::ordinaPerPriorita());
  • pulsante Rientro nell'elenco dei territori assegnati;
  • storico assegnazioni in una tabella sola, con le colonne in linea;
  • Assegna aperta da "Assegna →" della Home senza l'elenco dei territori assegnati;
  • password del sorvegliante dal pannello Utenti, anteprima dal numero in "Il mio gruppo";
  • modulo pubblico delle richieste, cartacei, S-13 per email (migrazione 2026_10_21_000010).

Per portarli in produzione: backup del database di produzione, merge di dev in main (dopo il rilascio di sicurezza del 21/10 non è più un fast-forward), deploy (l'autodeploy non parte: vedi Dove gira davvero), poi controllo di migrazioni e conteggi. In produzione non ci sono gruppi né richieste: le migrazioni non hanno dati da convertire.

Aspetto Stato
Deploy Dokploy (Compose) ✅ produzione e demo
Autodeploy al push ⚠️ configurato, ma dal 19/10 il push non fa partire il deploy: si avvia a mano (compose.deploy)
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
Ripristino da browser in linea ⚠️ limitato a 100 MB da Cloudflare — vedi Cosa resta aperto
Test automatici ✅ 612 test PHP + 13 JavaScript, verdi (21/10/2026); copertura ancora parziale
Prove nel browser ⚠️ le funzioni del 19-21 ottobre sono verificate dai test e sul server; nel browser le usa l'utente sulla demo (richieste, cartacei e S-13 non ancora provati da lui)

La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine, sistema visivo, prima suite di test) è in Da dove veniamo.


Lavori recenti — ottobre 2026

Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del 20-21 ottobre e i gruppi di servizio solo sulla demo.

Richieste dal modulo pubblico, cartacei, S-13 per email (21 ottobre 2026, solo su dev)

Dal documento _ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx (attività per la versione 2): analizzato con l'utente, che ha deciso così.

  • Modulo pubblico /richiesta (rotta richiesta, fuori dal login, throttle:60,1; componente Richieste\ModuloRichiesta, layout guest): nome e cognome, nuovo territorio o riconsegna (con numero), nota. Non mostra dati esistenti. Anti-spam: campo trappola sito (classe .tm-trappola), tempo minimo SECONDI_MINIMI = 3 s (in questi due casi risponde "inviata" senza salvare), MASSIMO_ORA = 5 per IP (chiave del RateLimiter con lo sha1 dell'IP). Il tempo si legge con now(), così i test possono viaggiare nel tempo.
  • Tabella richieste (migrazione 2026_10_21_000010, che crea anche il permesso richieste.gestisci per amministratore e assistente e la colonna assegnazioni.cartaceo): nome e note cifrati; nel registro delle attività solo tipo, stato, numero (richiesta_ricevuta all'arrivo, poi i cambi di stato). RichiesteServizio: registra(), destinatari(), nomiSimili(), assegnazioneAperta().
  • Email di avviso (Notifications\RichiestaRicevuta) a chi ha richieste.gestisci con l'email confermata, solo se la posta funziona (ConfigurazionePosta::funzionante()). Non in coda: contiene il nome.
  • Conferma dalla Home (riquadro Richieste, solo con il permesso): scelta dell'utente, "così non si modifica troppo il codice del form precompilato". Per un nuovo territorio la Home propone i proclamatori attivi con un nome simile (punteggio per parola: uguale 3, iniziale 2, una lettera di differenza 1; serve almeno 2) e ognuno apre Assegna con ?proclamatore=&richiesta=&dalla_home=1; per una riconsegna, Rientra con ?richiesta=. I due moduli leggono richiesta solo se aperta e se l'utente ha il permesso (#[Locked] richiestaId), la mostrano sopra il form e, fatta l'operazione, la segnano evasa (Richiesta::evadi()). Altrimenti Scarta. La Home "messaggi" del documento sono solo queste richieste.
  • Pulizia: richieste:pulizia ogni notte cancella le richieste gestite da più di PuliziaRichieste::GIORNI = 90 giorni. Le richieste non sono nell'XML né nel .zip (un ripristino non le tocca); l'azzeramento le cancella (passo "Richieste" di ResetApplication, ora 12 passi).
  • Cartaceo sull'assegnazione (scelta dell'utente: "va sull'assegnazione"): spunta su Assegna e in "Il mio gruppo" (GruppiServizio::assegna(..., cartaceo:)), etichetta "cartaceo" nell'elenco degli assegnati e in "Il mio gruppo". Nell'elenco è un'espressione, non un blocco @if: Livewire mette due commenti segnaposto attorno a ogni @if, 58 byte a riga anche quando è falso, e la riga superava i 2,6 KB del test di peso. Pagina Cartacei (assegnazioni/cartacei, territori.assign, voce di menu sotto Assegnazioni) con i territori cartacei fuori. Passa per l'XML.
  • S-13 per email: registro:invia-s13 {--anno=} manda Mail\ModuloS13Mail (PDF allegato, non in coda) agli amministratori con l'email confermata; nello scheduler weeklyOn(0, '08:00') con when(settimana dopo in un altro mese) = ultima domenica del mese. Senza posta non parte e lo dice. Nel registro s13_inviato con anno e quanti.
  • Informativa privacy aggiornata (dati, conteggi, posta, conservazione; revisione 21 ottobre 2026). Test in tests/Feature/RichiesteTest.php e GruppiServizioTest (cartaceo dal sorvegliante, cartaceo nell'XML).

Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)

Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle 14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: 8.3.36. Il progetto girava su 8.3.35.

  • docker/php/Dockerfile: ARG PHP_VERSION=8.3.36 e FROM php:${PHP_VERSION}-fpm. Prima era php:8.3-fpm, un tag che si sposta ma che Docker tiene in cache: una ricostruzione senza --pull (anche su Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la 8.3.36 o fallisce. Test tests/Unit/VersionePhpTest.php contro il ritorno a un tag mobile o a una versione inferiore.
  • phpredis da GitHub (ARG PHPREDIS_VERSION=6.3.0) invece di pecl install: quel giorno pecl.php.net rispondeva 504 e la build falliva.
  • Solo per provare in locale prima che l'immagine sia su Docker Hub: PHP_VERSION=8.3.35 docker compose ... (mai per un deploy).
  • Mitigazione in attesa della patch (indicata da Sansec, approvata dall'utente): session.upload_progress.enabled = 0 e session.use_strict_mode = 1 in docker/php/php.ini, da tenere anche dopo (all'applicazione non servono: Laravel ha le sue sessioni, in Redis). Nei container già in esecuzione di demo e produzione la stessa cosa va in conf.d/zz-mitigazione-cve-2026-109618.ini con ricarica morbida di php-fpm (USR2): si perde quando il container viene ricreato, e da lì vale il php.ini dell'immagine. Stato dell'applicazione ai container: vedi sotto, aggiornato a ogni passo.
  • Da fare quando php:8.3.36-fpm è su Docker Hub: test sulla 8.3.36, poi deploy di demo e produzione (vedi Stato attuale). Finché restano su 8.3.35 sono esposte. Scelta dell'utente (21/10): in produzione solo la correzione di PHP. I due commit di sicurezza (db5772d e la mitigazione nel php.ini) vanno su main con un cherry-pick (branch locale rilascio-php, sopra d3314a9); gruppi di servizio e lavori del 20 ottobre restano sulla demo, da rilasciare un altro giorno. Dopo il rilascio main e dev divergono: per portare dev in produzione servirà un merge (i cherry-pick sono già in dev, conflitti attesi solo in HANDOFF).
  • Il passaggio di consegne di quella sessione (HANDOFF-chat.md) sta nella cartella del progetto ma non nel repository (escluso in .git/info/exclude): contiene dettagli dell'infrastruttura.

⚠️ Demo costruita con PHP 8.3.35 in via provvisoria (21/10, 14:40 UTC). L'utente ha chiesto il deploy della demo prima che uscisse php:8.3.36-fpm: nelle variabili d'ambiente del compose della demo in Dokploy c'è PHP_VERSION=8.3.35 (con un commento "TOGLIERE subito dopo"), che il docker-compose.yml passa al Dockerfile. La mitigazione ora sta nell'immagine (php.ini). Appena c'è la 8.3.36: togliere quella variabile e rifare il deploy della demo, altrimenti resta su 8.3.35 anche dopo la patch. In produzione questa via non si usa.

Stato al 21/10, 11:00 UTC circa: mitigazione nel php.ini del repository (su dev e nel branch di rilascio, non ancora deployabile finché manca l'immagine 8.3.36) e applicata ai container in esecuzione di demo e produzione, entrambi ancora su PHP 8.3.35: php-fpm ricaricato (processi di lavoro nuovi, stesso master), php-fpm -i riporta session.upload_progress.enabled => Off e session.use_strict_mode => On, login 200 su entrambi.

Password del sorvegliante dal pannello Utenti (21 ottobre 2026, solo su dev)

Richiesta dell'utente: poter reimpostare la password del sorvegliante dal pannello di amministrazione. Prima si poteva solo dalla pagina del gruppo; in Utenti la sua riga non aveva azioni. Ora ha "Nuova password temporanea" (con conferma) e "Apri il gruppo"; la password compare una volta sola in cima all'elenco, con l'email, fino a "Ho annotato la password". La logica è una sola, GruppiServizio::nuovaPasswordTemporanea(), usata anche da GruppoShow; rifiuta gli utenti che non sono sorveglianti (per loro c'è il modulo di modifica). Test in GruppiServizioTest.

Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev)

Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del territorio, con un popup al clic del numero. Il numero diventa un pulsante (stesso aspetto, button.tm-assegnato-numero in app.css) che apre una finestra (tm-modale, stato Alpine anteprima sulla radice della vista) con la miniatura e "Apri la piantina (PDF)". La miniatura passa da una rotta nuova, mio-gruppo.miniatura (TerritorioFileController::miniaturaGruppo, Territorio::miniaturaGruppoUrl()), con la stessa regola di mio-gruppo.pdf: solo i territori del pacchetto del gruppo che l'utente sorveglia. La rotta normale delle miniature resta chiusa al sorvegliante. Senza miniatura né PDF il numero resta testo.

Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)

Richiesta dell'utente: "quando provengo da un territorio dalla home, non mostrare lo storico assegnazioni". Avevo capito la scheda del territorio e ne avevo nascosto lo storico (commit f1f3408, poi annullato con un revert): intendeva la pagina Assegna aperta da "Assegna →" della Home, e come "storico" l'elenco "Territori Assegnati" sotto il modulo. Ora quel link porta ?dalla_home=1 e Assegna::$elencoNascosto toglie ElencoAssegnati (che così non si carica nemmeno). Dal menu o dalla scheda del territorio l'elenco c'è come prima. Non si usa il ?da= del ritorno per capirlo: si trasmette di pagina in pagina.

Storico assegnazioni in linea (20 ottobre 2026)

Richiesta dell'utente (screenshot della scheda territorio): le colonne dello storico cambiavano posizione da un anno all'altro, perché ogni anno era una tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio e in quella del proclamatore, è una tabella sola con un'intestazione, colonne a larghezza fissa (table-fixed, w-[..%]), e l'anno come riga di separazione tr.tm-riga-gruppo (in app.css, su telefono resta un titolo e non diventa una scheda). Ordine esplicito: anni dal più recente (sortKeysDesc), dentro l'anno per data di assegnazione. Nella scheda del proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del territorio, e c'è il badge "via gruppo …". Test: StoricoAssegnazioniTest.

Rientro dall'elenco dei territori assegnati (20 ottobre 2026)

Richiesta dell'utente ("un tasto che va alla registrazione", dallo screenshot della pagina Assegna con l'elenco filtrato per proclamatore): in ElencoAssegnati ogni riga ha Rientro (solo con territori.return), che apre la registrazione del rientro e, con data-ritorno, torna all'elenco. Anche le righe senza PDF lo hanno. L'elenco ha un tetto di peso per riga (AssegnaPrestazioniTest, 2,6 KB) e il pulsante lo sforava di 21 byte: per starci l'indirizzo è relativo (route(..., false), data-ritorno lo gestisce) e il pulsante sta sulla stessa riga del <div>, senza a capo né commenti dentro il ciclo (i commenti Blade spariscono, gli spazi no).

Gruppi di servizio (19-20 ottobre 2026, solo su dev)

Richiesta dell'utente: un gruppo di servizio con un sorvegliante che, entrato con una sua utenza, distribuisce ai membri un pacchetto di territori e non vede nient'altro. Le decisioni sono cambiate più volte in due giorni: qui sotto com'è oggi, in fondo la storia.

Come funziona

  • Chi: un gruppo ha un nome (unico), un sorvegliante (proclamatore attivo) e dei membri. Un proclamatore sta in un solo gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé compreso. Gruppi, membri e pacchetti li gestisce solo l'amministratore (menu Gruppi di servizio).
  • Utenza del sorvegliante: nasce con il gruppo. Email fittizia (GruppiServizio::emailFittizia(): iniziale del nome e cognome, minuscoli, senza accenti, spazi né apostrofi, su GruppiServizio::DOMINIO_EMAIL = termanager.it — Francesco Fontana → ffontana@termanager.it, numero in coda se già presa), la stessa convenzione degli altri utenti. Password temporanea, mostrata una volta: al primo accesso la pagina del cambio dice "Password temporanea" (users.password_temporanea, che il ripristino e l'import XML spengono, perché lì il motivo è il ripristino). Nessuna conferma dell'email (User::hasVerifiedEmail() vero per il sorvegliante), e dal profilo l'email non si cambia. Ruolo sorvegliante, con il solo permesso gruppo.gestisci; non compare nella pagina Utenti, dove le utenze legate a un proclamatore non si modificano né si eliminano. "Nuova password temporanea" dalla pagina del gruppo o dalla riga del sorvegliante nel pannello Utenti (stessa logica, GruppiServizio::nuovaPasswordTemporanea(): password provvisoria, cambio obbligato, "ricordami" azzerato, log password_reimpostata; le sessioni aperte si chiudono perché cambia l'hash). In Utenti è l'unica azione su quell'utenza, con "Apri il gruppo".
  • Cosa vede: LimitaSorvegliante (gruppo web) lascia a chi ha solo quel ruolo mio-gruppo*, assegnazioni.rientra (solo per il suo gruppo: Rientra::autorizza), profilo, cambio password e le azioni Livewire — *livewire.*, perché in Livewire 3.7 la rotta si chiama default.livewire.update (con livewire.* ogni clic era un 403, e Livewire::test() non passa dai middleware: un test chiama la rotta vera). La Home lo rimanda al gruppo, il resto è 403. La voce di menu "Il mio gruppo" guarda il ruolo, non @can: l'amministratore passa ogni @can (Gate::before).
  • Pacchetto: non è un'assegnazione ("la media non deve essere alterata"): i territori sono "In gruppo …" (stato in_gruppo), fuori da "Da assegnare" e dalla pagina Assegna (Territorio::scopeInReparto esclude gruppo_servizio_id). Si aggiungono in blocco dai territori in reparto in ordine di priorità (Territorio::ordinaPerPriorita(), la regola della Home), con ricerca, zona, tipologia, "solo prioritari" e "Seleziona tutti quelli mostrati" (GruppoShow).
  • "Il mio gruppo" (MioGruppo): sotto il titolo nome e cognome del sorvegliante; prima i territori da dare in ordine di priorità, poi i già dati dal più vecchio; filtri stato, zona, tipologia, "solo prioritari" (nell'indirizzo). Il sorvegliante assegna, manda il link (copia o condividi: trait Concerns\InviaLink + partial assegnazioni/_invio-link, gli stessi di Assegna), registra i rientri, apre la piantina, rimanda in reparto quelli non dati. Il numero del territorio apre l'anteprima (finestra Alpine nella vista, miniatura e collegamento al PDF).
  • Assegnazione dal pacchetto: un'assegnazione normale, con gruppo_servizio_id e gruppo_nome ("via gruppo …" in registro e schede). GruppiServizio::assegna() rilegge territorio e membro e ricontrolla il pacchetto sotto lock. Al rientro il territorio esce dal pacchetto e torna in reparto (hook updated in Assegnazione, da qualunque pagina).
  • Scioglimento: "Togli il ruolo e sciogli il gruppo", oppure il sorvegliante non è più attivo — reso non attivo, nel cestino o anonimizzato (Proclamatore::booted, eventi updated su attivo e deleted; l'elenco chiede conferma, la modifica avvisa sotto "Attivo"). GruppiServizio::sciogli() elimina il gruppo: i territori solo nel pacchetto tornano in reparto; quelli già dati restano ai membri e l'assegnazione ne conserva il nome (confermato dall'utente); i membri restano senza gruppo; l'utenza del sorvegliante si elimina con User::eliminaConservandoStorico() — la stessa eliminazione della pagina Utenti — che passa le sue assegnazioni a chi scioglie (o al primo amministratore) e lascia le sue voci nel registro a nome "Sorvegliante del gruppo …". Il motivo va nel registro (gruppo_sciolto, motivo). Un membro non attivo resta nel gruppo ma non riceve territori; nel cestino ne esce.
  • Segnalazioni: Proclamatore::gruppo_etichetta ("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco dei proclamatori (filtro In un gruppo di servizio, filtroStato=gruppo) e in testa alla scheda; nelle opzioni di <x-scelta> come dettaglio (Assegna, Registro), non come motivo: assegnare direttamente a un membro resta possibile. La scheda del proclamatore mostra il gruppo che guida (GruppoServizio::riepilogo(), email, password temporanea o no, ultimo accesso) o quello di cui è membro; il Profilo del sorvegliante il suo gruppo.
  • Dati personali: nel registro delle attività User::tapActivity() sostituisce nome ed email dell'utenza del sorvegliante con "proclamatore #id", come per tutti i proclamatori; nella tabella users restano in chiaro, come per ogni utente. Informativa privacy aggiornata.
  • Backup: l'XML porta gruppi, membri, pacchetto, provenienza delle assegnazioni (id e nome del gruppo) e il legame utenza ↔ proclamatore; da un file del 19-20 ottobre non si importano i gruppi con <sciolto_at> né le utenze con <disattivato_at>. L'azzeramento (Zona pericolosa) cancella anche i gruppi.

Dove sta

  • Migrazioni: 2026_10_20_000010_gruppi_di_servizio (tabella, colonne gruppo_servizio_id su proclamatori, territori e assegnazioni, users.proclamatore_id unico, permesso e ruolo; il seeder dà all'amministratore tutti i permessi tranne gruppo.gestisci), 000020 (assegnazioni.gruppo_nome, via sciolto_at e i gruppi sciolti), 000030 (via users.username e users.disattivato_at e le utenze disattivate, email fittizia a chi non l'aveva, email di nuovo obbligatoria), 000040 (users.password_temporanea).
  • Regole in App\Services\GruppiServizio; i componenti Gruppi\GruppiIndex, Gruppi\GruppoShow (amministratore) e Gruppi\MioGruppo (sorvegliante) le chiamano soltanto.
  • Test: tests/Feature/GruppiServizioTest.php.

Com'è cambiato (decisioni dell'utente, in ordine)

  1. 19/10 — gruppo con membri; il pacchetto non conta nelle statistiche; utenza con email e password, senza conferma; togliere il ruolo scioglie il gruppo, che restava archiviato (sciolto_at) con l'utenza disattivata.
  2. 20/10 — sorvegliante non più attivo ⇒ gruppo sciolto; dettagli nella scheda e nel profilo; anche i membri segnalati.
  3. 20/10 — il gruppo sciolto non resta nello storico (si elimina); traccia "via gruppo" sulle assegnazioni confermata. Per qualche ora il sorvegliante è entrato con un nome utente ricavato dal nome del gruppo.
  4. 20/10 — al posto del nome utente l'email fittizia iniziale+cognome; sciolto il gruppo l'utenza si elimina ("torna proclamatore normale, non tenere traccia").
  5. 20/10 — "Password temporanea" al primo accesso; il 403 sulle azioni; priorità e filtri; nome del sorvegliante sotto il titolo.

Decisione dell'utente, dopo il rilascio: i proclamatori non hanno più email né telefono, e WhatsApp ed email spariscono come canali. Restano:

  • Copia link;
  • Condividi…: navigator.share() con il messaggio già scritto (Assegnazione::messaggioLink()), cioè il menu di condivisione del telefono (WhatsApp, Telegram, SMS…), da cui chi usa l'app sceglie app e contatto. Compare solo dove il browser lo offre (telefoni, alcuni computer); il canale si annota a condivisione fatta, non se la si annulla. Nell'elenco dei territori assegnati "Condividi" andato a buon fine segna il link come inviato (ElencoAssegnati::segnaCondiviso).

Cosa è cambiato:

  • Migrazione 2026_10_19_000020_togli_contatti_proclamatori: elimina le colonne email e telefono. In produzione erano vuote (0 su 111) al momento della decisione; su dev c'era un solo valore di prova.
  • Tolti dal form, dalla scheda e dall'elenco dei proclamatori; tolti Proclamatore::telefonoInternazionale()/whatsappUrl(), il componente <x-whatsapp>, gli stili tm-whatsapp, la notifica LinkTerritorio e Assegna::inviaEmail().
  • XML: non si esportano più; un file vecchio che li contiene si importa ignorandoli.
  • Assegnazione::CANALI_ATTIVI = copia, condividi. In CANALI_LINK restano anche "whatsapp" ed "email" solo per le assegnazioni già inviate così.
  • La posta resta, ma solo per le email agli utenti (conferma dell'indirizzo).
  • Informativa privacy, README e test (InvioLinkTest, al posto di ContattiProclamatoriTest) aggiornati.

In produzione dal 19 ottobre 2026, ore 17:10 (main = 7384a4b). Prima: ricontrollato che email e telefono fossero vuoti (0 su 111) e backup in /etc/dokploy/termanager2-backups/prod-20261019-150408/. Dopo: migrazione applicata, colonne tolte, conteggi identici a prima (1130 assegnazioni, 77 aperte), 111/111 proclamatori decifrati, 272/272 PDF, 4/4 utenti confermati. L'autodeploy non era partito entro 2 minuti dal push: deploy avviato a mano.

Allineamento di main in dev e passaggio in produzione (19 ottobre 2026)

main è stato unito in dev (merge, non rebase): da main arrivano solo S-12_I.pdf nella radice e una riga di .gitignore. Quel PDF, copia identica di resources/pdf/S-12_I.pdf e non usato dal codice, è stato poi tolto su richiesta dell'utente; il modulo usato da PiantinaPdfService è quello in resources/pdf/ e resta. I 5 conflitti (README, CampagnaShow, Registro, Assegnazione, vista della campagna) nascevano dalle correzioni sulle campagne fatte su main la mattina e già portate su dev: si è tenuta la versione di dev, che le contiene già (con proclamatore_id nel log al posto del nome). Dopo, main va portato avanti fino a dev senza un nuovo merge, così in produzione va esattamente il codice provato su dev.

Analisi d'impatto per prod (fatta sui dati di prod copiati in dev):

  • 11 migrazioni nuove, provate sulla copia: nessun errore, conteggi identici. Due toccano dati esistenti: nomi_proclamatori_fuori_dal_log (irreversibile, solo da backup) e posta_contatti_e_conferma_email, che azzera la conferma email di tutti gli utenti.
  • In prod non c'è un server di posta: subito dopo il deploy va eseguito php artisan utenti:conferma-email <email> per ogni utente, altrimenti nessuno riesce a lavorare. La posta si configura poi dall'app.
  • MAIL_SCHEME=tls nell'ambiente di prod non è valido per Laravel 11: svuotarlo. Prod è in HTTPS con SESSION_SECURE_COOKIE=false: valutare true.
  • Il nuovo controllo dell'entrypoint su APP_KEY passa: chiave d'ambiente e storage/app/.app_key coincidono.
  • Ritorno indietro: ripristino del dump fatto prima del deploy e deploy del main precedente (bde7a4a).

Rilasciato in produzione il 19 ottobre 2026 alle 16:13 (main = e4549f8, fast-forward da dev). Prima: backup in /etc/dokploy/termanager2-backups/prod-20261019-141137/ sul server (database, file, conteggi) e copia del dump sul PC dell'utente; MAIL_SCHEME svuotato nell'ambiente di prod. Deploy di 32 secondi, 11 migrazioni applicate senza errori, email dei 4 utenti confermata con utenti:conferma-email. Verifica: conteggi identici a prima del deploy, 111/111 proclamatori decifrati, 272/272 PDF presenti, campagna di settembre a 13 conteggiati (4,7%), nessun nome in chiaro rimasto nel log. Subito dopo, su decisione dell'utente, SESSION_SECURE_COOKIE=true nell'ambiente di prod (nuovo deploy: i cookie di sessione e XSRF ora sono secure; httponly; samesite=lax). Resta da fare: configurare la posta dall'app (Impostazioni → Server di posta) per poter usare il canale Email.

Deploy: su Dokploy l'autodeploy da push è attivo (triggerType=push, nessun filtro di percorso) e secondo l'utente funziona. Le variabili d'ambiente invece diventano effettive solo con un nuovo deploy, che una loro modifica non avvia da sola.

Informativa privacy aggiornata e dinamica (19 ottobre 2026)

Richiesta dell'utente: aggiornare la sezione privacy con i dati attuali e prendere i parametri dalle impostazioni. Il testo era fermo a una versione precedente (solo nome e cognome, "tre ruoli: Admin, Servitore di Territorio, Proclamatore Semplificato" che non esistono).

  • App\Livewire\Privacy legge al momento: congregazione, audit_retention_days, giorni_per_smarrito (scadenza del link), pdf_viewer_show_download, server di posta (ConfigurazionePosta), config('app.cipher'), config('session.lifetime'), APP_URL (avvisa se non è HTTPS), i ruoli con i permessi veri (Privacy::PERMESSI li spiega) e quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano NULL, non serve decifrare). Privacy::REVISIONE è la data del testo.
  • Aggiunte: link alla piantina, invio dal telefono di chi assegna, mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi. (Email e telefono dei proclamatori, che il primo testo citava, sono stati tolti la sera stessa; dal 20 ottobre il testo parla anche del gruppo di servizio e dell'utenza del sorvegliante.)
  • Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali, da far rivedere a chi ne ha la competenza.
  • Aperto — backup in chiaro: il pacchetto di backup usa l'export XML, che legge i proclamatori dal modello e quindi esporta nome e cognome decifrati. L'informativa lo dice; valutare se cifrare l'archivio (per esempio con una password scelta al download).
  • PrivacyTest verifica che i valori cambino con le impostazioni, i ruoli, i conteggi senza nomi e l'accesso solo amministratore.

Pagina Assegna più leggera; "Vedi tutti" in testata nella Home (19 ottobre 2026)

Segnalazione dell'utente: la pagina Assegnazioni era lenta. Misurato su dev con i dati di prod (79 assegnazioni aperte): il server la genera in ~80 ms con 10 query, ma l'HTML pesava 387 KB (299 l'elenco dei territori assegnati, di cui 58 di icone SVG ripetute; 75 le opzioni dei campi di scelta) e Livewire lo rimandava per intero a ogni interazione: scegliere un territorio (anteprima con wire:model.live), scrivere nella ricerca dell'elenco (linkSearch), aprire l'invio del link. Rimedi:

  • L'elenco è un componente a sé, Assegnazioni\ElencoAssegnati, lazy: la pagina mostra subito il form e le sue azioni non lo ridisegnano. "Invia link" nell'elenco chiede alla pagina di aprire l'invio con l'evento apri-invio (Assegna::apriInvio ha #[On('apri-invio')]); toggleLinkSent sta nell'elenco.
  • La ricerca nell'elenco filtra nel browser (data-cerca senza accenti, Alpine elencoCercabile): nessuna richiesta mentre si scrive.
  • Il territorio va a Livewire con wire:model (non più .live): l'anteprima della miniatura segue la scelta nel browser (evento scelta di <x-scelta>, URL della miniatura nelle opzioni).
  • Righe compatte: icone come simboli SVG definiti una volta e richiamati con <use>, classi brevi tm-assegnato-* in app.css al posto delle catene di utility, copia del link con il metodo condiviso copia() di elencoCercabile. I pulsanti restano <x-btn> (lo impone StileUnificatoTest). Il ramo "link scaduto" dell'elenco (solo per non autenticati, cioè mai su questa pagina) è stato tolto.
  • AssegnaPrestazioniTest tiene fermi questi punti, compreso un tetto al peso per riga dell'elenco.
  • Corretto anche Assegnazione::giorni: con Carbon 3 diffInDays() è un float e PHP segnalava una conversione implicita a ogni riga.

Home: "Vedi tutti" di Da assegnare e Da rientrare è ora un pulsante nella testata del riquadro, accanto al PDF, invece che in fondo all'elenco. Compare alle stesse condizioni di prima (elenco troncato al limite della Home).

Scelta di territori e proclamatori con ricerca (19 ottobre 2026)

Al posto delle tendine di territorio e proclamatore (Assegna, finestra del Registro, editor delle piantine) c'è <x-scelta>: si scrive e compaiono le proposte più vicine; campo vuoto o appena aperto = la tendina completa.

  • Ordine: uguale → inizia con → contiene → a una lettera di distanza ("forse cercavi", due lettere dalle 7 in su; mai sui numeri sotto le 4 cifre, così "35" non trova "36"). Le parole scritte valgono in qualsiasi ordine ("mario ros"); maiuscole e accenti non contano. Logica pura in resources/js/scelta.js, provata da tests/js/scelta.test.mjs (npm run test:js, oppure node --test tests/js/).
  • Le opzioni le prepara il server (Territorio::opzioneScelta(), Proclamatore::opzioneScelta()/opzioniScelta()). Con motivo un'opzione compare cercando ma non si sceglie: in Assegna i territori già assegnati ("già assegnato a …"), nel Registro territori e proclamatori disattivati.
  • Nomi e cognomi sono cifrati: si ordinano e si cercano decifrati, nella pagina. Prima il Registro li ordinava in SQL, cioè a caso.
  • Telefono: testo a 16px sotto i 640px (iOS non ingrandisce), voci alte 44px, elenco in mezzo schermo, scelta al tocco senza perdere il focus; dopo la scelta col dito il campo perde il focus e la tastiera si chiude.
  • Con Livewire: wire:model sul componente (x-modelable). vuoto è il valore di "nessuna scelta": null per le proprietà ?int (Assegna), '' per le stringhe. Il componente si ridisegna quando cambiano le opzioni (wire:key con l'impronta), perché x-data le legge una volta sola.
  • Il filtro "Filtra territori" di Assegna non serve più ed è stato tolto.

Si torna da dove si è partiti (19 ottobre 2026)

Regola dell'utente: dopo un'operazione — confermata o annullata — si torna alla pagina da cui il percorso era partito, con i suoi filtri, ricerca, ordinamento e pagina. Elenco filtrato → scheda → Rientra → conferma riporta all'elenco filtrato, non alla scheda (scelta esplicita).

  • Gli elenchi (territori, proclamatori, registro) tengono ricerca, filtri e ordinamento nell'indirizzo con #[Url]; la pagina c'era già (paginazione).
  • I link verso schede e operazioni hanno data-ritorno: resources/js/app.js gli aggiunge ?da=<percorso> al momento dell'uso (pointerdown/click/menu contestuale/focus, prima di wire:navigate). Se la pagina corrente ha già un da, passa quello: si risale sempre all'origine.
  • App\Support\Ritorno::valida() accetta solo percorsi interni (niente https://…, //…, \, caratteri di controllo, /livewire/…): il parametro non può diventare un rimando verso l'esterno.
  • Le pagine di scheda e operazione usano il trait RicordaRitorno ($ritorno, destinazione()): redirect dopo il salvataggio, "Annulla" e "Torna…" (che con un ritorno diventa "Torna indietro").
  • Aperte senza da (menu laterale, link salvato) tutto resta com'era.
  • Link nuovi verso una scheda o un'operazione: metterci data-ritorno; pagine nuove di operazione: usare RicordaRitorno.

Richieste dell'utente:

  • Dopo l'assegnazione non si va avanti finché non si è deciso del link: si apre una finestra che blocca la pagina (oggi trait Concerns\InviaLink, usato anche da "Il mio gruppo") con i canali. Superato la sera stessa: WhatsApp ed email sono stati tolti, restano Copia link e Condividi… — vedi "Proclamatori senza contatti". Com'era il primo giorno:

    • Copia link: sempre;
    • WhatsApp: solo se il proclamatore ha un numero utilizzabile;
    • Email: solo se ha un indirizzo e la posta funziona (ConfigurazionePosta::funzionante), inviata subito e mai in coda.

    Poi "Hai inviato il link?": Sì vuole un canale usato, No è sempre possibile. Solo dopo la risposta si ricarica la pagina.

  • Se la procedura non si conclude (scheda chiusa) o si risponde No, resta assegnazioni.link_da_inviare = true e compare "Link ancora da inviare" nell'elenco della pagina Assegna (con "Invia link", che riapre la finestra) e nella scheda del territorio (che rimanda a assegnazioni/assegna?invia=<id>). L'avviso sparisce quando il link risulta inviato, anche dal pulsante "Non inviato / Link inviato" dell'elenco.

  • Colonne nuove (migrazione 2026_10_19_000010): link_da_inviare (le assegnazioni già esistenti restano false: niente avvisi a posteriori su link di cui non si sa niente) e link_canale (oggi copia/condividi, Assegnazione::CANALI_ATTIVI; whatsapp/email restano in CANALI_LINK solo per le assegnazioni già inviate così). Entrambe nell'XML.

  • Nel log link_inviato / link_non_inviato con canale e proclamatore_id: mai nomi.

  • "Nuovo territorio" (elenco territori) apre il designer delle piantine con ?nuovo=1 (PiantineEditor::$creaTerritorio): la mappa parte già in disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto. L'editor è solo dell'amministratore: chi ha territori.manage ma non settings.manage resta sul form territori.create, che esiste ancora.

Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)

Richieste dell'utente, con le sue scelte:

  • Server di posta dall'app (Impostazioni → Server di posta, rotta posta.edit, Settings\PostaElettronica): host, porta, cifratura (automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante "Invia email di prova". Se compilato prende il posto del .env (ConfigurazionePosta::applica(), agganciata alla creazione del mail manager e a ogni job della coda). Non si esporta nell'XML e l'import non la cancella (Setting::CAMPI_POSTA). Nel .env.example MAIL_SCHEME=tls era un valore non valido per Laravel 11: ora vuoto.
  • Proclamatori: email e telefono facoltativi — tolti il 19 ottobre 2026, vedi "Proclamatori senza contatti" qui sotto.
  • Dopo l'assegnazione — rivisto il 19 ottobre 2026, vedi "Invio del link dopo l'assegnazione" qui sotto. Il riquadro in sessione (Assegna::DA_INVIARE) non esiste più.
  • Conferma email obbligatoria per tutti gli utenti (tranne il sorvegliante di gruppo), anche quelli già esistenti (la migrazione 2026_10_13_000010 azzera email_verified_at). L'admin crea l'utente con la password come prima; parte l'email di conferma (in italiano, VerifyEmail::toMailUsing); il link funziona anche senza login. Cambiare email (profilo o admin) la rende da confermare e rimanda il link. In Utenti: stato "da confermare" e "Reinvia conferma". Raggiungibili senza conferma: profilo, cambio password, pagina di conferma e — per l'amministratore — il server di posta. Emergenza: php artisan utenti:conferma-email <email>. Il primo admin creato da riga di comando nasce confermato.
  • Nei test gli utenti nascono confermati (tests/TestCase.php, $confermaUtentiCreati).

Provato nel browser in locale con mailpit come server configurato dall'app: email di prova e di conferma arrivate, link aperto senza login, account sbloccato. Configurazione e dati di prova rimossi dopo.

Modulo S-13, editor piantine, revisione (12 ottobre 2026)

In produzione dal 19 ottobre 2026 (erano su dev dal 12):

  • d433c93 — Modulo S-13 sul modulo ufficiale (regole in app/Services/Registro/ModuloS13.php) e blocco 1 della revisione (integrità dati).
  • 056d95c — Editor piantine: bozza automatica, un solo "Pubblica" con anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona: Le piantine sono una sotto-app.
  • 426a952 — blocchi 2 e 3 della revisione (sotto).

Revisione profonda: blocchi 2 e 3 chiusi

Problema Correzione
Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) trait Avvisa + <x-avvisi>; un test vieta session()->flash() senza redirect
PDF, miniature e versioni scaricabili da /storage/territori-* senza login (nomi indovinabili, cache Cloudflare 7 giorni) nginx risponde 404 su /storage/territori-; TerritorioFileController con permessi della pagina e cache privata; Territorio::pdfUrl() / miniaturaUrl()
Ripristino non atomico estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (rename, annullabile) → file vecchi cancellati solo dopo il commit
Piantine, storico PDF, area KML fuori dal backup formato archivio 2 (piantine.json, piantine/…, PiantineBackup); gli archivi di formato 1 si ripristinano lo stesso
Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi BackupOperationState::occupa() sotto Cache::lock, usato da tutte le porte; rilascio anche su errore e in failed()
Import PDF da cartella: 1-A → territorio 1, 12 B ambiguo, solo 20 file, Thumbs.db blocca tutto, __MACOSX abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati
Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo prima il nuovo, poi il vecchio nello storico (PiantinaPdfService::archiviaCorrente, origini import / manuale)
L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) l'import elimina i file non più usati; per i vecchi c'è php artisan territori:file-orfani (elenca; --elimina cancella) — da lanciare quando si decide
Azzeramento lasciava piantine orfane, area KML, anteprime cancellate anche quelle
/cambia-password senza password attuale richiesta, tranne dopo un ripristino (cambio obbligatorio)
Sessioni aperte altrove non chiuse al cambio password AuthenticateSession nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori
Nomi dei proclamatori in chiaro nel registro attività si salva proclamatore_id, il registro mostra il nome attuale; migrazione 2026_10_12_000010 ripulisce le voci vecchie
Export XML con remember_token; l'import XML non obbligava a cambiare password token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password
Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% confronto per data; si contano i territori attivi distinti
retry_after 90 s con job fino a 900 s config/queue.php: 960 s
Worker ucciso dopo 10 s al riavvio stop_grace_period: 16m
Redis allkeys-lru poteva sfrattare i job in coda volatile-lru
Cache statica di Setting vecchia nel worker svuotata a ogni job (Queue::before)
Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (pdf_pubblicato, migrazione 2026_10_12_000020)
corrente() / liberaCorrente() potevano cancellare il puntatore di un'operazione appena avviata corrente() in sola lettura; liberaCorrente() sotto lo stesso mutex di occupa()
Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) .tm-btn sta fuori dai layer di Tailwind e vince su hidden: si nasconde il contenitore

Non cambiato, di proposito: trustProxies('*') (commento in bootstrap/app.php: con real_ip di nginx, restringerlo romperebbe HTTPS).

Suite: 429 test verdi. Provati in browser in locale: file protetti (404 senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2 lotti, Thumbs.db ignorato, nessun territorio toccato, posto liberato).

Non provato / aperto:

  • chiusura delle sessioni con due browser veri; coda e worker con un riavvio reale; modifica vertici al tocco su tablet vero;
  • l'upload Livewire ha throttle:60,1: oltre ~1200 file al minuto qualche lotto riceve 429 (il browser lo segnala);
  • numeri di territorio con lettere davanti ("A12") non si abbinano da nome file: il PDF si carica dalla scheda.

Da dove veniamo

Il progetto aveva un difetto strutturale nel deploy: il Dockerfile compilava l'applicazione in /app-src, ma il runtime girava da /var/www/html, popolato solo dal bind mount del sorgente. L'immagine costruita non veniva mai usata. L'entrypoint rieseguiva composer install e npm run build a ogni avvio dentro il container. Su Dokploy, senza sorgente sull'host, significava build a runtime a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.

Da qui la ristrutturazione descritta sotto.

Luglio-agosto 2026

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. 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.

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.

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.


Dove gira davvero

Due ambienti su Dokploy, stesso progetto TerManager2, entrambi alimentati dal repository Gitea francesco/termanager2 su repository.pyconetwork.it (il vecchio git.pyconetwork.it non è più la sorgente).

Ambiente Branch Dominio Compose (nome / appName / composeId)
production main termanager2.pyconetwork.it termanager2-core-production / termanager2-core-vizcxr / vh_NtgaPyfQintCbcB3p6
develop dev demo-termanager.pyconetwork.it termanager2-core-demo / termanager2-core-vbekis / 20DoOKciTzmhlPWdcqcxz

Conseguenze pratiche, tutte imparate sul campo:

  • L'autodeploy non parte. È attivo in Dokploy, ma dal 19 ottobre 2026 nessun push ha avviato un deploy (il webhook di Gitea è da verificare): dopo un push si aspettano un paio di minuti e, se in deployment non compare niente di nuovo, si avvia a mano (compose.deploy via API, o Deploy nell'interfaccia). Controllare prima di avviare, per non fare deploy doppi.

  • Test sul server, prima del push. In locale (Windows) non ci sono PHP, Docker né Node: la suite gira sul server Dokploy con una schedule di tipo dokploy-server usa e getta, che costruisce lo stage test del Dockerfile e lancia anche node --test tests/js/. Per provare un commit non ancora pubblicato (il push su dev lo pubblicherebbe): git bundle create x <commit della checkout>..dev, bundle in base64 dentro lo script, git clone della checkout di Dokploy in /tmp e git fetch del bundle. La checkout di Dokploy (/etc/dokploy/compose/<appName>/code) è shallow e può essere indietro rispetto a origin/dev (i commit di sola documentazione non la aggiornano): il bundle parte dal suo git log -1. La schedule gira dentro il container di Dokploy: docker run -v /tmp/... non vede quei file, si passano con tar | docker run -i.

  • Il branch decide l'ambiente. Pubblicare su dev non tocca la produzione, che segue main. È la separazione su cui contare quando si vuole provare qualcosa senza rischi.

  • Un "Rebuild deployment" NON scarica il codice nuovo: ricostruisce il checkout già presente e ci mette una ventina di secondi. Per prendere l'ultimo commit serve un deploy vero (compose.deploy via API, o Deploy nell'interfaccia). Un rebuild che finisce troppo in fretta è il segnale che si sta ricostruendo il codice di prima.

  • Le variabili d'ambiente stanno in Dokploy, non nel repository. Il .env dell'ambiente lo genera l'interfaccia: una variabile aggiunta a .env.example non arriva da sola sui deployment, va aggiunta a mano lì. È successo con MAX_UPLOAD_MB, presente in develop e — deliberatamente — non in produzione.

  • Verifica che il codice servito sia quello giusto confrontando l'impronta degli asset: public/build/assets/app-*.css e -*.js hanno il nome derivato dal contenuto, quindi se coincide con quello della propria build, il frontend in linea è lo stesso sorgente. È il controllo più rapido dopo un deploy, e non richiede di autenticarsi.


Decisioni architetturali

Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il contesto per ridiscuterla è qui.

L'immagine è autosufficiente

vendor/ e public/build sono compilati in stage separati del Dockerfile e copiati nell'immagine finale. I container di produzione non montano il sorgente e non eseguono build all'avvio.

Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend, serve un rebuild dell'immagine, non un semplice restart.

nginx e php-fpm nello stesso container

Con container separati, nginx avrebbe bisogno di una copia di public/ e del volume storage solo per servire file statici. Per questa scala è complessità senza beneficio. Sono coordinati da supervisor (docker/supervisor/supervisord.conf), che abbatte il container se uno dei due muore definitivamente — altrimenti resterebbe "up" senza servire nulla.

La configurazione arriva dall'ambiente, non da .env riscritto

Il vecchio entrypoint riscriveva .env con sed a ogni avvio. Ora Laravel legge direttamente l'ambiente di processo, e docker-compose.yml si limita a env_file: .env senza ridichiarare nulla.

Il .env è quindi l'unica fonte di configurazione. Una versione precedente fissava nel compose i valori dettati dalla topologia (DB_HOST: mariadb e simili) perché vincessero su un .env sbagliato, ma richiedeva gli anchor YAML (x-app-env, <<:) ed è stata scartata a favore della leggibilità.

Il rovescio della medaglia: DB_HOST e REDIS_HOST devono valere mariadb e redis, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il container non trova più il database. È documentato in .env.example e nel README, ma è il punto fragile di questa scelta.

Nota: perché la configurazione da ambiente funzioni, www.conf ha clear_env = no. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe nessuna variabile.

Fail-fast sulle variabili mancanti

Lo stack si rifiuta di partire senza APP_URL, DB_PASSWORD, DB_ROOT_PASSWORD, REDIS_PASSWORD. Le password sono validate dall'interpolazione compose (${VAR:?}), APP_URL dall'entrypoint (dove il messaggio d'errore è più chiaro).

Deliberato: un errore immediato è meglio di un database in produzione con password secret.

Le piantine sono una sotto-app: renderer esterno, agganci minimi

Le Piantine (solo amministratore) generano le mappe dei territori. Si carica il KML dell'area della città, si disegnano i confini su OpenStreetMap (Leaflet) nell'editor a schermo intero, e da ogni disegno si ottiene un'immagine ad alta definizione e — a scelta — la cartolina sul modulo ufficiale S-12 (resources/pdf/S-12_I.pdf, importato con FPDI) che sostituisce il PDF del territorio. Il PDF precedente finisce nella tabella territorio_pdf_versioni ed è ripristinabile dalla pagina di gestione.

Il rendering vero e proprio non sta nell'applicazione: lo fa il container map-renderer (docker/renderer/, Node + sharp), che scarica le tessere OSM (cache persistente nel volume tile_cache, User-Agent identificante, attribuzione impressa sull'immagine come da licenza), le cuce, disegna il confine e attenua l'esterno. L'app gli parla solo via HTTP sulla rete interna (config/piantine.php, default http://map-renderer:3000: nessuna variabile da aggiungere in Dokploy). Se il container è giù, l'unica cosa che smette di funzionare è la generazione delle immagini; il resto del gestionale non lo sa nemmeno.

Gli agganci al core sono deliberatamente pochi: la rotta nel gruppo settings.manage, la voce di menu, e l'aggiornamento di pdf_path/thumbnail_path sul territorio quando si sostituisce un PDF (stesso percorso territori-pdf/ + TerritorioThumbnailService dell'upload manuale). Le geometrie vivono nella tabella separata territorio_mappe, l'area KML in storage/app/piantine/: rimuovere la sotto-app significa togliere questi pezzi, non disfare il gestionale.

Il disegno viene prima del numero. Una piantina non ha bisogno di un territorio: territorio_mappe.territorio_id è nullable e il collegamento si fa (e si disfa) quando si vuole, con nome come etichetta finché manca. Si taglia l'area guardando le strade e solo dopo si decide di chi è — e se quel territorio non esiste, lo si crea dall'editor col solo numero. La chiave esterna è nullOnDelete, non più cascadeOnDelete: cancellare un territorio, o svuotare territori come fa ogni import XML, non porta più via i disegni.

Due pagine, non una. /piantine è il quadro d'insieme: stato di ogni piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate / scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre, si pubblica e si elimina. /piantine/editor è l'editor a schermo intero, con un telaio suo (layouts/editor) senza barra laterale: tagliare un confine su una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano nello spazio libero (variabili --libero-sx/--libero-dx); sotto i 720px il pannello sale dal basso. Su tablet i vertici sono più grandi (pointer: coarse).

Bozza e pubblicazione (ottobre 2026). Nell'editor tutto si salva da solo: il confine a ogni disegno o spostamento di vertice, nome e collegamento appena cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio (numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano subito, colonna per colonna (un campo non valido non trascina gli altri). Il PDF cambia solo con Pubblica, uguale in editor e gestione (Livewire\Piantine\Pubblicazione): PubblicazionePiantina::avvia() mette in coda PreparaAnteprimaPiantina (il primo rendering può superare i 100 s di Cloudflare), che genera immagine e PDF sul disco privato (storage/app/piantine-anteprime/, stato in cache sotto un token di 40 caratteri, servito da piantine.anteprima); la finestra controlla ogni 2 s e mostra il PDF; solo Conferma lo rende ufficiale. Se nel frattempo è cambiato qualcosa di stampato, la conferma rifiuta.

Gli stati (TerritorioMappa::stato()): Bozza (mai pubblicata), Pubblicata, Modifiche da pubblicare. Il confronto è sull'impronta(): sha256 di confine, territorio, numero, nome della zona e confini — cambia uno qualunque e la piantina torna da pubblicare anche col disegno identico. Vale "Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina (territori-pdf/piantina-{territorio}-…): un ripristino dallo storico, un upload a mano o un import la riportano "da pubblicare". "Ripristina il confine pubblicato" riporta la bozza a geojson_pubblicato. La migrazione 2026_10_09_000010 ha segnato pubblicate le piantine esistenti solo se il PDF in vigore del territorio veniva da una piantina.

Disegnare crea sempre una piantina nuova; per cambiare quella aperta ci sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si parla per eventi: i dati di tutte le piantine li chiede il JS una volta (datiMappa()), poi arriva solo la piantina cambiata (piantina-aggiornata). Gli avvisi passano dall'evento avviso (trait Livewire\Concerns\Avvisa, componente <x-avvisi>): i flash di sessione nel telaio non si vedono dopo un'azione Livewire.

La cartolina e la piantina sono due pagine distinte. Il riquadro dell'S-12 è largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi pagina 1 è il modulo con Località, numero e i confini scritti nello spazio centrale (il corpo del testo si restringe da solo finché ci sta), pagina 2 è la piantina sola al 98% della pagina.

La "Località" del modulo è il nome della zona. Era un campo globale, poi un campo della piantina, e in entrambi i casi era un doppione: la zona esiste già, dice la stessa cosa («Palermo Montepellegrino») ed è quella che si aggiorna quando la congregazione riorganizza il lavoro. Due posti dove scrivere lo stesso nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco marca i territori collegati senza zona: senza, l'intestazione della cartolina esce vuota e ce se ne accorge in stampa.

L'immagine è JPEG, sovracampionata e nella forma della pagina. Tre scelte che vanno lette insieme (config/piantine.php, docker/renderer/server.js):

  • Il renderer compone alle tessere dello zoom più alto che entri in max_side × 2 e poi riduce con Lanczos. Le etichette OSM sono disegnate dentro la tessera a dimensione fissa: salire di uno zoom le rende più definite, e ridurre dopo porta quella nitidezza dentro l'immagine finale.
  • Chiede al renderer le proporzioni della pagina (aspetto): un territorio quasi quadrato su una cartolina orizzontale, tenendo le sue proporzioni, lascerebbe due fasce bianche larghe un quinto di foglio. Allargando il ritaglio si mostrano più dintorni invece di carta vuota.
  • JPEG a qualità 86 invece di PNG: 0,9 MB invece di 5 per territorio. Con 274 territori è la differenza fra qualche centinaio di MB e diversi GB.

L'attribuzione OSM si dimensiona sull'immagine finale, non su una misura fissa: 22 px su 3400 sarebbero meno di un millimetro stampato, cioè presenti nel file ma invisibili a chi tiene la cartolina — e la ODbL la vuole leggibile. Per lo stesso motivo il tratto del confine è espresso in pixel dell'immagine finale e riportato alla scala di composizione: deve dire dove passa il confine senza cancellare la via che ci corre sotto, che è proprio quella che serve leggere.

Il sistema visivo sta nei token, non nelle viste

resources/css/app.css ridefinisce le scale gray e indigo di Tailwind dentro @theme. È la cosa che sorprende di più aprendo il file, ed è deliberata: le trentasette viste sono scritte con le utility (text-gray-600, bg-indigo-50), e cambiare la scala le riallinea tutte insieme. L'alternativa — riscrivere le classi vista per vista — a ogni file dimenticato lascia in giro il grigio di prima accanto a quello nuovo, e nessuno se ne accorge finché non apre proprio quella pagina.

Da qui tre conseguenze che vanno sapute prima di metterci mano:

  • I grigi sono caldi. Quelli di serie di Tailwind tirano al blu e, sommati a un accento indaco, davano all'interfaccia una dominante fredda. Questi elenchi diventano moduli di carta che passano di mano: un fondo caldo li avvicina a quello che sono. Non mescolare grigi freddi qui dentro.
  • L'accento è uno. Indaco = azione, verde = fatto, ambra = in sospeso, rosso = distrugge dati. Se una cosa non rientra in queste quattro, è grigia. Prima le tinte erano cinque e decorative.
  • Il carattere è servito dall'applicazione (@fontsource-variable/figtree, dipendenza npm), non da Google Fonts. Un gestionale che tiene nomi e indirizzi di persone non deve chiamare un dominio di terze parti a ogni pagina caricata, e così funziona anche senza rete.

I componenti tm-* (pulsanti, console, card, campi, elenchi, editor) stanno tutti qui e fuori da @layer, quindi vincono sulle utility: un pulsante non si può ritoccare a metà con le classi. Chi aggiunge una schermata usa questi, non catene di utility nuove.

HTTP_HOST a nginx deve conservare la porta

docker/nginx/default.conf rimette fastcgi_param HTTP_HOST $http_host; dopo l'include fastcgi_params. Non e' ridondanza: dalla 1.26 il fastcgi_params di sistema usa $host, che la porta la butta via. Con APP_URL su porta non standard — cioe' lo sviluppo locale su :8090 — Laravel ricostruisce l'URL della richiesta senza porta mentre URL::forceRootUrl() genera i link con, e ogni URL firmato non valida piu': tutti gli upload di file rispondono 401, perche' l'endpoint di Livewire aborta sulla firma. In produzione APP_URL non ha porta e le due forme coincidono, quindi il guasto si vede solo in locale. Inoltrare l'header grezzo e' sicuro perche' trustHosts() in bootstrap/app.php rifiuta gia' gli Host che non siano quello di APP_URL, localhost o 127.0.0.1.

Modifica/cancellazione delle assegnazioni: solo amministratore

L'unico punto in cui un'assegnazione esistente si modifica o si cancella è il CRUD del Registro (Livewire/Registro: openCreate/openEdit/save/ askDelete/deleteConfirmed), blindato su settings.manage a due livelli — abort_if(..., 403) nel componente e @can('settings.manage') in registro.blade.php, che nasconde pulsanti e modali. settings.manage ce l'ha solo l'amministratore.

Di conseguenza gli altri ruoli non possono alterare le assegnazioni: la pagina Assegna solo ne crea (territori.assign), la pagina Rientra solo le chiude (territori.return). Nello specifico l'assistente (registro.view + territori.assign + territori.return) vede il Registro, crea nuove assegnazioni e le fa rientrare, ma non le modifica né le elimina.

Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su syncPermissions() più sotto), un permesso settings.manage concesso a mano all'assistente in passato sopravviverebbe a ogni re-seed. La migration 2026_07_25_000001_revoke_settings_manage_from_assistente_role chiude questo drift per i database esistenti: revoca settings.manage dal ruolo assistente se presente. È idempotente (no-op sul baseline) e volutamente irreversibile — il down() non ripristina il permesso.

Stesso principio per le campagne: creazione e visualizzazione restano su campagne.manage (che ha anche l'assistente), ma modifica ed eliminazione sono riservate all'amministratore (settings.manage). Il gate è su tre livelli: la route campagne/{campagna}/edit è nel gruppo settings.manage; CampagnaEdit::mount()/save() e CampagnaIndex::deleteCampagna() chiamano $this->authorize('settings.manage') (l'index è raggiungibile da chi ha campagne.manage, quindi il delete va protetto nel metodo, non solo in route); i pulsanti Modifica/Elimina in campagna-index/campagna-show sono sotto @can('settings.manage'). Nota: Campagna non ha soft delete — l'eliminazione è definitiva.

Per i territori vale un modello leggermente diverso, perché la visualizzazione va aperta a tutti gli operativi: lista (territori.index) e dettaglio (territori.show) sono ora su territori.assign (che hanno admin, assistente e operatore); creazione/modifica/cestino restano su territori.manage (solo admin). Attenzione a due punti:

  • Ordine delle rotte. Le rotte letterali territori/create e territori/cestino devono restare registrate PRIMA del wildcard territori/{territorio}, altrimenti verrebbero catturate dalla rotta di dettaglio.

  • TerritorioIndex perde la copertura del middleware persistente. Il suo componente è ora servito da una rotta rilassata (territori.assign), quindi i suoi metodi di mutazione (toggleActive, togglePriority, deleteTerritorio) NON sono più protetti dal middleware di route e hanno un $this->authorize('territori.manage') interno — senza, un operativo potrebbe invocarli via /livewire/update col pulsante nascosto. Per difesa in profondità lo stesso check è stato aggiunto anche a TerritorioCreate::save, TerritorioEdit::save/removePdf, TerritorioCestino::restore/forceDelete (che restano comunque su rotte territori.manage). I pulsanti Modifica/toggle/Elimina/Nuovo/Cestino in territorio-index/territorio-show sono sotto @can('territori.manage'); il link "Dettaglio" resta visibile a tutti. Il menu laterale mostra "Territori" a chi ha territori.assign.

Sola lettura per proclamatori e campagne (non-admin)

Stessa impostazione dei territori estesa ad altre due sezioni:

  • Proclamatori: index e show restano su proclamatori.manage (l'assistente li vede in sola lettura); create/edit/cestino e tutte le mutazioni (ProclamatoreIndex::toggleActive/deleteProclamatore/anonimizza, ProclamatoreCreate::save, ProclamatoreEdit::save, ProclamatoreCestino::restore/forceDelete) richiedono settings.manage. ProclamatoreIndex è servito da una rotta rilassata, quindi i suoi metodi di mutazione hanno il check interno; i pulsanti in proclamatore-index/-show/ -cestino sono sotto @can('settings.manage'). Le rotte letterali create/cestino restano prima del wildcard {proclamatore}.
  • Campagne: la creazione è ora admin-only come già modifica/eliminazione — CampagnaCreate::save chiama authorize('settings.manage'), la rotta campagne/create è nel gruppo settings.manage (registrata prima del wildcard) e il pulsante "Nuova Campagna" è sotto @can('settings.manage'). index/show restano su campagne.manage (sola lettura per l'assistente).

Tabelle-elenco responsive su mobile (portrait)

Le liste principali usano una sola "politica" CSS invece di layout duplicati: la classe table-cards (in resources/css/app.css) sotto 640px nasconde il thead e trasforma ogni riga in una scheda, con ogni cella resa come "ETICHETTA … valore" (l'etichetta viene dall'attributo data-label della <td>). Un telefono in portrait sta sotto la soglia → schede; ruotato in landscape la larghezza supera i 640px → torna la tabella normale (per questo landscape "non ha problemi"). Convenzioni: ogni <td> dati ha data-label="..."; la cella azioni ha classe cell-actions (niente label, occupa la riga a piena larghezza); le celle colspan (stato vuoto, form di modifica utente inline) restano block a piena larghezza. Applicata a tutte le liste e sottopagine con tabelle: territori, proclamatori, campagne, registro, utenti, audit-log, i due cestini, le tabelle "storico assegnazioni" di territorio-show/proclamatore-show, "territori conteggiati" di campagna-show e le due tabelle di esito import in xml-exchange (una a classi Tailwind, una a stili inline — border:none !important nella regola gestisce anche i bordi inline). La pagina Assegnazioni era già ottimizzata.

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.

La sezione "Territori Assegnati" (i link PDF) ha un unico campo che filtra per numero territorio, zona e proclamatore. Il filtro è in memoria (proprietà linkSearch, collection assegnazioniAperteVisibili) perché nome/cognome del proclamatore sono cifrati e non filtrabili via SQL — stesso approccio del Registro. La sezione resta visibile anche a risultato vuoto (mostra "Nessun risultato"), perché l'@if esterno guarda la lista non filtrata.

Assegnare un territorio ne azzera il flag "prioritario"

prioritario è un flag manuale; is_prioritario è true se il flag è acceso oppure se la giacenza supera la soglia — ma la parte a soglia vale solo per i territori in reparto (scopePrioritari/getIsPrioritarioAttribute filtrano su inReparto). Quindi un territorio assegnato può risultare prioritario solo per via del flag manuale. Regola: alla creazione di un'assegnazione aperta il flag viene spento. È implementata come evento Assegnazione::created (in booted()), guardato su returned_at === null e prioritario === true, così copre ogni via di creazione (pagina Assegna, CRUD del Registro, import XML) senza duplicare la logica. Interazione con l'import XML: per i backup creati dopo questa regola è un no-op (nessun territorio assegnato è prioritario); su backup vecchi con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e quale.

Profilo utente, viewer PDF, privacy admin-only

Tre ritocchi minori ma con qualche implicazione:

  • Pagina profilo (Profile → rotta profilo, nome profile) accessibile a ogni utente loggato (nel gruppo auth, nessun permesso): l'utente corrente può modificare il proprio nome/cognome ed email (updateProfile) e cambiare la password (updatePassword, con verifica della password attuale via regola current_password; il cast hashed cifra al salvataggio). Dopo il salvataggio dei dati updateProfile fa redirect(navigate:true) sulla stessa pagina, così header (nel layout, fuori dal componente) e card mostrano subito i nuovi valori. Il link al profilo è nell'header su desktop e, su mobile, in fondo alla sidebar (lg:hidden, dopo un separatore) — sull'header è hidden sm:flex. Non esiste un campo "username" né "cognome" separato: name è il nome completo (etichettato "Nome e cognome") e l'email fa da identificativo. La qualifica (ruolo) non è modificabile dal profilo — resta in gestione all'admin.
  • Viewer PDF pubblico: oltre a territorio/proclamatore/data mostra ora giorni assegnato, consegna prevista e scadenza. Sono attributi calcolati su Assegnazione (data_consegna_presunta, giorni_alla_scadenza) basati sulla soglia giorni_per_smarrito — la stessa oltre cui Territorio::stato diventa da_rientrare. Il viewer è servito solo per assegnazioni aperte, ma gli accessor gestiscono comunque il caso returned_at (ritornano null).
  • Privacy solo admin: la rotta privacy è passata dal solo auth al gruppo permission:settings.manage e il link in sidebar è sotto @can('settings.manage').

Backup completo .zip, ripristino, azzeramento

L'export XML da solo non ricostruisce un'installazione: pdf_path, thumbnail_path e logo_path sono percorsi sul disco public, quindi un ripristino su volumi vuoti lascia ogni territorio che punta a un file inesistente — link pubblici a 404 e miniature rotte. Il pacchetto .zip (Services/BackupArchive, Http/Controllers/Settings/BackupController) mette insieme manifest.json, data.xml (lo stesso XML dell'export) e storage/… con tutti i file del disco public. L'export XML resta, etichettato come parziale.

Scelte non ovvie:

  • Rotte HTTP, non Livewire. Lo streamDownload di Livewire passa il file base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci sta. backup/file/{op} e backup/restore sono due rotte normali nel gruppo settings.manage. Per lo stesso motivo ZipArchive::addFile() (che legge dal disco in close()) e gli stream in estrazione: nessun PDF passa intero per la memoria del processo.

  • Tutto in coda, con avanzamento reale. Backup, ripristino e azzeramento sono job (Jobs/CreateBackupArchive, RestoreBackupArchive, ResetApplication) che scrivono su Services/BackupOperationState, e Livewire/Settings/BackupManager lo interroga con wire:poll. In coda perché un'installazione con qualche centinaio di PDF supera i 300 secondi di fastcgi_read_timeout e il browser vedrebbe cadere la connessione a metà compressione, senza modo di distinguerlo da un errore.

    La barra conta unità di lavoro completate, non tempo. Per la compressione questo costa qualcosa: ZipArchive non scrive niente finché non si chiama close(), quindi contare le addFile() porterebbe al 100% in un istante per poi restare fermo per tutta la scrittura. BackupArchive::create() chiude e riapre l'archivio a lotti (FLUSH_MASSIMI = 20): si paga una ricopiatura delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è totale/20, così la ricopiatura resta limitata a ~10 volte la dimensione dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono cicli nostri, lì l'avanzamento è per-file senza artifici.

  • L'azzeramento ha due tempi, separati dal download. Il pulsante nella zona pericolosa (avviaAzzeramento) crea solo il backup e si ferma in attesa-download; la cancellazione parte da un secondo clic esplicito (confermaAzzeramento), che rifiuta se lo stato non registra un servito_at e se non riceve la password dell'amministratore. Il primo tentativo faceva partire il reset dalla coda dello streamDownload, "dopo l'ultimo byte" — non funziona: con fastcgi_buffering on (il default, e docker/nginx/default.conf non lo disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi prosegue anche se il browser ha chiuso la connessione al primo byte. Un download annullato azzerava lo stesso. Il server può verificare di aver spedito l'archivio, non che sia arrivato: quella verifica è dell'operatore, e per questo la conferma è umana.

  • La password si richiede al secondo clic, e non passa dallo snapshot. La sessione è già autenticata, quindi la password non serve a stabilire chi sia: serve a stabilire che davanti allo schermo ci sia ancora quella persona — una scheda lasciata aperta su una postazione condivisa è lo scenario contro cui protegge. Viaggia come parametro di confermaAzzeramento($password), non come proprietà wire:model: le proprietà pubbliche di Livewire vengono serializzate nello snapshot e rispedite al browser a ogni risposta, e una password non ha motivo di fare quel viaggio di ritorno.

    Attenzione al dettaglio che costa un pomeriggio: la proprietà $passwordConferma esiste comunque, e resta sempre vuota. Livewire scarta dalla risposta ogni errore la cui chiave non corrisponda a una proprietà del componente (SupportValidation::dehydrate() filtra su Utils::hasProperty), quindi senza quella riga addError('passwordConferma', …) sparisce in silenzio: il pulsante sembra non fare nulla, mentre il controllo sta funzionando. Non collegarla al campo con wire:model, o la password torna nello snapshot.

    Cinque tentativi sbagliati in 5 minuti bloccano l'azione (RateLimiter, chiave per utente). Senza, la richiesta di password su una sessione dirottata sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account: nessuno resta chiuso fuori dall'applicazione.

  • Una sola operazione alla volta, con blocco che si autoripara. Backup, ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso disco: due in parallelo — due schede, due amministratori — non si rallentano soltanto, si sovrascrivono. BackupOperationState::corrente() fa da posto unico; lo controllano sia il componente Livewire sia BackupController::upload(), che sono due porte d'ingresso separate (la seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e corrente() lo considera libero se l'operazione puntata non esiste più o è terminata. In più i tre job hanno un failed() — Laravel lo chiama quando il worker uccide il job per timeout o errore fatale, cioè proprio i casi che handle() non può intercettare — che chiude l'operazione e libera il posto invece di lasciare una barra ferma per un'ora.

  • La pagina si riaggancia all'operazione in corso. BackupManager::mount() legge corrente(). Senza, ricaricare la pagina durante un backup lungo faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di conferma diventava irraggiungibile: nessuno poteva più né confermarlo né annullarlo, e il posto restava occupato fino alla scadenza.

  • L'esito del ripristino si legge senza consumarlo. Il job non ha una sessione in cui lasciare un flash, quindi deposita il messaggio in cache a nome di chi ha avviato il ripristino e la schermata di cambio password lo mostra. Deve essere un Cache::get + forget a password cambiata, non un pull: quando è il middleware a dirottare una richiesta di Livewire, il client segue il 302 via fetch — ed è quella fetch a bruciare il messaggio — per poi rifare la stessa GET con window.location, che troverebbe la cache già vuota. Con il pull l'amministratore non vedeva mai il riepilogo del proprio ripristino.

  • Il tetto degli upload è una variabile sola, MAX_UPLOAD_MB. Prima viveva in tre posti scollegati — client_max_body_size in docker/nginx/default.conf, upload_max_filesize/post_max_size in docker/php/php.ini, e la regola max: nella validazione — e bastava che uno restasse indietro perché il più piccolo vincesse nel modo peggiore. Adesso entrypoint.sh genera i primi due (conf.d/upload-limit.conf e conf.d/zz-upload-limit.ini) e config/app.php alimenta la terza.

    Non rimettere client_max_body_size nel blocco server di default.conf: una direttiva lì dentro vince su quella generata a livello http e reintroduce in silenzio proprio il disallineamento che questa disposizione esiste per evitare. La validazione tiene un 2% di margine sotto il valore di nginx, così a fermare un pacchetto fuori misura è Laravel, che sa spiegarsi, e non nginx.

    Il sintomo, quando il limite viene superato, non assomiglia affatto a un errore di dimensione: nginx risponde 413 dopo aver accettato zero byte e chiude. Il browser ha appena iniziato a spedire, la barra si pianta a una percentuale bassa e nessun evento utile arriva all'XMLHttpRequest — pagina muta, con l'aria di essersi bloccata. Da qui i tre presidi nella vista: controllo della dimensione prima di caricare, xhr.timeout con i relativi gestori di timeout/abort/error (senza, un caricamento fermo non produce più nulla per sempre), e il codice HTTP dentro il messaggio, perché 413 e 419 non hanno un corpo JSON da cui ricavare una spiegazione.

  • La console di log non è un vezzo diagnostico. Quasi tutti i modi in cui un caricamento muore sono muti: Cloudflare o nginx chiudono la connessione e nessun evento del browser porta con sé una spiegazione. Il pannello Console nella scheda di ripristino registra file scelto e dimensione, limite del server, avanzamento ogni 10%, codice HTTP della risposta, errori, timeout e interruzioni; sotto, i log lato server dell'operazione. Un battito ogni 15 secondi scrive «nessun avanzamento da N s», che è l'unico modo di distinguere lento da fermo. Il pulsante Copia tutto esiste perché il primo passo di ogni diagnosi è farsi mandare quel testo.

  • Nell'azzeramento i file si cancellano prima del database. Svuotare il disco è l'unico passo che può fallire per cause esterne (permessi sul volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un errore sui file lascerebbe database azzerato e disco pieno di PDF orfani. Collegato: svuotaDiscoPubblico() ricontrolla il disco dopo la cancellazione e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale. Flysystem non solleva se una unlink fallisce, e basta un file scritto da un processo con un altro utente (l'entrypoint lavora da root, php-fpm e il worker da www-data) perché l'operazione si dichiari riuscita lasciando il disco popolato.

  • L'archivio si controlla prima di consegnarlo. Un backup è l'unico file di cui non ci si accorge che era rotto finché non serve, quindi BackupArchive::create() non si fida di aver scritto bene: controlla il valore di ritorno di ogni addFile() (che torna false, senza sollevare, se il file è sparito o è illeggibile — l'archivio finirebbe con un buco silenzioso), e alla fine riapre l'archivio e verifica che contenga data.xml, manifest.json e esattamente i file attesi. Se qualcosa non torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun backup che un backup che sembra buono.

    Attenzione a una trappola di ZipArchive: dopo un close() fallito l'oggetto resta inutilizzabile e ogni metodo successivo solleva Invalid or uninitialized Zip object. Una chiusura di cortesia dentro un catch sostituiva il messaggio vero con quello, e l'amministratore leggeva una stringa incomprensibile al posto di «permesso negato su questo file». Da qui chiudi(), che cattura getStatusString() prima che vada perso, e i close() di pulizia avvolti in un try/catch che non può mascherare l'errore originale.

  • Eliminare definitivamente un territorio porta via anche i suoi file. TerritorioCestino::forceDelete() cancellava la riga e lasciava mappa e miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono prima della riga, così se la cancellazione fallisce il territorio resta nel cestino e l'operazione si può ritentare.

  • L'azzeramento tiene in vita l'amministratore che l'ha chiesto. Un'installazione appena creata ha un amministratore; cancellarli tutti lascerebbe l'applicazione senza nessuno che possa accedere fino al riavvio del container (l'admin iniziale lo ricrea entrypoint.sh da INITIAL_ADMIN_*, che in esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta via Eloquent, non con un delete di massa: è il trait HasRoles di Spatie a staccare ruoli e permessi nell'evento deleting, che un delete di massa non fa scattare. Il registro attività si svuota per ultimo — quelle cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che sopravvive, quella dell'azzeramento stesso.

  • Ordine del restore: verifica → una transazione → pulizia. Dal 12/10/2026 (RestoreBackupArchive): si estrae e si verifica tutto (CRC, byte, il piantine.json del formato 2) prima di toccare qualcosa; poi una sola DB::transaction contiene import XML, tabelle delle piantine, blocco degli account e, per ultimo, lo scambio dei file (BackupArchive::scambiaFile(): solo rename() sullo stesso volume, annotati e annullabili). Se qualcosa fallisce si torna allo stato di prima; i file vecchi aspettano in storage/app/backup-precedenti/<uuid> e si cancellano solo dopo il commit. Il disco public viene sostituito, non fuso: i percorsi vivono nelle righe appena riscritte.

  • Limiti sull'archivio in ingresso. Un .zip è compresso: senza tetti su numero di entry, dimensione della singola entry e totale decompresso, pochi MB possono riempire il volume (zip bomb). I nomi delle entry sono normalizzati e ogni .. o percorso assoluto viene rifiutato (zip slip) — senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione.

  • Dopo un restore tutti cambiano password. Il pacchetto contiene gli hash bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il restore accende password_change_required su tutte le righe di users (amministratore che sta ripristinando compreso) e azzera i remember_token, che sono credenziali persistenti e stanno pure loro nel backup. Il middleware RequirePasswordChange (in coda al gruppo web, quindi copre anche /livewire/update — Livewire segue le risposte redirezionate) dirotta su password.change tutto tranne quella pagina, il suo invio e il logout.

  • La schermata di cambio password non è Livewire e non chiede la password attuale. Non è Livewire perché deve restare raggiungibile mentre il middleware respinge anche le POST di Livewire, e distinguere quale componente sta dietro una /livewire/update è fragile; due rotte HTTP si escludono per nome. Non chiede la password attuale perché il restore ha appena sovrascritto gli hash con quelli del backup: la "password attuale" di un account è quella dell'epoca del backup, che l'utente — l'amministratore per primo — può benissimo non ricordare. La garanzia è la sessione: a quella pagina ci si arriva già autenticati.

Export/Import XML: backup completo e round-trip

Livewire/Settings/XmlExchange esporta e reimporta tutti i dati gestionali, non più un sottoinsieme. L'export (currentDataset + datasetToXml) include ora: utenti (con hash password, ruoli, email_verified_at, remember_token), i campi settings prima omessi (public_base_url, logo_path, pdf_viewer_show_download, setup_completed), lo stato soft-delete (deleted_at) di proclamatori/territori, i path file dei territori (pdf_path, thumbnail_path), le FK di audit delle assegnazioni (created_by/returned_by, esportate come legacy_*_id), link_sent, pdf_access_code, e i timestamp created_at/updated_at di ogni entità. L'import (importXmlFromContent) legge simmetricamente tutti questi campi, così un export si reimporta senza perdite.

Tre scelte non ovvie:

  • legacy_id derivato dall'id reale. Il rimappaggio delle FK in import si basa sull'attributo legacy_id. Prima veniva scritto solo dalla conversione SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import perdeva territori e assegnazioni (riferimenti non risolti). Ora addLegacyIdAttribute() usa legacy_id se presente, altrimenti l'id reale.

  • Gli utenti sono UPSERT per email, non azzerati. Tutte le altre entità vengono cancellate e ricreate; gli utenti no. Azzerare la tabella users durante un restore rischierebbe di estromettere l'amministratore che sta importando (backup che precede il suo account, o file parziale). Gli account esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è senza perdite e non blocca fuori nessuno.

  • Hash password e timestamp scritti col query builder. Dopo il save() del modello, password/remember_token/email_verified_at/created_at/ updated_at vengono scritti con DB::table()->update(): preserva l'hash esatto (il cast hashed rivaliderebbe il costo bcrypt e potrebbe rifiutare un hash generato con BCRYPT_ROUNDS diverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica in applyRowMeta() per le altre entità.

La pagina "Backup e import": cinque zone e una sola console

La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato, la zona pericolosa a metà strada, cinque aree di log separate e pulsanti che ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a tre regole.

Cinque zone numerate, in ordine di quanto sono definitive. 1 Backup, 2 Ripristino, 3 Import PDF, 4 XML, 5 Zona pericolosa. Ognuna ha un'ancora (id="zona-…") e l'indice appiccicato in cima ci salta sopra. Un test verifica che le cinque ancore esistano e che siano in quell'ordine nel sorgente: l'ordine è la sostanza della sistemazione, non un dettaglio estetico.

Il "focus" della pagina. Premuto un pulsante — o scelta una voce dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare (window.tmFocusZona). Il focus da tastiera si sposta solo quando la richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona è marcato data-zona-focus ed è quello che lo riceve.

Una sola console, agganciata in fondo alla finestra. Prima la stessa operazione si raccontava in tre punti diversi: un ripristino cominciava nel riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio" richiudibile nel pannello di avanzamento, in cima. Per capire dove si era fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di tempo, con la sorgente accanto a ogni riga, e la finestra è fissa: quasi tutti i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è interrotto il flusso di righe — un log che esce dallo schermo appena si scorre non serve proprio nel momento in cui servirebbe.

Dettagli non ovvi della console:

  • Le righe del server viaggiano già scremate. Il conto di quante ne sono state spedite lo tiene il componente Livewire (logSpediti), non il browser: la coda intera dentro ogni risposta significherebbe sessanta righe poco meno di due volte al secondo, e per l'import PDF — la cui coda non ha tetto — l'intera cronologia a ogni poll.

  • Per ogni operazione c'è un solo componente che la racconta (SegueOperazioneBackup::tipiSeguiti()). Backup e zona pericolosa guardano lo stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi le stesse righe, e la console le mostrerebbe due volte.

  • Il download automatico si ascolta in un posto solo. scarica-backup è un evento su window: due componenti che lo ascoltassero entrambi assegnerebbero window.location.href due volte, e il pacchetto verrebbe scaricato due volte. L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo stesso motivo scaricaOra() esce subito se non è il proprietario a chiamarlo: il segno "download già inviato" sta nello stato condiviso, quindi chi segue l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca la cancellazione.

  • Lo scorrimento automatico si sospende se si torna indietro a leggere. Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è il modo più rapido di rendere inutile un log che scorre. Compare invece "Torna in fondo".

  • Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in fondo. Senza, coprirebbe l'ultima zona — che è quella pericolosa.

  • Il JavaScript della console sta in un <script>, non dentro x-data. È la via d'uscita dalla trappola documentata più sotto: dentro un <script> le virgolette doppie e gli apostrofi non hanno alcuna limitazione. Non sta in resources/js/app.js perché quel bundle è un modulo ES, che il browser rimanda a dopo il parsing del documento, mentre Alpine arriva col pacchetto di Livewire in fondo al <body> ed è già avviato a quel punto: una Alpine.data() registrata dopo l'avvio non viene applicata agli elementi già inizializzati. Un <script> classico nella pagina viene eseguito prima, sempre — e c'è un test che verifica proprio che la definizione preceda livewire.js, perché è un rapporto che guardando i due file non si vede.

Un solo sistema di pulsanti — vedi la sezione dedicata qui sotto.

Un solo pulsante per tutta l'applicazione

resources/css/app.css definisce .tm-btn; resources/views/components/btn.blade.php è l'unico posto da cui si usa. Nessuna vista scrive quelle classi a mano, e nessuna vista definisce più un pulsante per conto proprio.

Da dove veniamo: quattro vocabolari in circolazione insieme — style= inline con i colori scritti a mano (più onmouseover/onmouseout per simulare l'hover), catene di utility Tailwind ripetute a memoria, .btn-action* in sei tinte e .btn-primary-* in due. La stessa azione cambiava aspetto da una pagina all'altra, l'ordine di Salva e Annulla si invertiva fra un form e l'altro, e nessuno di quei pulsanti aveva uno stato di focus: chi naviga da tastiera non vedeva dove si trovava.

Il sistema ha due assi ortogonali, non una tavolozza:

Asse Valori Regola
variante primary l'azione principale. Una sola per schermata o zona.
secondary tutto il resto: dettaglio, modifica, scarica, filtra.
danger solo dove si cancellano o si sovrascrivono dati.
ghost annulla, chiudi, pulisci.
tono solido il pieno (default), per le azioni isolate.
tenue la tinta chiara, per le righe di elenco.
velato per i comandi sopra una superficie colorata: solo la testata.

più dimensione="sm" per le righe e icona per i pulsanti di sola icona.

Le decisioni non ovvie prese convertendo:

  • tenue esiste per le liste. In una riga i pulsanti sono tanti quanti i record: cinquanta pulsanti pieni sono rumore, e il rosso pieno smette di significare "attenzione" quando compare cinquanta volte di fila.

  • Il rosso è tornato a significare una cosa sola. Rientra era rosso pieno quanto un Elimina, in tre pagine diverse; anche Scarica PDF nella scheda "Da rientrare" della dashboard lo era, solo perché lo è la scheda che lo contiene. Nessuno dei due cancella niente.

  • Ordine unico: azione principale prima, uscita dopo. Valeva già in metà dei form, l'opposto nell'altra metà, e nelle modali del Registro c'era la terza variante. Ora è lo stesso ovunque, form e modali comprese.

  • icona richiede aria-label. I pulsanti di sola icona avevano solo un title, che i lettori di schermo annunciano in modo inaffidabile: un pulsante senza nome accessibile è un pulsante che non si può usare senza vederlo.

  • velato non è una quinta variante inventata. La testata ha il suo gradiente, e lì un pulsante bianco o indaco sarebbe una toppa: il colore lo dà lo sfondo. Ha anche un anello di focus bianco, perché quello blu su indaco non si distingue.

Attenzione a una trappola di Blade: dentro un tag di componente si scrive :disabled="$condizione", non @disabled($condizione). Le direttive non vengono compilate lì dentro: il tag <x-btn> resta letterale nel PHP generato mentre il </x-btn> viene chiuso, e la vista muore con «syntax error, unexpected token endif».

A tenere insieme tutto questo ci sono due test, perché una convenzione senza guardia dura fino alla prossima pagina scritta copiando una vecchia:

  • tests/Unit/StileUnificatoTest.php legge i sorgenti delle viste e rifiuta il ritorno di .btn-action*, .btn-primary-*, onmouseover/onmouseout, degli sfondi scritti a mano su <button>/<a>, e delle classi tm-btn messe direttamente in una vista invece che dal componente.
  • tests/Feature/PagineApplicativeTest.php apre ogni pagina e verifica che non sia rimasto un <x-btn da compilare — perché un componente non compilato non solleva: semplicemente il pulsante non c'è.

Perché l'azzeramento è un componente a sé

Settings/ZonaPericolosa è nato da un problema di sola disposizione: la zona pericolosa deve stare in fondo alla pagina, mentre backup e ripristino stanno in cima, e i tre erano lo stesso componente. Reso da BackupManager l'azzeramento finiva a metà pagina, fra il ripristino e l'import — l'ultimo posto in cui si vuole trovare un pulsante che cancella tutto.

La logica condivisa non è stata duplicata: sta nel trait Livewire/Concerns/SegueOperazioneBackup (posto unico, aggancio all'operazione in corso, download automatico, righe verso la console). Duplicare postoLibero() avrebbe significato duplicare la regola su cui si regge il fatto che due operazioni non si sovrascrivano: è l'ultimo posto dove ha senso avere due copie.

Conseguenza da conoscere: due componenti pollano lo stesso stato. Il proprietario a 750ms (5s in attesa-download), l'altro a 3s — non ha righe da raccontare, ma deve accorgersi che il posto si è liberato, altrimenti il suo pulsante resterebbe disabilitato fino al ricaricamento della pagina, con l'aria di essersi rotto. Si avvisano anche a evento (operazione-avviata), indirizzato con ->to() e non gridato: senza, tornerebbe pure al mittente.

Il secondo tempo dell'azzeramento (verifica del backup, password, conferma) compare adesso dentro la zona, dove è stato premuto il primo. Prima usciva nel pannello di avanzamento in cima, a schermate di distanza dal pulsante che lo aveva avviato.

La suite di test ha un'immagine sua

Le dipendenze di sviluppo non stanno nell'immagine di produzione, e non devono cominciare a starci: --no-dev è la ragione per cui il runtime non porta con sé PHPUnit, Faker e Ignition. Da qui uno stage test nel Dockerfile, che condivide con runtime lo stage base — stesse estensioni, stesso php.ini — e si distingue solo per il vendor/ completo. Un test che passa lì dentro non può passare grazie a un ambiente diverso da quello vero.

Le scelte che vale la pena conoscere:

  • runtime deve restare l'ultimo stage del file. docker build senza --target costruisce l'ultimo, ed è così che lo invocano sia i tre servizi del compose (nessuno dichiara target) sia Dokploy. Uno stage aggiunto in fondo diventerebbe in silenzio l'immagine di produzione. Per questo test sta prima di runtime pur essendo stato aggiunto dopo. L'avvertenza è anche nel Dockerfile, sopra lo stage.

  • La suite non dipende da niente. phpunit.xml impone SQLite in memoria, cache array e code sync, e il servizio compose non ha depends_on né env_file. Gira a stack spento, non tocca db_data né storage_data, e soprattutto non può puntare per sbaglio al database di sviluppo: un .env ereditato dal container è esattamente il modo in cui una suite di test cancella dati veri.

    Il rovescio: SQLite non è MariaDB. Resta un solo punto non verificabile qui, l'ordinamento di Registro::render(), che usa CAST(... AS UNSIGNED): va provato sullo stack vero. Gli altri due sono stati tolti di mezzo perché costavano poco e in cambio rendevano testabile una pagina intera — la migrazione 2026_04_08_123500, il cui GREATEST(...) faceva proprio morire la costruzione dello schema, e la media di percorrenza in Home, che usava DATEDIFF/YEAR e teneva fuori dalla suite la pagina più visitata dell'applicazione. Entrambe danno lo stesso risultato di prima.

  • Il sorgente non è montato, e il --build non è facoltativo. Con i bind mount la stessa suite passa da ~15 secondi a ~2 minuti (ogni bootstrap di Laravel legge una decina di file attraverso il layer di Docker Desktop), e in cambio si guadagna solo di non ricostruire. Il comando canonico è docker compose --profile test run --build --rm test: senza --build si eseguono i test dell'immagine precedente, che possono essere di due modifiche fa. Stesso genere di trappola del "Rebuild deployment" su Dokploy.

  • APP_KEY di test è fissa e sta in chiaro in phpunit.xml. Nome e cognome dei proclamatori hanno il cast encrypted: senza chiave nemmeno un test che non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con la chiave di produzione, che vive nell'ambiente e nel volume.

  • Il modulo S-13 è un servizio, non un metodo del componente. Calcolo e PDF stanno in App\Services\Registro\ModuloS13, che i test chiamano direttamente; il componente Registro si limita a validare l'anno e scaricare. Il PDF scrive sopra il modulo ufficiale (resources/pdf/S-13_I.pdf, convertito in PDF 1.4 per FPDI) alle coordinate della sua griglia, come la cartolina S-12.

Un solo container esegue le migrazioni

Solo app migra. queue-worker e scheduler usano queue-entrypoint.sh, che attende e non tocca lo schema. Due container in gara su artisan migrate è il modo tipico di ritrovarsi con uno schema applicato a metà.

APP_KEY risolta una volta sola all'avvio

Prima veniva rigenerata da AppServiceProvider::boot() a ogni richiesta se mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il provider solleva un'eccezione se manca, invece di rigenerarla.

Dal 09/10/2026 l'entrypoint si rifiuta di partire se APP_KEY nell'ambiente è diversa dalla copia in storage/app/.app_key: prima la sovrascriveva, e un valore sbagliato in Dokploy distruggeva l'unica copia della chiave giusta. Per un cambio voluto si aggiunge APP_KEY_ROTATE=1 (la chiave vecchia resta in .app_key.prev-<data>) e poi la si toglie.


Bug corretti — i più significativi

Contesto utile se ti imbatti in comportamenti che sembrano regressioni.

Bug Effetto Dove
key:generate a runtime Rotazione silenziosa della chiave → perdita irreversibile dei dati cifrati AppServiceProvider
Import PDF da cartella mai accodato Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata Livewire/Settings/XmlExchange
Permessi non riapplicati su Livewire Un permesso revocato restava attivo fino al reload della pagina AppServiceProvider (addPersistentMiddleware)
Host header injection Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari bootstrap/app.php, nginx/default.conf
Default TTL link mai convertito Link PDF validi 2 anni invece di 1 mese migrazione 2026_07_18_000001
syncPermissions() nel seeder Un re-seed azzerava i permessi personalizzati dei ruoli RolesAndPermissionsSeeder
campagna_id manipolabile Rientro conteggiato su una campagna arbitraria Livewire/Assegnazioni/Rientra (#[Locked])
pdftoppm senza timeout Un PDF malformato bloccava il worker per sempre TerritorioThumbnailService
Collisione nomi file PDF 1 A e 1-A scrivevano sullo stesso file → dati sovrascritti Jobs/ImportTerritoryPdfFolder
Zip senza limiti Zip bomb → esaurimento memoria TerritorioPdfImportDispatcher
Export XML senza legacy_id Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) Livewire/Settings/XmlExchange (addLegacyIdAttribute)
Migrazioni thumbnail duplicate migrate:rollback falliva a metà rimosse 2 migrazioni ridondanti
addFile() senza controllo esito Backup con un file mancante, silenzioso: ci si accorge solo al ripristino Services/BackupArchive
Esito del ripristino con Cache::pull L'amministratore non vedeva mai il riepilogo: la fetch che segue il 302 lo consumava prima della navigazione Auth/PasswordChangeController
Nessun blocco fra operazioni Backup, ripristino e azzeramento potevano sovrapporsi sullo stesso database e disco Services/BackupOperationState (corrente())
Job ucciso = operazione eterna Timeout o crash del worker lasciavano lo stato "in corso" e il posto occupato failed() sui tre job di backup
Cancellazione file senza verifica «N file cancellati» con i file ancora sul disco (permessi) BackupArchive::svuotaDiscoPubblico()
Eliminazione definitiva territorio La riga spariva, mappa e miniatura restavano orfane sul volume per sempre Territori/TerritorioCestino
Virgolette doppie in x-data Attributo HTML chiuso a metà: il JavaScript finiva a video come testo backup-manager.blade.php
Warning di addFile() al posto del messaggio Su file illeggibile l'amministratore leggeva ZipArchive::addFile(): No such file or directory invece della frase che dice quale file e cosa controllare Services/BackupArchive
Date esportate in UTC Ogni backup → ripristino (e export → import XML) spostava tutte le date indietro di un giorno, sommandosi a ogni giro. I file vecchi vengono riportati al giorno giusto all'import XmlExchange::righeEsportate(), normalizeDateForDb()
Import di qualsiasi XML Un XML ben formato ma estraneo svuotava il database con "completato con successo". Ora servono la radice <termanager2> e le sezioni principali XmlExchange::importXmlFromContent()
Caratteri di controllo nell'export Uno solo, in una nota, rendeva il file (e il data.xml del backup) non reimportabile XmlExchange::addXmlText()
Territori senza zona scartati all'import Sparivano con tutto il loro storico di assegnazioni XmlExchange::importXmlFromContent()
Import che estromette chi lo esegue Password vecchia e ruoli del file applicati anche all'amministratore che importava XmlExchange::importXmlFromContent()
whereNull fuori da latestOfMany Con una chiusa più recente dell'aperta il territorio risultava libero mentre era fuori, e assegnabile due volte Territorio::assegnazioneCorrente()
Periodi di assegnazione accavallati Uscite retrodatate a prima dell'ultimo rientro, doppie aperte dal Registro, anno scelto a mano incoerente con la data Assegnazione::sovrappostaA(), Assegna, Registro
Declassamento dell'ultimo amministratore Togliendosi il ruolo l'installazione restava senza nessuno che potesse gestirla Settings/UsersIndex
GREATEST() in migrazione Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire migrazione 2026_04_08_123500
@disabled dentro <x-btn> Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» viste di settings
Segno del download consumato dal componente sbagliato L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione SegueOperazioneBackup::scaricaOra()

Cosa resta aperto

In ordine di priorità.

0. Il ripristino da browser è limitato a 100 MB — decisione in sospeso

È il punto da cui riprendere. Cloudflare rifiuta i caricamenti oltre 100 MB (vedi Trappole da conoscere), quindi il ripristino dall'interfaccia non funziona per un pacchetto con molte mappe. L'applicazione è a posto: il muro sta davanti.

Da decidere, in ordine di rapidità:

  1. Togliere il dominio da Cloudflare (DNS-only), anche solo su un sottodominio dedicato all'amministrazione — gratis e immediato.
  2. Caricare puntando direttamente all'IP dell'origine, scavalcando Cloudflare.
  3. Ripristino dai volumi (procedura nel README): nessun limite, serve accesso al server.
  4. Piano Cloudflare Business: 200 MB, spesso ancora insufficienti.

Resta da accertare quanto pesa davvero il pacchetto che si vuole ripristinare: sotto i 100 MB il problema non sarebbe la dimensione ma il timeout di 100 secondi di Cloudflare, e la soluzione sarebbe un'altra.

1. Copertura dei test ancora parziale

L'impalcatura ora c'è (vedi La suite di test ha un'immagine sua) e i tre blocchi che impedivano di scrivere il primo test sono stati rimossi: stage test nel Dockerfile con le dipendenze di sviluppo, connessione sqlite in config/database.php, phpunit.xml. Si lancia con:

docker compose --profile test run --build --rm test

Coperto oggi — 612 test PHP e 13 JavaScript, tutti verdi (21/10/2026). Senza Docker in locale si fanno girare sul server: vedi Dove gira davvero. I file storici, con cosa proteggono:

File Cosa protegge
tests/Unit/BackupArchiveTest.php Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di data.xml mancante o vuoto, zip slip (percorso assoluto, .., unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio
tests/Unit/BackupOperationStateTest.php Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata
tests/Feature/RegistroS13Test.php Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" (fine ciclo e ultimo rientro prima dell'anno), assegnazioni a cavallo d'anno su entrambi i fogli, una riga per ogni territorio attivo anche senza assegnazioni, esclusione di territori disattivati e cestinati, ordinamento naturale dei numeri, fogli da 20 righe e generazione del PDF
tests/Feature/ZonaPericolosaTest.php I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede
tests/Feature/PaginaBackupImportTest.php La pagina servita per intero: permessi, le cinque zone con la loro ancora e nel loro ordine, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag <x-btn sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine
tests/Feature/PagineApplicativeTest.php Ogni pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina
tests/Unit/StileUnificatoTest.php Che i vocabolari di stile paralleli non rientrino: .btn-action*, .btn-primary-*, l'hover simulato con onmouseover, gli sfondi scritti a mano sui pulsanti, le classi tm-btn messe in una vista invece che dal componente
tests/Feature/PiantineManagerTest.php La pagina di gestione: accesso riservato, KML accettato e rifiutato, collegamento e scollegamento di una piantina, rifiuto di un territorio che ne ha gia' una, cancellare il territorio non cancella il disegno (la regressione che la cascata causava a ogni import XML), niente PDF senza territorio, e l'anteprima che non tocca pdf_path ne' lo storico
tests/Feature/PiantineEditorTest.php L'editor: piantina salvata senza territorio, territorio creato dalla scheda con zona/tipologia/confini, aggiornamento di uno esistente, zona e tipologia create sul posto e lasciate selezionate, numero e zona duplicati respinti, ridisegno che sostituisce senza duplicare, geometria non valida ignorata
tests/Unit/PiantinaPdfServiceTest.php La cartolina: due pagine, la Localita' presa dalla zona e i confini davvero presenti nel PDF (estratti decomprimendo i flussi, non solo "non ha sollevato eccezioni"), il giro completo dello storico — sostituzione, ripristino reversibile, file sparito
tests/Unit/PiantinaRendererTest.php Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata
tests/Feature/HomeMediaDurataTest.php La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio

Arrivati dopo (ottobre 2026), uno per funzione: GruppiServizioTest (gruppi di servizio, compresa la rotta vera delle azioni Livewire del sorvegliante, priorità e filtri), RichiesteTest (modulo pubblico e anti-spam, nomi simili, Home, richiesta evasa da Assegna e Rientra, pulizia, cartacei, S-13 per email e la regola dell'ultima domenica), InvioLinkTest (invio del link, pulsante Rientro, Assegna dalla Home), StoricoAssegnazioniTest, SceltaRicercaTest e tests/js/scelta.test.mjs (scelta con ricerca), RitornoTest, AssegnaPrestazioniTest (peso della pagina Assegna e delle righe dell'elenco), AssegnazioneSovrapposizioneTest, CampagnaCalcoliTest, CambioPasswordTest, ConfermaEmailTest, PostaElettronicaTest, PrivacyTest, LogSenzaNomiTest, AvvisiTest, CodaTest, UltimoAmministratoreTest, i test di import (XmlExchangeImportTest, ImportXmlFileOrfaniTest, ImportPdfTerritoriTest, PostoUnicoImportTest, RipristinoBackupTest) e dei file (TerritorioFileTest, TerritoriFileOrfaniTest, PubblicazionePiantinaTest, AzzeramentoPiantineTest, PiantineAreaTest).

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: in parte coperti (RipristinoBackupTest, ZonaPericolosaTest, AzzeramentoPiantineTest, l'azzeramento in GruppiServizioTest); restano da verificare l'ordine staging → database → file e il failed() che libera il posto. È la logica il cui errore si scopre solo quando è troppo tardi.
  • Livewire/Settings/XmlExchange: il round-trip export → import è coperto per le parti nuove (gruppi, invio del link) e da XmlExchangeImportTest, ma non campo per campo su tutto il dataset.
  • Le regole di business: anno teocratico (AnnoTeocratico::perData a cavallo del 1º settembre), Territorio::stato e la soglia giorni_per_smarrito. La priorità (Territorio::ordinaPerPriorita) e la percorrenza delle campagne ora hanno i loro test.
  • 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: resta solo l'ordinamento di Registro::render(), che usa SQL specifico di MySQL (la Home non più: vedi La suite di test ha un'immagine sua).

2. Race su doppia assegnazione — chiusa il 09/10/2026

Assegna::save() e il salvataggio del Registro controllano e scrivono dentro una transazione con lockForUpdate() sulla riga del territorio, e rifiutano ogni periodo che si accavalla a un altro (Assegnazione::sovrappostaA()). Non c'è ancora un vincolo nel database: l'import XML e i dati storici possono contenere sovrapposizioni, che il controllo segnala solo quando si modifica una di quelle righe.

3. Scrittura durante il rendering

Assegnazione::shortPdfUrl() genera e salva il codice d'accesso al primo utilizzo, quindi una GET del registro può produrre decine di INSERT. Meglio generare il codice alla creazione dell'assegnazione (observer) e far restituire null al metodo quando manca.

4. Ricerca in memoria sui campi cifrati

Nome e cognome dei proclamatori sono cifrati, quindi la ricerca carica l'intera tabella e filtra in PHP (ProclamatoreIndex, Registro). Accettabile sotto qualche centinaio di record, non oltre. Se dovesse servire scalare: hash deterministico ricercabile affiancato al campo cifrato.

5. Endpoint PDF pubblici senza throttle

/p/{code} e le rotte del visualizzatore sono senza autenticazione né rate limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a ~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non verrebbe né rallentato né registrato. Un throttle:30,1 costerebbe una riga.

6. Restano da uniformare i colori che non sono pulsanti

I pulsanti sono a posto in tutta l'applicazione (vedi Un solo pulsante per tutta l'applicazione), ma i colori decorativi sono ancora scritti a mano, esadecimale per esadecimale, dentro gli attributi style=: le piastrelle delle icone e le fasce delle schede nella dashboard, gli sfondi delle intestazioni di tabella, i pannelli delle modali del Registro, i riquadri dei messaggi nel layout.

Non sono un problema di accessibilità come lo erano i pulsanti — nessuno di questi elementi si può mettere a fuoco — ma sono lo stesso genere di debito: lo stesso verde compare in quattro tonalità leggermente diverse, e cambiare la tinta dell'applicazione oggi significa cercare a mano tutte le occorrenze.

Dal 28/08 metà del lavoro è fatta: i token esistono (:root e @theme in app.css, vedi Il sistema visivo sta nei token), e le scale gray/indigo di Tailwind sono ridefinite, quindi tutto ciò che usa le utility è già allineato. Resta da togliere gli style= con gli esadecimali scritti a mano e farli citare i token. Il test StileUnificatoTest è il posto dove aggiungere la guardia, quando si farà.

Fuori perimetro per scelta: resources/views/pdf/* le impagina dompdf, che di Tailwind e delle nostre classi non sa niente. StileUnificatoTest esclude anche resources/views/vendor/, che oggi non esiste — le viste di paginazione pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro componenti Livewire, che risolvono la vista dal namespace livewire:: e non da pagination::. L'esclusione resta a guardia di future viste di terze parti.

7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026

Dal formato 2 l'archivio contiene piantine.json (righe di territorio_mappe e territorio_pdf_versioni, con il numero del territorio per ricollegarle) e la cartella piantine/ dell'area KML. Un archivio di formato 1 si ripristina lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.

8. L'autodeploy non parte

Dal 19 ottobre 2026 nessun push su dev o main ha avviato un deploy, pur con autoDeploy attivo in Dokploy. Da verificare il webhook del repository in Gitea (repository.pyconetwork.it). Intanto ogni deploy si avvia a mano: vedi Dove gira davvero.

9. Gruppi di servizio e lavori del 20 ottobre non ancora in produzione

Sono sulla demo e l'utente li sta provando lì; per il rilascio serve il suo ok. Come portarli in produzione: Stato attuale.


Trappole da conoscere

Cloudflare taglia i caricamenti a 100 MB, e MAX_UPLOAD_MB non c'entra niente. I domini pubblici passano da Cloudflare, che sul piano free rifiuta i corpi di richiesta oltre 100 MB. Rifiuta sulla base del Content-Length, quindi il 413 arriva dopo che il browser ha spedito appena un megabyte: la barra di caricamento si pianta a una percentuale bassa e — senza i gestori aggiunti alla console — non compare alcun messaggio. È il motivo per cui il ripristino di un pacchetto grande sembra bloccarsi senza spiegazione.

Misurato il 29/07/2026 su demo-termanager.pyconetwork.it:

Payload Esito
150 MB 413 Payload Too Large, Server: cloudflare, 2,7 s
100 MB 524 (timeout dell'origine dopo 100 s di Cloudflare)

Il pacchetto non raggiunge mai nginx, quindi alzare MAX_UPLOAD_MB non sposta nulla: quel valore governa solo ciò che accade dopo Cloudflare. Le strade per superarlo sono togliere il dominio da Cloudflare (DNS-only, anche solo su un sottodominio d'amministrazione), caricare puntando direttamente all'IP dell'origine, oppure usare il ripristino dai volumi. Il piano Business arriva a 200 MB, che per un archivio con tutte le mappe è spesso ancora poco.

Nota sul 524: anche restando sotto i 100 MB, Cloudflare chiude dopo 100 secondi se l'origine non ha ancora risposto. Un caricamento lento può quindi fallire pur essendo dentro il limite di dimensione.

Il JavaScript che vive dentro un attributo HTML. Vale ancora per i blocchi x-data di backup-manager, zona-pericolosa e della zona Import PDF, che gestiscono i caricamenti. La console, che era il blocco più grosso, è stata portata fuori in un <script> proprio per uscirne (vedi La pagina "Backup e import"); il resto resta soggetto alla regola. x-data finisce alla prima virgoletta doppia, comprese quelle dentro un commento JavaScript: un banale // un generico "errore" ha chiuso l'attributo a metà e riversato tutto il codice restante a video come testo — è successo due volte. Nello stesso blocco, un \' scritto a mano nel sorgente Blade chiude la stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco, da nessuna parte; apostrofi italiani solo dentro backtick.

In una vista Blade la forma in linea @php(...) non va messa prima di un blocco @php … @endphp. Blade estrae i blocchi @php … @endphp per primi, con un'espressione che parte dalla prima @php che trova: se è quella in linea, ingoia tutto fino al primo @endphp e la vista non compila più ("unexpected token class"). Successo in Il mio gruppo il 19/10/2026. E vale anche dentro i commenti {{-- --}}, che Blade toglie dopo: lì la parola @php non va scritta.

Un blocco <script> con @script eviterebbe la questione alla radice, ma rompe il rilevamento della radice singola di Livewire e restituisce 500 — provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.

APP_KEY è il singolo punto di perdita dati. I nomi dei proclamatori sono cifrati con questa chiave. Il backup del database senza la chiave è inutile per quei campi. Se non la imposti, l'entrypoint ne genera una e la salva nel volume storage_data, stampandola nei log: se quel volume viene perso, i dati sono irrecuperabili.

APP_URL deve combaciare col dominio servito. L'applicazione rifiuta richieste con Host diverso (protezione host header injection) e restituisce 400 senza spiegazioni. È la causa più comune di "il deploy è andato ma il sito non si apre". localhost e 127.0.0.1 sono ammessi perché li usano gli health check interni.

Il volume storage_data non è solo cache. Contiene i PDF dei territori, le miniature e la chiave di fallback. Non va trattato come rigenerabile.

Modifiche a dipendenze o asset richiedono un rebuild. Non è più sufficiente riavviare il container: vendor/ e public/build vivono dentro l'immagine.

L'export XML contiene dati sensibili. Il file di export include gli hash delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro. Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli hash. La pagina di export lo segnala, ma il controllo resta umano — la funzione è già riservata all'amministratore (settings.manage).


Come verificare che tutto funzioni

Dopo una modifica sostanziale, questo giro copre i percorsi critici:

C'è un solo docker-compose.yml, usato sia in produzione sia in locale. In locale serve creare una volta la rete esterna attesa da Dokploy.

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). Senza Docker in locale:
#    sul server Dokploy, vedi "Dove gira davvero"
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 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:

git show 701f479:TerManager2_v2.md