Un ex sorvegliante puo' guidare un gruppo nuovo: si riattiva la sua utenza. Il sorvegliante di un gruppo attivo non si cestina ne' si anonimizza; anonimizzando un ex sorvegliante si anonimizza anche l'utenza. Nel registro delle attivita' nome ed email dell'utenza del sorvegliante diventano "proclamatore #id". La voce "Il mio gruppo" dipende dal ruolo: l'amministratore passa ogni @can. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1729 lines
105 KiB
Markdown
1729 lines
105 KiB
Markdown
# Handoff — TerManager2
|
||
|
||
Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||
|
||
Ultimo aggiornamento: **19 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
|
||
|
||
---
|
||
|
||
## Stato attuale
|
||
|
||
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
|
||
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
|
||
redeploy, login, tutte le pagine, code e scheduler.
|
||
|
||
| Aspetto | Stato |
|
||
|-----------------------------|-------|
|
||
| Deploy Dokploy (Compose) | ✅ verificato |
|
||
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
|
||
| Redeploy senza interventi | ✅ ~25 secondi |
|
||
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
||
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
|
||
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
||
| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale |
|
||
|
||
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
||
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
||
di avanzamento reali e console di log (commit `f909791`, deploy verificato
|
||
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
|
||
su develop; la produzione non è stata toccata e resta sul suo deploy
|
||
precedente, senza quella variabile.
|
||
|
||
**Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu
|
||
Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap,
|
||
immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico
|
||
versioni. Architettura e agganci sono descritti in [Decisioni
|
||
architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||
Al primo deploy il compose costruisce anche l'immagine del renderer; non
|
||
servono variabili nuove in Dokploy.
|
||
|
||
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine
|
||
si sono staccate dai territori (una piantina vive anche scollegata, il territorio
|
||
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la
|
||
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98%
|
||
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema
|
||
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema
|
||
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste).
|
||
|
||
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti**
|
||
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
|
||
porta](#http_host-a-nginx-deve-conservare-la-porta).
|
||
|
||
Il 30/07 sono state fatte due cose.
|
||
|
||
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
|
||
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
|
||
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
|
||
dell'immagine di produzione e non girano su un deploy: sono uno strumento
|
||
locale.
|
||
|
||
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
|
||
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
|
||
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
|
||
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
|
||
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
|
||
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
|
||
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
|
||
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
|
||
aprendo la pagina.
|
||
|
||
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
|
||
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
|
||
e l'unica che richiede una decisione, non del lavoro.
|
||
|
||
---
|
||
|
||
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
|
||
|
||
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo):
|
||
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
||
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
|
||
(integrità dati).
|
||
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
|
||
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
|
||
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
|
||
della tabella sono nel commit successivo.
|
||
|
||
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
|
||
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
|
||
|
||
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
|
||
|
||
Richieste dell'utente, con le sue scelte:
|
||
|
||
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
|
||
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
|
||
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
|
||
"Invia email di prova". Se compilato prende il posto del `.env`
|
||
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
|
||
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
|
||
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
|
||
un valore non valido per Laravel 11: ora vuoto.
|
||
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
|
||
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
|
||
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
|
||
considera italiano).
|
||
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
|
||
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
|
||
(`Assegna::DA_INVIARE`) non esiste più.
|
||
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
|
||
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
|
||
L'admin crea l'utente con la password come prima; parte l'email di conferma
|
||
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
|
||
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
|
||
link. In Utenti: stato "da confermare" e "Reinvia conferma".
|
||
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
|
||
e — per l'amministratore — il server di posta.
|
||
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
|
||
creato da riga di comando nasce confermato.
|
||
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
|
||
`$confermaUtentiCreati`).
|
||
|
||
Provato nel browser in locale con mailpit come server configurato dall'app:
|
||
email di prova e di conferma arrivate, link aperto senza login, account
|
||
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
||
e dati di prova rimossi dopo.
|
||
|
||
### Gruppi di servizio (19 ottobre 2026, solo su dev)
|
||
|
||
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
|
||
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
|
||
e non vede nient'altro. Scelte dell'utente:
|
||
|
||
- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo**
|
||
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
|
||
compreso.
|
||
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata".
|
||
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da
|
||
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
|
||
esclude `gruppo_servizio_id`).
|
||
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**,
|
||
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel
|
||
registro e nella scheda del territorio).
|
||
- Al **rientro** il territorio esce dal pacchetto e torna in reparto
|
||
(hook `updated` in `Assegnazione`, vale da qualunque pagina).
|
||
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
|
||
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
|
||
pacchetti li gestisce **solo l'amministratore**.
|
||
- Utenza: email e password temporanea da cambiare al primo accesso, **senza
|
||
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
|
||
sorvegliante; anche cambiandola dal profilo).
|
||
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
|
||
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
|
||
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
|
||
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
|
||
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
|
||
|
||
Casi aggiunti di mio, da far confermare all'utente:
|
||
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
|
||
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
|
||
è unico), con email e password temporanea nuove.
|
||
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
|
||
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
|
||
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
|
||
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
|
||
ruoli).
|
||
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
|
||
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
|
||
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
|
||
`users`, come per ogni utente.
|
||
|
||
Dove sta:
|
||
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
|
||
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
|
||
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`,
|
||
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
|
||
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
|
||
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
|
||
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
|
||
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e
|
||
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto.
|
||
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante
|
||
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo,
|
||
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
|
||
rimanda al gruppo; il resto è 403.
|
||
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
|
||
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
|
||
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
|
||
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
|
||
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
|
||
l'amministratore passa ogni `@can` (`Gate::before`).
|
||
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
|
||
assegnazioni e il legame utenza ↔ proclamatore.
|
||
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
|
||
del proclamatore).
|
||
- Test: `tests/Feature/GruppiServizioTest.php`.
|
||
|
||
Non provato nel browser. **In produzione non c'è**: serve l'ok dell'utente.
|
||
|
||
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
|
||
|
||
Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email
|
||
né telefono**, e WhatsApp ed email spariscono come canali. Restano:
|
||
|
||
- **Copia link**;
|
||
- **Condividi…**: `navigator.share()` con il messaggio già scritto
|
||
(`Assegnazione::messaggioLink()`), cioè il menu di condivisione del telefono
|
||
(WhatsApp, Telegram, SMS…), da cui chi usa l'app sceglie app e contatto.
|
||
Compare solo dove il browser lo offre (telefoni, alcuni computer); il canale
|
||
si annota a condivisione fatta, non se la si annulla. Nell'elenco dei
|
||
territori assegnati "Condividi" andato a buon fine segna il link come
|
||
inviato (`ElencoAssegnati::segnaCondiviso`).
|
||
|
||
Cosa è cambiato:
|
||
- Migrazione `2026_10_19_000020_togli_contatti_proclamatori`: elimina le
|
||
colonne `email` e `telefono`. In produzione erano vuote (0 su 111) al
|
||
momento della decisione; su dev c'era un solo valore di prova.
|
||
- Tolti dal form, dalla scheda e dall'elenco dei proclamatori; tolti
|
||
`Proclamatore::telefonoInternazionale()/whatsappUrl()`, il componente
|
||
`<x-whatsapp>`, gli stili `tm-whatsapp`, la notifica `LinkTerritorio` e
|
||
`Assegna::inviaEmail()`.
|
||
- XML: non si esportano più; un file vecchio che li contiene si importa
|
||
ignorandoli.
|
||
- `Assegnazione::CANALI_ATTIVI` = copia, condividi. In `CANALI_LINK` restano
|
||
anche "whatsapp" ed "email" solo per le assegnazioni già inviate così.
|
||
- La posta resta, ma solo per le email agli utenti (conferma dell'indirizzo).
|
||
- Informativa privacy, README e test (`InvioLinkTest`, al posto di
|
||
`ContattiProclamatoriTest`) aggiornati.
|
||
|
||
**In produzione dal 19 ottobre 2026, ore 17:10** (`main` = `7384a4b`). Prima:
|
||
ricontrollato che email e telefono fossero vuoti (0 su 111) e backup in
|
||
`/etc/dokploy/termanager2-backups/prod-20261019-150408/`. Dopo: migrazione
|
||
applicata, colonne tolte, conteggi identici a prima (1130 assegnazioni, 77
|
||
aperte), 111/111 proclamatori decifrati, 272/272 PDF, 4/4 utenti confermati.
|
||
L'autodeploy non era partito entro 2 minuti dal push: deploy avviato a mano.
|
||
|
||
### Allineamento di main in dev e passaggio in produzione (19 ottobre 2026)
|
||
|
||
`main` è stato unito in `dev` (merge, non rebase): da `main` arrivano solo
|
||
`S-12_I.pdf` nella radice e una riga di `.gitignore`. Quel PDF, copia
|
||
identica di `resources/pdf/S-12_I.pdf` e non usato dal codice, è stato poi
|
||
tolto su richiesta dell'utente; il modulo usato da `PiantinaPdfService` è
|
||
quello in `resources/pdf/` e resta. I 5 conflitti (README,
|
||
`CampagnaShow`, `Registro`, `Assegnazione`, vista della campagna) nascevano
|
||
dalle correzioni sulle campagne fatte su `main` la mattina e già portate su
|
||
`dev`: si è tenuta la versione di `dev`, che le contiene già (con
|
||
`proclamatore_id` nel log al posto del nome). Dopo, `main` va portato avanti
|
||
fino a `dev` senza un nuovo merge, così in produzione va esattamente il codice
|
||
provato su dev.
|
||
|
||
Analisi d'impatto per prod (fatta sui dati di prod copiati in dev):
|
||
- 11 migrazioni nuove, provate sulla copia: nessun errore, conteggi identici.
|
||
Due toccano dati esistenti: `nomi_proclamatori_fuori_dal_log` (irreversibile,
|
||
solo da backup) e `posta_contatti_e_conferma_email`, che **azzera la
|
||
conferma email di tutti gli utenti**.
|
||
- In prod non c'è un server di posta: subito dopo il deploy va eseguito
|
||
`php artisan utenti:conferma-email <email>` per ogni utente, altrimenti
|
||
nessuno riesce a lavorare. La posta si configura poi dall'app.
|
||
- `MAIL_SCHEME=tls` nell'ambiente di prod non è valido per Laravel 11:
|
||
svuotarlo. Prod è in HTTPS con `SESSION_SECURE_COOKIE=false`: valutare `true`.
|
||
- Il nuovo controllo dell'entrypoint su `APP_KEY` passa: chiave d'ambiente e
|
||
`storage/app/.app_key` coincidono.
|
||
- Ritorno indietro: ripristino del dump fatto prima del deploy e deploy del
|
||
`main` precedente (`bde7a4a`).
|
||
|
||
**Rilasciato in produzione il 19 ottobre 2026 alle 16:13** (`main` =
|
||
`e4549f8`, fast-forward da `dev`). Prima: backup in
|
||
`/etc/dokploy/termanager2-backups/prod-20261019-141137/` sul server (database,
|
||
file, conteggi) e copia del dump sul PC dell'utente; `MAIL_SCHEME` svuotato
|
||
nell'ambiente di prod. Deploy di 32 secondi, 11 migrazioni applicate senza
|
||
errori, email dei 4 utenti confermata con `utenti:conferma-email`. Verifica:
|
||
conteggi identici a prima del deploy, 111/111 proclamatori decifrati, 272/272
|
||
PDF presenti, campagna di settembre a 13 conteggiati (4,7%), nessun nome in
|
||
chiaro rimasto nel log. Subito dopo, su decisione dell'utente,
|
||
`SESSION_SECURE_COOKIE=true` nell'ambiente di prod (nuovo deploy: i cookie di
|
||
sessione e XSRF ora sono `secure; httponly; samesite=lax`). Resta da fare:
|
||
configurare la posta dall'app (*Impostazioni → Server di posta*) per poter
|
||
usare il canale Email.
|
||
|
||
Deploy: su Dokploy l'autodeploy da push è attivo (`triggerType=push`, nessun
|
||
filtro di percorso) e secondo l'utente funziona. Le variabili d'ambiente
|
||
invece diventano effettive solo con un nuovo deploy, che una loro modifica
|
||
non avvia da sola.
|
||
|
||
### Informativa privacy aggiornata e dinamica (19 ottobre 2026)
|
||
|
||
Richiesta dell'utente: aggiornare la sezione privacy con i dati attuali e
|
||
prendere i parametri dalle impostazioni. Il testo era fermo a una versione
|
||
precedente (solo nome e cognome, "tre ruoli: Admin, Servitore di Territorio,
|
||
Proclamatore Semplificato" che non esistono).
|
||
|
||
- `App\Livewire\Privacy` legge al momento: congregazione,
|
||
`audit_retention_days`, `giorni_per_smarrito` (scadenza del link),
|
||
`pdf_viewer_show_download`, server di posta (`ConfigurazionePosta`),
|
||
`config('app.cipher')`, `config('session.lifetime')`, `APP_URL` (avvisa se
|
||
non è HTTPS), i ruoli con i permessi veri (`Privacy::PERMESSI` li spiega) e
|
||
quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano
|
||
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
|
||
- Aggiunte: email/telefono cifrati, link alla piantina, invio via WhatsApp
|
||
(parte dal telefono di chi assegna) ed email (server di posta configurato),
|
||
mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi.
|
||
- Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali,
|
||
da far rivedere a chi ne ha la competenza.
|
||
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
|
||
legge i proclamatori dal modello e quindi esporta nome, cognome, email e
|
||
telefono **decifrati**. L'informativa ora lo dice; valutare se cifrare
|
||
l'archivio (per esempio con una password scelta al download).
|
||
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
|
||
i conteggi senza nomi e l'accesso solo amministratore.
|
||
|
||
### Pagina Assegna più leggera; "Vedi tutti" in testata nella Home (19 ottobre 2026)
|
||
|
||
Segnalazione dell'utente: la pagina Assegnazioni era lenta. Misurato su dev
|
||
con i dati di prod (79 assegnazioni aperte): il server la genera in ~80 ms
|
||
con 10 query, ma l'HTML pesava **387 KB** (299 l'elenco dei territori
|
||
assegnati, di cui 58 di icone SVG ripetute; 75 le opzioni dei campi di scelta)
|
||
e Livewire lo rimandava **per intero a ogni interazione**: scegliere un
|
||
territorio (anteprima con `wire:model.live`), scrivere nella ricerca
|
||
dell'elenco (`linkSearch`), aprire l'invio del link. Rimedi:
|
||
|
||
- L'elenco è un componente a sé, `Assegnazioni\ElencoAssegnati`, **lazy**: la
|
||
pagina mostra subito il form e le sue azioni non lo ridisegnano. "Invia
|
||
link" nell'elenco chiede alla pagina di aprire l'invio con l'evento
|
||
`apri-invio` (`Assegna::apriInvio` ha `#[On('apri-invio')]`);
|
||
`toggleLinkSent` sta nell'elenco.
|
||
- La ricerca nell'elenco filtra nel browser (`data-cerca` senza accenti,
|
||
Alpine `elencoCercabile`): nessuna richiesta mentre si scrive.
|
||
- Il territorio va a Livewire con `wire:model` (non più `.live`): l'anteprima
|
||
della miniatura segue la scelta nel browser (evento `scelta` di
|
||
`<x-scelta>`, URL della miniatura nelle opzioni).
|
||
- Righe compatte: icone come simboli SVG definiti una volta e richiamati con
|
||
`<use>`, classi brevi `tm-assegnato-*` in `app.css` al posto delle catene
|
||
di utility, copia del link con il metodo condiviso `copia()` di
|
||
`elencoCercabile`. I pulsanti restano `<x-btn>` (lo impone
|
||
`StileUnificatoTest`). Il ramo "link scaduto" dell'elenco (solo per non autenticati, cioè
|
||
mai su questa pagina) è stato tolto.
|
||
- `AssegnaPrestazioniTest` tiene fermi questi punti, compreso un tetto al
|
||
peso per riga dell'elenco.
|
||
- Corretto anche `Assegnazione::giorni`: con Carbon 3 `diffInDays()` è un
|
||
float e PHP segnalava una conversione implicita a ogni riga.
|
||
|
||
Home: "Vedi tutti" di *Da assegnare* e *Da rientrare* è ora un pulsante nella
|
||
testata del riquadro, accanto al PDF, invece che in fondo all'elenco. Compare
|
||
alle stesse condizioni di prima (elenco troncato al limite della Home).
|
||
|
||
### Scelta di territori e proclamatori con ricerca (19 ottobre 2026)
|
||
|
||
Al posto delle tendine di territorio e proclamatore (Assegna, finestra del
|
||
Registro, editor delle piantine) c'è `<x-scelta>`: si scrive e compaiono le
|
||
proposte più vicine; campo vuoto o appena aperto = la tendina completa.
|
||
|
||
- Ordine: uguale → inizia con → contiene → a una lettera di distanza ("forse
|
||
cercavi", due lettere dalle 7 in su; mai sui numeri sotto le 4 cifre, così
|
||
"35" non trova "36"). Le parole scritte valgono in qualsiasi ordine ("mario
|
||
ros"); maiuscole e accenti non contano. Logica pura in
|
||
`resources/js/scelta.js`, provata da `tests/js/scelta.test.mjs`
|
||
(`npm run test:js`, oppure `node --test tests/js/`).
|
||
- Le opzioni le prepara il server (`Territorio::opzioneScelta()`,
|
||
`Proclamatore::opzioneScelta()/opzioniScelta()`). Con `motivo` un'opzione
|
||
compare cercando ma non si sceglie: in Assegna i territori già assegnati
|
||
("già assegnato a …"), nel Registro territori e proclamatori disattivati.
|
||
- Nomi e cognomi sono cifrati: si ordinano e si cercano decifrati, nella
|
||
pagina. Prima il Registro li ordinava in SQL, cioè a caso.
|
||
- Telefono: testo a 16px sotto i 640px (iOS non ingrandisce), voci alte 44px,
|
||
elenco in mezzo schermo, scelta al tocco senza perdere il focus; dopo la
|
||
scelta col dito il campo perde il focus e la tastiera si chiude.
|
||
- Con Livewire: `wire:model` sul componente (x-modelable). `vuoto` è il valore
|
||
di "nessuna scelta": `null` per le proprietà `?int` (Assegna), `''` per le
|
||
stringhe. Il componente si ridisegna quando cambiano le opzioni (wire:key
|
||
con l'impronta), perché x-data le legge una volta sola.
|
||
- Il filtro "Filtra territori" di Assegna non serve più ed è stato tolto.
|
||
|
||
### Si torna da dove si è partiti (19 ottobre 2026)
|
||
|
||
Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna
|
||
alla pagina da cui il percorso era partito, con i suoi filtri, ricerca,
|
||
ordinamento e pagina. Elenco filtrato → scheda → Rientra → conferma riporta
|
||
all'**elenco filtrato**, non alla scheda (scelta esplicita).
|
||
|
||
- Gli elenchi (territori, proclamatori, registro) tengono ricerca, filtri e
|
||
ordinamento nell'indirizzo con `#[Url]`; la pagina c'era già (paginazione).
|
||
- I link verso schede e operazioni hanno `data-ritorno`: `resources/js/app.js`
|
||
gli aggiunge `?da=<percorso>` al momento dell'uso (pointerdown/click/menu
|
||
contestuale/focus, prima di wire:navigate). Se la pagina corrente ha già un
|
||
`da`, passa quello: si risale sempre all'origine.
|
||
- `App\Support\Ritorno::valida()` accetta solo percorsi interni (niente
|
||
`https://…`, `//…`, `\`, caratteri di controllo, `/livewire/…`): il
|
||
parametro non può diventare un rimando verso l'esterno.
|
||
- Le pagine di scheda e operazione usano il trait `RicordaRitorno`
|
||
(`$ritorno`, `destinazione()`): redirect dopo il salvataggio, "Annulla" e
|
||
"Torna…" (che con un ritorno diventa "Torna indietro").
|
||
- Aperte senza `da` (menu laterale, link salvato) tutto resta com'era.
|
||
- Link nuovi verso una scheda o un'operazione: metterci `data-ritorno`;
|
||
pagine nuove di operazione: usare `RicordaRitorno`.
|
||
|
||
### Invio del link dopo l'assegnazione, nuovo territorio dal designer (19 ottobre 2026)
|
||
|
||
Richieste dell'utente:
|
||
|
||
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
|
||
apre una finestra che blocca la pagina (`Assegna::apriInvio`) con i canali
|
||
- *Copia link*: sempre;
|
||
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
|
||
- *Email*: solo se ha un indirizzo **e** la posta funziona
|
||
(`ConfigurazionePosta::funzionante`). La manda l'app con
|
||
`Notification::route('mail', …)->notifyNow(new LinkTerritorio)`: **mai in
|
||
coda**, perché il job salverebbe in chiaro su Redis/`failed_jobs`
|
||
l'indirizzo che nel database è cifrato.
|
||
|
||
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
|
||
possibile. Solo dopo la risposta si ricarica la pagina.
|
||
- **Se la procedura non si conclude** (scheda chiusa) o si risponde *No*, resta
|
||
`assegnazioni.link_da_inviare = true` e compare "Link ancora da inviare"
|
||
nell'elenco della pagina Assegna (con "Invia link", che riapre la finestra)
|
||
e nella scheda del territorio (che rimanda a `assegnazioni/assegna?invia=<id>`).
|
||
L'avviso sparisce quando il link risulta inviato, anche dal pulsante
|
||
"Non inviato / Link inviato" dell'elenco.
|
||
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
|
||
assegnazioni già esistenti restano false: niente avvisi a posteriori su
|
||
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`,
|
||
`Assegnazione::CANALI_LINK`). Entrambe nell'XML.
|
||
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
|
||
mai nome, numero o email. Email e telefono restano solo, cifrati, nel
|
||
proclamatore.
|
||
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
|
||
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
|
||
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
|
||
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
|
||
`settings.manage` resta sul form `territori.create`, che esiste ancora.
|
||
|
||
### Revisione profonda: blocchi 2 e 3 chiusi
|
||
|
||
| Problema | Correzione |
|
||
|---|---|
|
||
| Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) | trait `Avvisa` + `<x-avvisi>`; un test vieta `session()->flash()` senza redirect |
|
||
| PDF, miniature e versioni scaricabili da `/storage/territori-*` senza login (nomi indovinabili, cache Cloudflare 7 giorni) | nginx risponde 404 su `/storage/territori-`; `TerritorioFileController` con permessi della pagina e cache privata; `Territorio::pdfUrl()` / `miniaturaUrl()` |
|
||
| Ripristino non atomico | estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (`rename`, annullabile) → file vecchi cancellati solo dopo il commit |
|
||
| Piantine, storico PDF, area KML fuori dal backup | formato archivio 2 (`piantine.json`, `piantine/…`, `PiantineBackup`); gli archivi di formato 1 si ripristinano lo stesso |
|
||
| Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi | `BackupOperationState::occupa()` sotto `Cache::lock`, usato da tutte le porte; rilascio anche su errore e in `failed()` |
|
||
| Import PDF da cartella: `1-A` → territorio 1, `12 B` ambiguo, solo 20 file, `Thumbs.db` blocca tutto, `__MACOSX` | abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati |
|
||
| Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo | prima il nuovo, poi il vecchio nello storico (`PiantinaPdfService::archiviaCorrente`, origini `import` / `manuale`) |
|
||
| L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) | l'import elimina i file non più usati; per i vecchi c'è `php artisan territori:file-orfani` (elenca; `--elimina` cancella) — **da lanciare quando si decide** |
|
||
| Azzeramento lasciava piantine orfane, area KML, anteprime | cancellate anche quelle |
|
||
| `/cambia-password` senza password attuale | richiesta, tranne dopo un ripristino (cambio obbligatorio) |
|
||
| Sessioni aperte altrove non chiuse al cambio password | `AuthenticateSession` nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori |
|
||
| Nomi dei proclamatori in chiaro nel registro attività | si salva `proclamatore_id`, il registro mostra il nome attuale; migrazione `2026_10_12_000010` ripulisce le voci vecchie |
|
||
| Export XML con `remember_token`; l'import XML non obbligava a cambiare password | token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password |
|
||
| Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% | confronto per data; si contano i territori attivi distinti |
|
||
| `retry_after` 90 s con job fino a 900 s | `config/queue.php`: 960 s |
|
||
| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` |
|
||
| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` |
|
||
| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) |
|
||
| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) |
|
||
| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` |
|
||
| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore |
|
||
|
||
Non cambiato, di proposito: `trustProxies('*')` (commento in
|
||
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
|
||
|
||
Suite: **429 test verdi**. Provati in browser in locale: file protetti (404
|
||
senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2
|
||
lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato).
|
||
|
||
**Non provato / aperto:**
|
||
- chiusura delle sessioni con due browser veri; coda e worker con un riavvio
|
||
reale; modifica vertici al tocco su tablet vero;
|
||
- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche
|
||
lotto riceve 429 (il browser lo segnala);
|
||
- numeri di territorio con lettere davanti ("A12") non si abbinano da nome
|
||
file: il PDF si carica dalla scheda.
|
||
|
||
---
|
||
|
||
## Da dove veniamo
|
||
|
||
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
|
||
l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato
|
||
solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.**
|
||
L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro
|
||
il container. Su Dokploy, senza sorgente sull'host, significava build a runtime
|
||
a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
|
||
|
||
Da qui la ristrutturazione descritta sotto.
|
||
|
||
---
|
||
|
||
## Dove gira davvero
|
||
|
||
Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal
|
||
repository Gitea `francesco/termanager2` con **autoDeploy attivo**.
|
||
|
||
| Ambiente | Branch | Dominio | Compose (`appName`) |
|
||
|--------------|--------|-------------------------------------|----------------------------|
|
||
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` |
|
||
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` |
|
||
|
||
Conseguenze pratiche, tutte imparate sul campo:
|
||
|
||
- **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione,
|
||
che segue `main`. È la separazione su cui contare quando si vuole provare
|
||
qualcosa senza rischi.
|
||
|
||
- **Un "Rebuild deployment" NON scarica il codice nuovo**: ricostruisce il
|
||
checkout già presente e ci mette una ventina di secondi. Per prendere l'ultimo
|
||
commit serve un deploy vero (`compose.deploy` via API, o *Deploy*
|
||
nell'interfaccia). Un rebuild che finisce troppo in fretta è il segnale che si
|
||
sta ricostruendo il codice di prima.
|
||
|
||
- **Le variabili d'ambiente stanno in Dokploy, non nel repository.** Il `.env`
|
||
dell'ambiente lo genera l'interfaccia: una variabile aggiunta a
|
||
`.env.example` non arriva da sola sui deployment, va aggiunta a mano lì. È
|
||
successo con `MAX_UPLOAD_MB`, presente in develop e — deliberatamente — non in
|
||
produzione.
|
||
|
||
- **Verifica che il codice servito sia quello giusto** confrontando l'impronta
|
||
degli asset: `public/build/assets/app-*.css` e `-*.js` hanno il nome derivato
|
||
dal contenuto, quindi se coincide con quello della propria build, il frontend
|
||
in linea è lo stesso sorgente. È il controllo più rapido dopo un deploy, e non
|
||
richiede di autenticarsi.
|
||
|
||
---
|
||
|
||
## Decisioni architetturali
|
||
|
||
Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il
|
||
contesto per ridiscuterla è qui.
|
||
|
||
### L'immagine è autosufficiente
|
||
|
||
`vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e
|
||
copiati nell'immagine finale. I container di produzione **non montano il
|
||
sorgente** e non eseguono build all'avvio.
|
||
|
||
Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend,
|
||
serve un rebuild dell'immagine, non un semplice restart.
|
||
|
||
### nginx e php-fpm nello stesso container
|
||
|
||
Con container separati, nginx avrebbe bisogno di una copia di `public/` e del
|
||
volume storage solo per servire file statici. Per questa scala è complessità
|
||
senza beneficio. Sono coordinati da supervisor
|
||
(`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due
|
||
muore definitivamente — altrimenti resterebbe "up" senza servire nulla.
|
||
|
||
### La configurazione arriva dall'ambiente, non da `.env` riscritto
|
||
|
||
Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel
|
||
legge direttamente l'ambiente di processo, e `docker-compose.yml` si limita a
|
||
`env_file: .env` senza ridichiarare nulla.
|
||
|
||
Il `.env` è quindi l'**unica** fonte di configurazione. Una versione precedente
|
||
fissava nel compose i valori dettati dalla topologia (`DB_HOST: mariadb` e
|
||
simili) perché vincessero su un `.env` sbagliato, ma richiedeva gli anchor YAML
|
||
(`x-app-env`, `<<:`) ed è stata scartata a favore della leggibilità.
|
||
|
||
Il rovescio della medaglia: `DB_HOST` e `REDIS_HOST` devono valere `mariadb` e
|
||
`redis`, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il
|
||
container non trova più il database. È documentato in `.env.example` e nel
|
||
README, ma è il punto fragile di questa scelta.
|
||
|
||
Nota: perché la configurazione da ambiente funzioni, `www.conf` ha
|
||
`clear_env = no`. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe
|
||
nessuna variabile.
|
||
|
||
### Fail-fast sulle variabili mancanti
|
||
|
||
Lo stack si rifiuta di partire senza `APP_URL`, `DB_PASSWORD`,
|
||
`DB_ROOT_PASSWORD`, `REDIS_PASSWORD`. Le password sono validate
|
||
dall'interpolazione compose (`${VAR:?}`), `APP_URL` dall'entrypoint (dove il
|
||
messaggio d'errore è più chiaro).
|
||
|
||
Deliberato: un errore immediato è meglio di un database in produzione con
|
||
password `secret`.
|
||
|
||
### Le piantine sono una sotto-app: renderer esterno, agganci minimi
|
||
|
||
Le **Piantine** (solo amministratore) generano le mappe dei territori. Si carica
|
||
il KML dell'area della città, si disegnano i confini su OpenStreetMap (Leaflet)
|
||
nell'editor a schermo intero, e da ogni disegno si ottiene un'immagine ad alta
|
||
definizione e — a scelta — la cartolina sul modulo ufficiale S-12
|
||
(`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce il PDF del
|
||
territorio. Il PDF precedente finisce nella tabella `territorio_pdf_versioni`
|
||
ed è ripristinabile dalla pagina di gestione.
|
||
|
||
Il rendering vero e proprio **non sta nell'applicazione**: lo fa il container
|
||
`map-renderer` (`docker/renderer/`, Node + sharp), che scarica le tessere OSM
|
||
(cache persistente nel volume `tile_cache`, User-Agent identificante,
|
||
attribuzione impressa sull'immagine come da licenza), le cuce, disegna il
|
||
confine e attenua l'esterno. L'app gli parla solo via HTTP sulla rete interna
|
||
(`config/piantine.php`, default `http://map-renderer:3000`: nessuna variabile
|
||
da aggiungere in Dokploy). Se il container è giù, l'unica cosa che smette di
|
||
funzionare è la generazione delle immagini; il resto del gestionale non lo sa
|
||
nemmeno.
|
||
|
||
Gli agganci al core sono deliberatamente pochi: la rotta nel gruppo
|
||
`settings.manage`, la voce di menu, e l'aggiornamento di
|
||
`pdf_path`/`thumbnail_path` sul territorio quando si sostituisce un PDF (stesso
|
||
percorso `territori-pdf/` + `TerritorioThumbnailService` dell'upload manuale).
|
||
Le geometrie vivono nella tabella separata `territorio_mappe`, l'area KML in
|
||
`storage/app/piantine/`: rimuovere la sotto-app significa togliere questi
|
||
pezzi, non disfare il gestionale.
|
||
|
||
**Il disegno viene prima del numero.** Una piantina non ha bisogno di un
|
||
territorio: `territorio_mappe.territorio_id` è nullable e il collegamento si fa
|
||
(e si disfa) quando si vuole, con `nome` come etichetta finché manca. Si taglia
|
||
l'area guardando le strade e solo dopo si decide di chi è — e se quel territorio
|
||
non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
|
||
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
|
||
`territori` come fa ogni import XML, non porta più via i disegni.
|
||
|
||
**Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
|
||
piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
|
||
scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
|
||
si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
|
||
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
|
||
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
|
||
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
|
||
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
|
||
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
|
||
|
||
**Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
|
||
il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
|
||
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
|
||
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
|
||
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
|
||
PDF cambia solo con **Pubblica**, uguale in editor e gestione
|
||
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
|
||
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
|
||
Cloudflare), che genera immagine e PDF sul disco **privato**
|
||
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
|
||
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
|
||
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
|
||
qualcosa di stampato, la conferma rifiuta.
|
||
|
||
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
|
||
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
|
||
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
|
||
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
|
||
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
|
||
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
|
||
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
|
||
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
|
||
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
|
||
in vigore del territorio veniva da una piantina.
|
||
|
||
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
|
||
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
|
||
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
|
||
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
|
||
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
|
||
componente `<x-avvisi>`): i flash di sessione nel telaio non si vedono dopo
|
||
un'azione Livewire.
|
||
|
||
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
|
||
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
|
||
pagina 1 è il modulo con Località, numero e i **confini scritti** nello spazio
|
||
centrale (il corpo del testo si restringe da solo finché ci sta), pagina 2 è la
|
||
piantina sola al 98% della pagina.
|
||
|
||
**La "Località" del modulo è il nome della zona.** Era un campo globale, poi un
|
||
campo della piantina, e in entrambi i casi era un doppione: la zona esiste già,
|
||
dice la stessa cosa («Palermo Montepellegrino») ed è quella che si aggiorna
|
||
quando la congregazione riorganizza il lavoro. Due posti dove scrivere lo stesso
|
||
nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
|
||
marca i territori collegati senza zona: senza, l'intestazione della cartolina
|
||
esce vuota e ce se ne accorge in stampa.
|
||
|
||
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
||
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
|
||
|
||
- Il renderer compone alle tessere dello zoom più alto che entri in
|
||
`max_side × 2` e poi riduce con Lanczos. Le etichette OSM sono disegnate
|
||
dentro la tessera a dimensione fissa: salire di uno zoom le rende più
|
||
definite, e ridurre dopo porta quella nitidezza dentro l'immagine finale.
|
||
- Chiede al renderer le **proporzioni della pagina** (`aspetto`): un territorio
|
||
quasi quadrato su una cartolina orizzontale, tenendo le sue proporzioni,
|
||
lascerebbe due fasce bianche larghe un quinto di foglio. Allargando il
|
||
ritaglio si mostrano più dintorni invece di carta vuota.
|
||
- JPEG a qualità 86 invece di PNG: 0,9 MB invece di 5 per territorio. Con 274
|
||
territori è la differenza fra qualche centinaio di MB e diversi GB.
|
||
|
||
L'attribuzione OSM si dimensiona sull'immagine finale, non su una misura fissa:
|
||
22 px su 3400 sarebbero meno di un millimetro stampato, cioè presenti nel file
|
||
ma invisibili a chi tiene la cartolina — e la ODbL la vuole leggibile. Per lo
|
||
stesso motivo il tratto del confine è espresso in pixel dell'immagine finale e
|
||
riportato alla scala di composizione: deve dire dove passa il confine senza
|
||
cancellare la via che ci corre sotto, che è proprio quella che serve leggere.
|
||
|
||
### Il sistema visivo sta nei token, non nelle viste
|
||
|
||
`resources/css/app.css` **ridefinisce le scale `gray` e `indigo` di Tailwind**
|
||
dentro `@theme`. È la cosa che sorprende di più aprendo il file, ed è
|
||
deliberata: le trentasette viste sono scritte con le utility (`text-gray-600`,
|
||
`bg-indigo-50`), e cambiare la scala le riallinea tutte insieme. L'alternativa
|
||
— riscrivere le classi vista per vista — a ogni file dimenticato lascia in giro
|
||
il grigio di prima accanto a quello nuovo, e nessuno se ne accorge finché non
|
||
apre proprio quella pagina.
|
||
|
||
Da qui tre conseguenze che vanno sapute prima di metterci mano:
|
||
|
||
- **I grigi sono caldi.** Quelli di serie di Tailwind tirano al blu e, sommati a
|
||
un accento indaco, davano all'interfaccia una dominante fredda. Questi elenchi
|
||
diventano moduli di carta che passano di mano: un fondo caldo li avvicina a
|
||
quello che sono. Non mescolare grigi freddi qui dentro.
|
||
- **L'accento è uno.** Indaco = azione, verde = fatto, ambra = in sospeso,
|
||
rosso = distrugge dati. Se una cosa non rientra in queste quattro, è grigia.
|
||
Prima le tinte erano cinque e decorative.
|
||
- **Il carattere è servito dall'applicazione** (`@fontsource-variable/figtree`,
|
||
dipendenza npm), non da Google Fonts. Un gestionale che tiene nomi e indirizzi
|
||
di persone non deve chiamare un dominio di terze parti a ogni pagina caricata,
|
||
e così funziona anche senza rete.
|
||
|
||
I componenti `tm-*` (pulsanti, console, card, campi, elenchi, editor) stanno
|
||
tutti qui e **fuori da `@layer`**, quindi vincono sulle utility: un pulsante non
|
||
si può ritoccare a metà con le classi. Chi aggiunge una schermata usa questi,
|
||
non catene di utility nuove.
|
||
|
||
### `HTTP_HOST` a nginx deve conservare la porta
|
||
|
||
`docker/nginx/default.conf` rimette `fastcgi_param HTTP_HOST $http_host;` dopo
|
||
l'`include fastcgi_params`. Non e' ridondanza: dalla 1.26 il `fastcgi_params` di
|
||
sistema usa `$host`, che la porta la butta via. Con `APP_URL` su porta non
|
||
standard — cioe' lo sviluppo locale su `:8090` — Laravel ricostruisce l'URL
|
||
della richiesta senza porta mentre `URL::forceRootUrl()` genera i link con, e
|
||
ogni URL firmato non valida piu': **tutti** gli upload di file rispondono 401,
|
||
perche' l'endpoint di Livewire aborta sulla firma. In produzione `APP_URL` non
|
||
ha porta e le due forme coincidono, quindi il guasto si vede solo in locale.
|
||
Inoltrare l'header grezzo e' sicuro perche' `trustHosts()` in `bootstrap/app.php`
|
||
rifiuta gia' gli Host che non siano quello di `APP_URL`, `localhost` o
|
||
`127.0.0.1`.
|
||
|
||
### Modifica/cancellazione delle assegnazioni: solo amministratore
|
||
|
||
L'unico punto in cui un'assegnazione **esistente** si modifica o si cancella è
|
||
il CRUD del Registro (`Livewire/Registro`: `openCreate`/`openEdit`/`save`/
|
||
`askDelete`/`deleteConfirmed`), blindato su `settings.manage` a due livelli —
|
||
`abort_if(..., 403)` nel componente e `@can('settings.manage')` in
|
||
`registro.blade.php`, che nasconde pulsanti e modali. `settings.manage` ce l'ha
|
||
solo l'`amministratore`.
|
||
|
||
Di conseguenza gli altri ruoli **non** possono alterare le assegnazioni: la
|
||
pagina Assegna solo ne crea (`territori.assign`), la pagina Rientra solo le
|
||
chiude (`territori.return`). Nello specifico l'`assistente` (`registro.view` +
|
||
`territori.assign` + `territori.return`) vede il Registro, crea nuove
|
||
assegnazioni e le fa rientrare, ma non le modifica né le elimina.
|
||
|
||
Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su
|
||
`syncPermissions()` più sotto), un permesso `settings.manage` concesso a mano
|
||
all'`assistente` in passato sopravviverebbe a ogni re-seed. La migration
|
||
`2026_07_25_000001_revoke_settings_manage_from_assistente_role` chiude questo
|
||
drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente`
|
||
se presente. È idempotente (no-op sul baseline) e volutamente irreversibile —
|
||
il `down()` non ripristina il permesso.
|
||
|
||
Stesso principio per le **campagne**: creazione e visualizzazione restano su
|
||
`campagne.manage` (che ha anche l'assistente), ma **modifica ed eliminazione
|
||
sono riservate all'amministratore** (`settings.manage`). Il gate è su tre
|
||
livelli: la route `campagne/{campagna}/edit` è nel gruppo `settings.manage`;
|
||
`CampagnaEdit::mount()`/`save()` e `CampagnaIndex::deleteCampagna()` chiamano
|
||
`$this->authorize('settings.manage')` (l'index è raggiungibile da chi ha
|
||
`campagne.manage`, quindi il delete va protetto nel metodo, non solo in route);
|
||
i pulsanti Modifica/Elimina in `campagna-index`/`campagna-show` sono sotto
|
||
`@can('settings.manage')`. Nota: `Campagna` non ha soft delete — l'eliminazione
|
||
è definitiva.
|
||
|
||
Per i **territori** vale un modello leggermente diverso, perché la
|
||
visualizzazione va aperta a tutti gli operativi: lista (`territori.index`) e
|
||
dettaglio (`territori.show`) sono ora su `territori.assign` (che hanno admin,
|
||
assistente e operatore); creazione/modifica/cestino restano su `territori.manage`
|
||
(solo admin). Attenzione a due punti:
|
||
|
||
- **Ordine delle rotte.** Le rotte letterali `territori/create` e
|
||
`territori/cestino` devono restare registrate PRIMA del wildcard
|
||
`territori/{territorio}`, altrimenti verrebbero catturate dalla rotta di
|
||
dettaglio.
|
||
|
||
- **`TerritorioIndex` perde la copertura del middleware persistente.** Il suo
|
||
componente è ora servito da una rotta rilassata (`territori.assign`), quindi i
|
||
suoi metodi di mutazione (`toggleActive`, `togglePriority`, `deleteTerritorio`)
|
||
NON sono più protetti dal middleware di route e hanno un
|
||
`$this->authorize('territori.manage')` interno — senza, un operativo potrebbe
|
||
invocarli via `/livewire/update` col pulsante nascosto. Per difesa in
|
||
profondità lo stesso check è stato aggiunto anche a `TerritorioCreate::save`,
|
||
`TerritorioEdit::save`/`removePdf`, `TerritorioCestino::restore`/`forceDelete`
|
||
(che restano comunque su rotte `territori.manage`). I pulsanti
|
||
Modifica/toggle/Elimina/Nuovo/Cestino in `territorio-index`/`territorio-show`
|
||
sono sotto `@can('territori.manage')`; il link "Dettaglio" resta visibile a
|
||
tutti. Il menu laterale mostra "Territori" a chi ha `territori.assign`.
|
||
|
||
### Sola lettura per proclamatori e campagne (non-admin)
|
||
|
||
Stessa impostazione dei territori estesa ad altre due sezioni:
|
||
|
||
- **Proclamatori**: `index` e `show` restano su `proclamatori.manage` (l'assistente
|
||
li vede in sola lettura); `create`/`edit`/`cestino` e tutte le mutazioni
|
||
(`ProclamatoreIndex::toggleActive`/`deleteProclamatore`/`anonimizza`,
|
||
`ProclamatoreCreate::save`, `ProclamatoreEdit::save`,
|
||
`ProclamatoreCestino::restore`/`forceDelete`) richiedono `settings.manage`.
|
||
`ProclamatoreIndex` è servito da una rotta rilassata, quindi i suoi metodi di
|
||
mutazione hanno il check interno; i pulsanti in `proclamatore-index`/`-show`/
|
||
`-cestino` sono sotto `@can('settings.manage')`. Le rotte letterali
|
||
create/cestino restano prima del wildcard `{proclamatore}`.
|
||
- **Campagne**: la **creazione** è ora admin-only come già modifica/eliminazione
|
||
— `CampagnaCreate::save` chiama `authorize('settings.manage')`, la rotta
|
||
`campagne/create` è nel gruppo `settings.manage` (registrata prima del
|
||
wildcard) e il pulsante "Nuova Campagna" è sotto `@can('settings.manage')`.
|
||
`index`/`show` restano su `campagne.manage` (sola lettura per l'assistente).
|
||
|
||
### Tabelle-elenco responsive su mobile (portrait)
|
||
|
||
Le liste principali usano una sola "politica" CSS invece di layout duplicati:
|
||
la classe `table-cards` (in `resources/css/app.css`) sotto `640px` nasconde il
|
||
`thead` e trasforma ogni riga in una scheda, con ogni cella resa come
|
||
"ETICHETTA … valore" (l'etichetta viene dall'attributo `data-label` della `<td>`).
|
||
Un telefono in portrait sta sotto la soglia → schede; ruotato in landscape la
|
||
larghezza supera i 640px → torna la tabella normale (per questo landscape "non ha
|
||
problemi"). Convenzioni: ogni `<td>` dati ha `data-label="..."`; la cella azioni
|
||
ha classe `cell-actions` (niente label, occupa la riga a piena larghezza); le
|
||
celle `colspan` (stato vuoto, form di modifica utente inline) restano `block` a
|
||
piena larghezza. Applicata a tutte le liste e sottopagine con tabelle: territori,
|
||
proclamatori, campagne, registro, utenti, audit-log, i due cestini, le tabelle
|
||
"storico assegnazioni" di territorio-show/proclamatore-show, "territori
|
||
conteggiati" di campagna-show e le due tabelle di esito import in xml-exchange
|
||
(una a classi Tailwind, una a stili inline — `border:none !important` nella regola
|
||
gestisce anche i bordi inline). La pagina *Assegnazioni* era già ottimizzata.
|
||
|
||
### Scadenza link PDF unificata con la soglia rientro
|
||
|
||
La "Validità link PDF (mesi)" è stata rimossa: il link condivisibile ora scade
|
||
esattamente quando l'assegnazione raggiunge la **soglia rientro**
|
||
(`giorni_per_smarrito`), cioè `assigned_at + giorni_per_smarrito` — la stessa
|
||
data di `Assegnazione::data_consegna_presunta` e del chip "Scadenza" nel viewer.
|
||
`AssignmentPdfController::linkScaduto` e `ShortPdfLinkController` calcolano ora
|
||
`addDays(giorni_per_smarrito)` invece di `addMonths(ttl)`; la scadenza vale solo
|
||
per i non autenticati e il link muore comunque al rientro (`is_aperta`). Rimossi
|
||
il campo Impostazioni e la proprietà `assignment_link_ttl_months` in
|
||
`SettingsEdit`. **La colonna `assignment_link_ttl_hours` resta nel DB** (niente
|
||
migration distruttiva): è ormai vestigiale per la logica ma l'export/import XML
|
||
continua a leggerla/scriverla senza effetti.
|
||
|
||
### Ricerca rapida nella lista link (pagina Assegna)
|
||
|
||
La sezione "Territori Assegnati" (i link PDF) ha un unico campo che filtra per
|
||
numero territorio, zona e proclamatore. Il filtro è **in memoria** (proprietà
|
||
`linkSearch`, collection `assegnazioniAperteVisibili`) perché nome/cognome del
|
||
proclamatore sono cifrati e non filtrabili via SQL — stesso approccio del
|
||
Registro. La sezione resta visibile anche a risultato vuoto (mostra "Nessun
|
||
risultato"), perché l'`@if` esterno guarda la lista non filtrata.
|
||
|
||
### Assegnare un territorio ne azzera il flag "prioritario"
|
||
|
||
`prioritario` è un flag **manuale**; `is_prioritario` è true se il flag è acceso
|
||
*oppure* se la giacenza supera la soglia — ma la parte a soglia vale solo per i
|
||
territori *in reparto* (`scopePrioritari`/`getIsPrioritarioAttribute` filtrano su
|
||
`inReparto`). Quindi un territorio assegnato può risultare prioritario **solo**
|
||
per via del flag manuale. Regola: alla creazione di un'assegnazione **aperta** il
|
||
flag viene spento. È implementata come evento `Assegnazione::created` (in
|
||
`booted()`), guardato su `returned_at === null` e `prioritario === true`, così
|
||
copre ogni via di creazione (pagina Assegna, CRUD del Registro, import XML) senza
|
||
duplicare la logica. Interazione con l'import XML: per i backup creati dopo questa
|
||
regola è un no-op (nessun territorio assegnato è prioritario); su backup vecchi
|
||
con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e
|
||
quale.
|
||
|
||
### Profilo utente, viewer PDF, privacy admin-only
|
||
|
||
Tre ritocchi minori ma con qualche implicazione:
|
||
|
||
- **Pagina profilo** (`Profile` → rotta `profilo`, nome `profile`) accessibile a
|
||
ogni utente loggato (nel gruppo `auth`, nessun permesso): l'utente corrente può
|
||
modificare il proprio nome/cognome ed email (`updateProfile`) e cambiare la
|
||
password (`updatePassword`, con verifica della password attuale via regola
|
||
`current_password`; il cast `hashed` cifra al salvataggio). Dopo il salvataggio
|
||
dei dati `updateProfile` fa `redirect(navigate:true)` sulla stessa pagina, così
|
||
header (nel layout, fuori dal componente) e card mostrano subito i nuovi valori.
|
||
Il link al profilo è nell'header su desktop e, su mobile, in fondo alla sidebar
|
||
(`lg:hidden`, dopo un separatore) — sull'header è `hidden sm:flex`. Non esiste
|
||
un campo "username" né "cognome" separato: `name` è il nome completo
|
||
(etichettato "Nome e cognome") e l'email fa da identificativo. La qualifica
|
||
(ruolo) non è modificabile dal profilo — resta in gestione all'admin.
|
||
- **Viewer PDF pubblico**: oltre a territorio/proclamatore/data mostra ora
|
||
*giorni assegnato*, *consegna prevista* e *scadenza*. Sono attributi calcolati
|
||
su `Assegnazione` (`data_consegna_presunta`, `giorni_alla_scadenza`) basati
|
||
sulla soglia `giorni_per_smarrito` — la stessa oltre cui `Territorio::stato`
|
||
diventa `da_rientrare`. Il viewer è servito solo per assegnazioni aperte, ma
|
||
gli accessor gestiscono comunque il caso `returned_at` (ritornano null).
|
||
- **Privacy solo admin**: la rotta `privacy` è passata dal solo `auth` al gruppo
|
||
`permission:settings.manage` e il link in sidebar è sotto
|
||
`@can('settings.manage')`.
|
||
|
||
### Backup completo `.zip`, ripristino, azzeramento
|
||
|
||
L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
|
||
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un
|
||
ripristino su volumi vuoti lascia ogni territorio che punta a un file
|
||
inesistente — link pubblici a 404 e miniature rotte. Il pacchetto `.zip`
|
||
(`Services/BackupArchive`, `Http/Controllers/Settings/BackupController`) mette
|
||
insieme `manifest.json`, `data.xml` (lo stesso XML dell'export) e `storage/…`
|
||
con **tutti** i file del disco `public`. L'export XML resta, etichettato come
|
||
parziale.
|
||
|
||
Scelte non ovvie:
|
||
|
||
- **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file
|
||
base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci
|
||
sta. `backup/file/{op}` e `backup/restore` sono due rotte normali nel gruppo
|
||
`settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
|
||
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
|
||
per la memoria del processo.
|
||
|
||
- **Tutto in coda, con avanzamento reale.** Backup, ripristino e azzeramento
|
||
sono job (`Jobs/CreateBackupArchive`, `RestoreBackupArchive`,
|
||
`ResetApplication`) che scrivono su `Services/BackupOperationState`, e
|
||
`Livewire/Settings/BackupManager` lo interroga con `wire:poll`. In coda
|
||
perché un'installazione con qualche centinaio di PDF supera i 300 secondi di
|
||
`fastcgi_read_timeout` e il browser vedrebbe cadere la connessione a metà
|
||
compressione, senza modo di distinguerlo da un errore.
|
||
|
||
La barra conta unità di lavoro completate, non tempo. Per la compressione
|
||
questo costa qualcosa: `ZipArchive` non scrive niente finché non si chiama
|
||
`close()`, quindi contare le `addFile()` porterebbe al 100% in un istante per
|
||
poi restare fermo per tutta la scrittura. `BackupArchive::create()` chiude e
|
||
riapre l'archivio a lotti (`FLUSH_MASSIMI = 20`): si paga una ricopiatura
|
||
delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio
|
||
ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è
|
||
`totale/20`, così la ricopiatura resta limitata a ~10 volte la dimensione
|
||
dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono
|
||
cicli nostri, lì l'avanzamento è per-file senza artifici.
|
||
|
||
- **L'azzeramento ha due tempi, separati dal download.** Il pulsante nella zona
|
||
pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in
|
||
`attesa-download`; la cancellazione parte da un secondo clic esplicito
|
||
(`confermaAzzeramento`), che rifiuta se lo stato non registra un
|
||
`servito_at` e se non riceve la password dell'amministratore. Il primo
|
||
tentativo faceva partire il reset dalla coda dello
|
||
`streamDownload`, "dopo l'ultimo byte" — **non funziona**: con
|
||
`fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo
|
||
disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi
|
||
prosegue anche se il browser ha chiuso la connessione al primo byte. Un
|
||
download annullato azzerava lo stesso. Il server può verificare di aver
|
||
*spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore,
|
||
e per questo la conferma è umana.
|
||
|
||
- **La password si richiede al secondo clic, e non passa dallo snapshot.** La
|
||
sessione è già autenticata, quindi la password non serve a stabilire *chi*
|
||
sia: serve a stabilire che davanti allo schermo ci sia ancora quella persona
|
||
— una scheda lasciata aperta su una postazione condivisa è lo scenario contro
|
||
cui protegge. Viaggia come **parametro** di `confermaAzzeramento($password)`,
|
||
non come proprietà `wire:model`: le proprietà pubbliche di Livewire vengono
|
||
serializzate nello snapshot e rispedite al browser a ogni risposta, e una
|
||
password non ha motivo di fare quel viaggio di ritorno.
|
||
|
||
Attenzione al dettaglio che costa un pomeriggio: la proprietà
|
||
`$passwordConferma` **esiste comunque, e resta sempre vuota**. Livewire
|
||
scarta dalla risposta ogni errore la cui chiave non corrisponda a una
|
||
proprietà del componente (`SupportValidation::dehydrate()` filtra su
|
||
`Utils::hasProperty`), quindi senza quella riga `addError('passwordConferma',
|
||
…)` sparisce in silenzio: il pulsante sembra non fare nulla, mentre il
|
||
controllo sta funzionando. Non collegarla al campo con `wire:model`, o la
|
||
password torna nello snapshot.
|
||
|
||
Cinque tentativi sbagliati in 5 minuti bloccano l'azione (`RateLimiter`,
|
||
chiave per utente). Senza, la richiesta di password su una sessione dirottata
|
||
sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account:
|
||
nessuno resta chiuso fuori dall'applicazione.
|
||
|
||
- **Una sola operazione alla volta, con blocco che si autoripara.** Backup,
|
||
ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso
|
||
disco: due in parallelo — due schede, due amministratori — non si rallentano
|
||
soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto
|
||
unico; lo controllano sia il componente Livewire sia
|
||
`BackupController::upload()`, che sono due porte d'ingresso separate (la
|
||
seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e
|
||
`corrente()` lo considera libero se l'operazione puntata non esiste più o è
|
||
terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il
|
||
worker uccide il job per timeout o errore fatale, cioè proprio i casi che
|
||
`handle()` non può intercettare — che chiude l'operazione e libera il posto
|
||
invece di lasciare una barra ferma per un'ora.
|
||
|
||
- **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()`
|
||
legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo
|
||
faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di
|
||
conferma diventava irraggiungibile: nessuno poteva più né confermarlo né
|
||
annullarlo, e il posto restava occupato fino alla scadenza.
|
||
|
||
- **L'esito del ripristino si legge senza consumarlo.** Il job non ha una
|
||
sessione in cui lasciare un flash, quindi deposita il messaggio in cache a
|
||
nome di chi ha avviato il ripristino e la schermata di cambio password lo
|
||
mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un
|
||
`pull`: quando è il middleware a dirottare una richiesta di Livewire, il
|
||
client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio
|
||
— per poi rifare la stessa GET con `window.location`, che troverebbe la cache
|
||
già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del
|
||
proprio ripristino.
|
||
|
||
- **Il tetto degli upload è una variabile sola, `MAX_UPLOAD_MB`.** Prima viveva
|
||
in tre posti scollegati — `client_max_body_size` in
|
||
`docker/nginx/default.conf`, `upload_max_filesize`/`post_max_size` in
|
||
`docker/php/php.ini`, e la regola `max:` nella validazione — e bastava che
|
||
uno restasse indietro perché il più piccolo vincesse nel modo peggiore.
|
||
Adesso `entrypoint.sh` genera i primi due (`conf.d/upload-limit.conf` e
|
||
`conf.d/zz-upload-limit.ini`) e `config/app.php` alimenta la terza.
|
||
|
||
**Non rimettere `client_max_body_size` nel blocco `server` di
|
||
`default.conf`**: una direttiva lì dentro vince su quella generata a livello
|
||
http e reintroduce in silenzio proprio il disallineamento che questa
|
||
disposizione esiste per evitare. La validazione tiene un 2% di margine sotto
|
||
il valore di nginx, così a fermare un pacchetto fuori misura è Laravel, che
|
||
sa spiegarsi, e non nginx.
|
||
|
||
Il sintomo, quando il limite viene superato, non assomiglia affatto a un
|
||
errore di dimensione: nginx risponde **413 dopo aver accettato zero byte** e
|
||
chiude. Il browser ha appena iniziato a spedire, la barra si pianta a una
|
||
percentuale bassa e nessun evento utile arriva all'`XMLHttpRequest` — pagina
|
||
muta, con l'aria di essersi bloccata. Da qui i tre presidi nella vista:
|
||
controllo della dimensione *prima* di caricare, `xhr.timeout` con i relativi
|
||
gestori di `timeout`/`abort`/`error` (senza, un caricamento fermo non produce
|
||
più nulla per sempre), e il codice HTTP dentro il messaggio, perché 413 e 419
|
||
non hanno un corpo JSON da cui ricavare una spiegazione.
|
||
|
||
- **La console di log non è un vezzo diagnostico.** Quasi tutti i modi in cui un
|
||
caricamento muore sono muti: Cloudflare o nginx chiudono la connessione e
|
||
nessun evento del browser porta con sé una spiegazione. Il pannello *Console*
|
||
nella scheda di ripristino registra file scelto e dimensione, limite del
|
||
server, avanzamento ogni 10%, codice HTTP della risposta, errori, timeout e
|
||
interruzioni; sotto, i log lato server dell'operazione. Un battito ogni 15
|
||
secondi scrive «nessun avanzamento da N s», che è l'unico modo di distinguere
|
||
*lento* da *fermo*. Il pulsante *Copia tutto* esiste perché il primo passo di
|
||
ogni diagnosi è farsi mandare quel testo.
|
||
|
||
- **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
|
||
disco è l'unico passo che può fallire per cause esterne (permessi sul
|
||
volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un
|
||
errore sui file lascerebbe database azzerato e disco pieno di PDF orfani.
|
||
Collegato: `svuotaDiscoPubblico()` ricontrolla il disco dopo la cancellazione
|
||
e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale.
|
||
Flysystem non solleva se una `unlink` fallisce, e basta un file scritto da un
|
||
processo con un altro utente (l'entrypoint lavora da root, php-fpm e il
|
||
worker da www-data) perché l'operazione si dichiari riuscita lasciando il
|
||
disco popolato.
|
||
|
||
- **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di
|
||
cui non ci si accorge che era rotto finché non serve, quindi
|
||
`BackupArchive::create()` non si fida di aver scritto bene: controlla il
|
||
valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se
|
||
il file è sparito o è illeggibile — l'archivio finirebbe con un buco
|
||
silenzioso), e alla fine riapre l'archivio e verifica che contenga
|
||
`data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non
|
||
torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun
|
||
backup che un backup che sembra buono.
|
||
|
||
Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito
|
||
l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or
|
||
uninitialized Zip object`. Una chiusura di cortesia dentro un `catch`
|
||
sostituiva il messaggio vero con quello, e l'amministratore leggeva una
|
||
stringa incomprensibile al posto di «permesso negato su questo file». Da qui
|
||
`chiudi()`, che cattura `getStatusString()` prima che vada perso, e i
|
||
`close()` di pulizia avvolti in un try/catch che non può mascherare l'errore
|
||
originale.
|
||
|
||
- **Eliminare definitivamente un territorio porta via anche i suoi file.**
|
||
`TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e
|
||
miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da
|
||
nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono
|
||
*prima* della riga, così se la cancellazione fallisce il territorio resta nel
|
||
cestino e l'operazione si può ritentare.
|
||
|
||
- **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
|
||
appena creata ha un amministratore; cancellarli tutti lascerebbe
|
||
l'applicazione senza nessuno che possa accedere fino al riavvio del container
|
||
(l'admin iniziale lo ricrea `entrypoint.sh` da `INITIAL_ADMIN_*`, che in
|
||
esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta
|
||
via Eloquent, non con un delete di massa: è il trait `HasRoles` di Spatie a
|
||
staccare ruoli e permessi nell'evento `deleting`, che un delete di massa non
|
||
fa scattare. Il registro attività si svuota **per ultimo** — quelle
|
||
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
|
||
sopravvive, quella dell'azzeramento stesso.
|
||
|
||
- **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026
|
||
(`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il
|
||
`piantine.json` del formato 2) prima di toccare qualcosa; poi una sola
|
||
`DB::transaction` contiene import XML, tabelle delle piantine, blocco degli
|
||
account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`:
|
||
solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa
|
||
fallisce si torna allo stato di prima; i file vecchi aspettano in
|
||
`storage/app/backup-precedenti/<uuid>` e si cancellano solo dopo il commit.
|
||
Il disco `public` viene sostituito, non fuso: i percorsi vivono nelle righe
|
||
appena riscritte.
|
||
|
||
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
|
||
numero di entry, dimensione della singola entry e totale decompresso, pochi
|
||
MB possono riempire il volume (zip bomb). I nomi delle entry sono
|
||
normalizzati e ogni `..` o percorso assoluto viene rifiutato (zip slip) —
|
||
senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione.
|
||
|
||
- **Dopo un restore tutti cambiano password.** Il pacchetto contiene gli hash
|
||
bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack
|
||
offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il
|
||
restore accende `password_change_required` su **tutte** le righe di `users`
|
||
(amministratore che sta ripristinando compreso) e azzera i `remember_token`,
|
||
che sono credenziali persistenti e stanno pure loro nel backup. Il
|
||
middleware `RequirePasswordChange` (in coda al gruppo `web`, quindi copre
|
||
anche `/livewire/update` — Livewire segue le risposte redirezionate) dirotta
|
||
su `password.change` tutto tranne quella pagina, il suo invio e il logout.
|
||
|
||
- **La schermata di cambio password non è Livewire e non chiede la password
|
||
attuale.** Non è Livewire perché deve restare raggiungibile mentre il
|
||
middleware respinge anche le POST di Livewire, e distinguere quale
|
||
componente sta dietro una `/livewire/update` è fragile; due rotte HTTP si
|
||
escludono per nome. Non chiede la password attuale perché il restore ha
|
||
appena sovrascritto gli hash con quelli del backup: la "password attuale" di
|
||
un account è quella dell'epoca del backup, che l'utente — l'amministratore
|
||
per primo — può benissimo non ricordare. La garanzia è la sessione: a quella
|
||
pagina ci si arriva già autenticati.
|
||
|
||
### Export/Import XML: backup completo e round-trip
|
||
|
||
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
|
||
non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include
|
||
ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`),
|
||
i campi settings prima omessi (`public_base_url`, `logo_path`,
|
||
`pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete
|
||
(`deleted_at`) di proclamatori/territori, i path file dei territori
|
||
(`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni
|
||
(`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`,
|
||
`pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità.
|
||
L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così
|
||
un export si reimporta senza perdite.
|
||
|
||
Tre scelte non ovvie:
|
||
|
||
- **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si
|
||
basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione
|
||
SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import
|
||
**perdeva territori e assegnazioni** (riferimenti non risolti). Ora
|
||
`addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale.
|
||
|
||
- **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità
|
||
vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users`
|
||
durante un restore rischierebbe di **estromettere l'amministratore** che sta
|
||
importando (backup che precede il suo account, o file parziale). Gli account
|
||
esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene
|
||
eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è
|
||
senza perdite e non blocca fuori nessuno.
|
||
|
||
- **Hash password e timestamp scritti col query builder.** Dopo il `save()` del
|
||
modello, password/`remember_token`/`email_verified_at`/`created_at`/
|
||
`updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash
|
||
esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un
|
||
hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i
|
||
timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità.
|
||
|
||
### La pagina "Backup e import": cinque zone e una sola console
|
||
|
||
La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato,
|
||
la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che
|
||
ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a
|
||
tre regole.
|
||
|
||
**Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`,
|
||
`2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha
|
||
un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test
|
||
verifica che le cinque ancore esistano *e che siano in quell'ordine nel
|
||
sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico.
|
||
|
||
**Il "focus" della pagina.** Premuto un pulsante — o scelta una voce
|
||
dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare
|
||
(`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la
|
||
richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il
|
||
cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona
|
||
è marcato `data-zona-focus` ed è quello che lo riceve.
|
||
|
||
**Una sola console, agganciata in fondo alla finestra.** Prima la stessa
|
||
operazione si raccontava in tre punti diversi: un ripristino cominciava nel
|
||
riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio"
|
||
richiudibile nel pannello di avanzamento, in cima. Per capire dove si era
|
||
fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di
|
||
tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti
|
||
i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è
|
||
interrotto il flusso di righe — un log che esce dallo schermo appena si scorre
|
||
non serve proprio nel momento in cui servirebbe.
|
||
|
||
Dettagli non ovvi della console:
|
||
|
||
- **Le righe del server viaggiano già scremate.** Il conto di quante ne sono
|
||
state spedite lo tiene il componente Livewire (`logSpediti`), non il browser:
|
||
la coda intera dentro ogni risposta significherebbe sessanta righe poco meno
|
||
di due volte al secondo, e per l'import PDF — la cui coda non ha tetto —
|
||
l'intera cronologia a ogni poll.
|
||
|
||
- **Per ogni operazione c'è un solo componente che la racconta**
|
||
(`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo
|
||
stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi
|
||
le stesse righe, e la console le mostrerebbe due volte.
|
||
|
||
- **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un
|
||
evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero
|
||
`window.location.href` due volte, e il pacchetto verrebbe scaricato due volte.
|
||
L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo
|
||
stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo:
|
||
il segno "download già inviato" sta nello stato condiviso, quindi chi segue
|
||
l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto
|
||
e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca
|
||
la cancellazione.
|
||
|
||
- **Lo scorrimento automatico si sospende se si torna indietro a leggere.**
|
||
Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è
|
||
il modo più rapido di rendere inutile un log che scorre. Compare invece
|
||
"Torna in fondo".
|
||
|
||
- **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in
|
||
fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa.
|
||
|
||
- **Il JavaScript della console sta in un `<script>`, non dentro `x-data`.** È
|
||
la via d'uscita dalla trappola documentata più sotto: dentro un `<script>` le
|
||
virgolette doppie e gli apostrofi non hanno alcuna limitazione. Non sta in
|
||
`resources/js/app.js` perché quel bundle è un modulo ES, che il browser rimanda
|
||
a dopo il parsing del documento, mentre Alpine arriva col pacchetto di Livewire
|
||
in fondo al `<body>` ed è già avviato a quel punto: una `Alpine.data()`
|
||
registrata dopo l'avvio non viene applicata agli elementi già inizializzati. Un
|
||
`<script>` classico nella pagina viene eseguito prima, sempre — e c'è un test
|
||
che verifica proprio che la definizione preceda `livewire.js`, perché è un
|
||
rapporto che guardando i due file non si vede.
|
||
|
||
**Un solo sistema di pulsanti** — vedi la sezione dedicata qui sotto.
|
||
|
||
### Un solo pulsante per tutta l'applicazione
|
||
|
||
`resources/css/app.css` definisce `.tm-btn`;
|
||
`resources/views/components/btn.blade.php` è l'unico posto da cui si usa. Nessuna
|
||
vista scrive quelle classi a mano, e nessuna vista definisce più un pulsante per
|
||
conto proprio.
|
||
|
||
Da dove veniamo: **quattro vocabolari in circolazione insieme** — `style=`
|
||
inline con i colori scritti a mano (più `onmouseover`/`onmouseout` per simulare
|
||
l'hover), catene di utility Tailwind ripetute a memoria, `.btn-action*` in sei
|
||
tinte e `.btn-primary-*` in due. La stessa azione cambiava aspetto da una pagina
|
||
all'altra, l'ordine di *Salva* e *Annulla* si invertiva fra un form e l'altro, e
|
||
**nessuno** di quei pulsanti aveva uno stato di focus: chi naviga da tastiera non
|
||
vedeva dove si trovava.
|
||
|
||
Il sistema ha due assi ortogonali, non una tavolozza:
|
||
|
||
| Asse | Valori | Regola |
|
||
|------|--------|--------|
|
||
| `variante` | `primary` | l'azione principale. Una sola per schermata o zona. |
|
||
| | `secondary` | tutto il resto: dettaglio, modifica, scarica, filtra. |
|
||
| | `danger` | **solo** dove si cancellano o si sovrascrivono dati. |
|
||
| | `ghost` | annulla, chiudi, pulisci. |
|
||
| `tono` | `solido` | il pieno (default), per le azioni isolate. |
|
||
| | `tenue` | la tinta chiara, per le righe di elenco. |
|
||
| | `velato` | per i comandi sopra una superficie colorata: solo la testata. |
|
||
|
||
più `dimensione="sm"` per le righe e `icona` per i pulsanti di sola icona.
|
||
|
||
Le decisioni non ovvie prese convertendo:
|
||
|
||
- **`tenue` esiste per le liste.** In una riga i pulsanti sono tanti quanti i
|
||
record: cinquanta pulsanti pieni sono rumore, e il rosso pieno smette di
|
||
significare "attenzione" quando compare cinquanta volte di fila.
|
||
|
||
- **Il rosso è tornato a significare una cosa sola.** *Rientra* era rosso pieno
|
||
quanto un *Elimina*, in tre pagine diverse; anche *Scarica PDF* nella scheda
|
||
"Da rientrare" della dashboard lo era, solo perché lo è la scheda che lo
|
||
contiene. Nessuno dei due cancella niente.
|
||
|
||
- **Ordine unico: azione principale prima, uscita dopo.** Valeva già in metà dei
|
||
form, l'opposto nell'altra metà, e nelle modali del Registro c'era la terza
|
||
variante. Ora è lo stesso ovunque, form e modali comprese.
|
||
|
||
- **`icona` richiede `aria-label`.** I pulsanti di sola icona avevano solo un
|
||
`title`, che i lettori di schermo annunciano in modo inaffidabile: un pulsante
|
||
senza nome accessibile è un pulsante che non si può usare senza vederlo.
|
||
|
||
- **`velato` non è una quinta variante inventata.** La testata ha il suo
|
||
gradiente, e lì un pulsante bianco o indaco sarebbe una toppa: il colore lo dà
|
||
lo sfondo. Ha anche un anello di focus bianco, perché quello blu su indaco non
|
||
si distingue.
|
||
|
||
Attenzione a una trappola di Blade: dentro un tag di componente si scrive
|
||
`:disabled="$condizione"`, **non** `@disabled($condizione)`. Le direttive non
|
||
vengono compilate lì dentro: il tag `<x-btn>` resta letterale nel PHP generato
|
||
mentre il `</x-btn>` viene chiuso, e la vista muore con «syntax error, unexpected
|
||
token endif».
|
||
|
||
A tenere insieme tutto questo ci sono due test, perché una convenzione senza
|
||
guardia dura fino alla prossima pagina scritta copiando una vecchia:
|
||
|
||
- `tests/Unit/StileUnificatoTest.php` legge i sorgenti delle viste e rifiuta il
|
||
ritorno di `.btn-action*`, `.btn-primary-*`, `onmouseover`/`onmouseout`, degli
|
||
sfondi scritti a mano su `<button>`/`<a>`, e delle classi `tm-btn` messe
|
||
direttamente in una vista invece che dal componente.
|
||
- `tests/Feature/PagineApplicativeTest.php` apre **ogni** pagina e verifica che
|
||
non sia rimasto un `<x-btn` da compilare — perché un componente non compilato
|
||
non solleva: semplicemente il pulsante non c'è.
|
||
|
||
### Perché l'azzeramento è un componente a sé
|
||
|
||
`Settings/ZonaPericolosa` è nato da un problema di sola disposizione: la zona
|
||
pericolosa deve stare **in fondo** alla pagina, mentre backup e ripristino stanno
|
||
in cima, e i tre erano lo stesso componente. Reso da `BackupManager` l'azzeramento
|
||
finiva a metà pagina, fra il ripristino e l'import — l'ultimo posto in cui si
|
||
vuole trovare un pulsante che cancella tutto.
|
||
|
||
La logica condivisa non è stata duplicata: sta nel trait
|
||
`Livewire/Concerns/SegueOperazioneBackup` (posto unico, aggancio all'operazione
|
||
in corso, download automatico, righe verso la console). Duplicare `postoLibero()`
|
||
avrebbe significato duplicare la regola su cui si regge il fatto che due
|
||
operazioni non si sovrascrivano: è l'ultimo posto dove ha senso avere due copie.
|
||
|
||
Conseguenza da conoscere: **due componenti pollano lo stesso stato**. Il
|
||
proprietario a 750ms (5s in attesa-download), l'altro a 3s — non ha righe da
|
||
raccontare, ma deve accorgersi che il posto si è liberato, altrimenti il suo
|
||
pulsante resterebbe disabilitato fino al ricaricamento della pagina, con l'aria
|
||
di essersi rotto. Si avvisano anche a evento (`operazione-avviata`), indirizzato
|
||
con `->to()` e non gridato: senza, tornerebbe pure al mittente.
|
||
|
||
Il secondo tempo dell'azzeramento (verifica del backup, password, conferma)
|
||
compare adesso **dentro la zona**, dove è stato premuto il primo. Prima usciva
|
||
nel pannello di avanzamento in cima, a schermate di distanza dal pulsante che lo
|
||
aveva avviato.
|
||
|
||
### La suite di test ha un'immagine sua
|
||
|
||
Le dipendenze di sviluppo non stanno nell'immagine di produzione, e non devono
|
||
cominciare a starci: `--no-dev` è la ragione per cui il runtime non porta con sé
|
||
PHPUnit, Faker e Ignition. Da qui uno stage `test` nel `Dockerfile`, che
|
||
condivide con `runtime` lo stage `base` — stesse estensioni, stesso `php.ini` —
|
||
e si distingue solo per il `vendor/` completo. Un test che passa lì dentro non
|
||
può passare grazie a un ambiente diverso da quello vero.
|
||
|
||
Le scelte che vale la pena conoscere:
|
||
|
||
- **`runtime` deve restare l'ultimo stage del file.** `docker build` senza
|
||
`--target` costruisce l'ultimo, ed è così che lo invocano sia i tre servizi
|
||
del compose (nessuno dichiara `target`) sia Dokploy. Uno stage aggiunto in
|
||
fondo diventerebbe **in silenzio** l'immagine di produzione. Per questo `test`
|
||
sta *prima* di `runtime` pur essendo stato aggiunto dopo. L'avvertenza è anche
|
||
nel Dockerfile, sopra lo stage.
|
||
|
||
- **La suite non dipende da niente.** `phpunit.xml` impone SQLite in memoria,
|
||
cache `array` e code `sync`, e il servizio compose non ha `depends_on` né
|
||
`env_file`. Gira a stack spento, non tocca `db_data` né `storage_data`, e
|
||
soprattutto non può puntare per sbaglio al database di sviluppo: un `.env`
|
||
ereditato dal container è esattamente il modo in cui una suite di test
|
||
cancella dati veri.
|
||
|
||
Il rovescio: **SQLite non è MariaDB**. Resta un solo punto non verificabile
|
||
qui, l'ordinamento di `Registro::render()`, che usa `CAST(... AS UNSIGNED)`:
|
||
va provato sullo stack vero. Gli altri due sono stati tolti di mezzo perché
|
||
costavano poco e in cambio rendevano testabile una pagina intera — la
|
||
migrazione `2026_04_08_123500`, il cui `GREATEST(...)` faceva proprio morire
|
||
la costruzione dello schema, e la media di percorrenza in `Home`, che usava
|
||
`DATEDIFF`/`YEAR` e teneva fuori dalla suite la pagina più visitata
|
||
dell'applicazione. Entrambe danno lo stesso risultato di prima.
|
||
|
||
- **Il sorgente non è montato, e il `--build` non è facoltativo.** Con i bind
|
||
mount la stessa suite passa da ~15 secondi a ~2 minuti (ogni bootstrap di
|
||
Laravel legge una decina di file attraverso il layer di Docker Desktop), e in
|
||
cambio si guadagna solo di non ricostruire. Il comando canonico è
|
||
`docker compose --profile test run --build --rm test`: senza `--build` si
|
||
eseguono i test dell'immagine precedente, che possono essere di due modifiche
|
||
fa. Stesso genere di trappola del "Rebuild deployment" su Dokploy.
|
||
|
||
- **`APP_KEY` di test è fissa e sta in chiaro in `phpunit.xml`.** Nome e cognome
|
||
dei proclamatori hanno il cast `encrypted`: senza chiave nemmeno un test che
|
||
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con
|
||
la chiave di produzione, che vive nell'ambiente e nel volume.
|
||
|
||
- **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e
|
||
PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano
|
||
direttamente; il componente `Registro` si limita a validare l'anno e
|
||
scaricare. Il PDF scrive sopra il modulo ufficiale
|
||
(`resources/pdf/S-13_I.pdf`, convertito in PDF 1.4 per FPDI) alle
|
||
coordinate della sua griglia, come la cartolina S-12.
|
||
|
||
### Un solo container esegue le migrazioni
|
||
|
||
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
|
||
attende e non tocca lo schema. Due container in gara su `artisan migrate` è
|
||
il modo tipico di ritrovarsi con uno schema applicato a metà.
|
||
|
||
### `APP_KEY` risolta una volta sola all'avvio
|
||
|
||
Prima veniva rigenerata da `AppServiceProvider::boot()` a ogni richiesta se
|
||
mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
|
||
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
|
||
provider **solleva un'eccezione** se manca, invece di rigenerarla.
|
||
|
||
Dal 09/10/2026 l'entrypoint **si rifiuta di partire** se `APP_KEY`
|
||
nell'ambiente è diversa dalla copia in `storage/app/.app_key`: prima la
|
||
sovrascriveva, e un valore sbagliato in Dokploy distruggeva l'unica copia della
|
||
chiave giusta. Per un cambio voluto si aggiunge `APP_KEY_ROTATE=1` (la chiave
|
||
vecchia resta in `.app_key.prev-<data>`) e poi la si toglie.
|
||
|
||
---
|
||
|
||
## Bug corretti — i più significativi
|
||
|
||
Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
|
||
|
||
| Bug | Effetto | Dove |
|
||
|-----|---------|------|
|
||
| `key:generate` a runtime | Rotazione silenziosa della chiave → **perdita irreversibile** dei dati cifrati | `AppServiceProvider` |
|
||
| Import PDF da cartella mai accodato | Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata | `Livewire/Settings/XmlExchange` |
|
||
| Permessi non riapplicati su Livewire | Un permesso revocato restava attivo fino al reload della pagina | `AppServiceProvider` (`addPersistentMiddleware`) |
|
||
| Host header injection | Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari | `bootstrap/app.php`, `nginx/default.conf` |
|
||
| Default TTL link mai convertito | Link PDF validi 2 anni invece di 1 mese | migrazione `2026_07_18_000001` |
|
||
| `syncPermissions()` nel seeder | Un re-seed azzerava i permessi personalizzati dei ruoli | `RolesAndPermissionsSeeder` |
|
||
| `campagna_id` manipolabile | Rientro conteggiato su una campagna arbitraria | `Livewire/Assegnazioni/Rientra` (`#[Locked]`) |
|
||
| `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` |
|
||
| Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` |
|
||
| Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` |
|
||
| Export XML senza `legacy_id` | Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | `Livewire/Settings/XmlExchange` (`addLegacyIdAttribute`) |
|
||
| Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti |
|
||
| `addFile()` senza controllo esito | Backup con un file mancante, **silenzioso**: ci si accorge solo al ripristino | `Services/BackupArchive` |
|
||
| Esito del ripristino con `Cache::pull` | L'amministratore non vedeva **mai** il riepilogo: la `fetch` che segue il 302 lo consumava prima della navigazione | `Auth/PasswordChangeController` |
|
||
| Nessun blocco fra operazioni | Backup, ripristino e azzeramento potevano sovrapporsi sullo stesso database e disco | `Services/BackupOperationState` (`corrente()`) |
|
||
| Job ucciso = operazione eterna | Timeout o crash del worker lasciavano lo stato "in corso" e il posto occupato | `failed()` sui tre job di backup |
|
||
| Cancellazione file senza verifica | «N file cancellati» con i file ancora sul disco (permessi) | `BackupArchive::svuotaDiscoPubblico()` |
|
||
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` |
|
||
| Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` |
|
||
| Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` |
|
||
| Date esportate in UTC | Ogni backup → ripristino (e export → import XML) spostava **tutte** le date indietro di un giorno, sommandosi a ogni giro. I file vecchi vengono riportati al giorno giusto all'import | `XmlExchange::righeEsportate()`, `normalizeDateForDb()` |
|
||
| Import di qualsiasi XML | Un XML ben formato ma estraneo svuotava il database con "completato con successo". Ora servono la radice `<termanager2>` e le sezioni principali | `XmlExchange::importXmlFromContent()` |
|
||
| Caratteri di controllo nell'export | Uno solo, in una nota, rendeva il file (e il `data.xml` del backup) non reimportabile | `XmlExchange::addXmlText()` |
|
||
| Territori senza zona scartati all'import | Sparivano con tutto il loro storico di assegnazioni | `XmlExchange::importXmlFromContent()` |
|
||
| Import che estromette chi lo esegue | Password vecchia e ruoli del file applicati anche all'amministratore che importava | `XmlExchange::importXmlFromContent()` |
|
||
| `whereNull` fuori da `latestOfMany` | Con una chiusa più recente dell'aperta il territorio risultava libero mentre era fuori, e assegnabile due volte | `Territorio::assegnazioneCorrente()` |
|
||
| Periodi di assegnazione accavallati | Uscite retrodatate a prima dell'ultimo rientro, doppie aperte dal Registro, anno scelto a mano incoerente con la data | `Assegnazione::sovrappostaA()`, `Assegna`, `Registro` |
|
||
| Declassamento dell'ultimo amministratore | Togliendosi il ruolo l'installazione restava senza nessuno che potesse gestirla | `Settings/UsersIndex` |
|
||
| `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` |
|
||
| `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
|
||
| Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` |
|
||
|
||
---
|
||
|
||
## Cosa resta aperto
|
||
|
||
In ordine di priorità.
|
||
|
||
### 0. Il ripristino da browser è limitato a 100 MB — decisione in sospeso
|
||
|
||
**È il punto da cui riprendere.** Cloudflare rifiuta i caricamenti oltre 100 MB
|
||
(vedi *Trappole da conoscere*), quindi il ripristino dall'interfaccia non
|
||
funziona per un pacchetto con molte mappe. L'applicazione è a posto: il muro sta
|
||
davanti.
|
||
|
||
Da decidere, in ordine di rapidità:
|
||
|
||
1. Togliere il dominio da Cloudflare (DNS-only), anche solo su un sottodominio
|
||
dedicato all'amministrazione — gratis e immediato.
|
||
2. Caricare puntando direttamente all'IP dell'origine, scavalcando Cloudflare.
|
||
3. Ripristino dai volumi (procedura nel README): nessun limite, serve accesso
|
||
al server.
|
||
4. Piano Cloudflare Business: 200 MB, spesso ancora insufficienti.
|
||
|
||
Resta da accertare **quanto pesa davvero il pacchetto** che si vuole
|
||
ripristinare: sotto i 100 MB il problema non sarebbe la dimensione ma il timeout
|
||
di 100 secondi di Cloudflare, e la soluzione sarebbe un'altra.
|
||
|
||
### 1. Copertura dei test ancora parziale
|
||
|
||
L'impalcatura ora c'è (vedi *La suite di test ha un'immagine sua*) e i tre
|
||
blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
|
||
`test` nel Dockerfile con le dipendenze di sviluppo, connessione `sqlite` in
|
||
`config/database.php`, `phpunit.xml`. Si lancia con:
|
||
|
||
```bash
|
||
docker compose --profile test run --build --rm test
|
||
```
|
||
|
||
**Coperto oggi — 294 test, tutti verdi:**
|
||
|
||
| File | Cosa protegge |
|
||
|------|---------------|
|
||
| `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio |
|
||
| `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata |
|
||
| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" (fine ciclo e ultimo rientro prima dell'anno), assegnazioni a cavallo d'anno su entrambi i fogli, una riga per ogni territorio attivo anche senza assegnazioni, esclusione di territori disattivati e cestinati, ordinamento naturale dei numeri, fogli da 20 righe e generazione del PDF |
|
||
| `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede |
|
||
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
||
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
||
| `tests/Unit/StileUnificatoTest.php` | Che i vocabolari di stile paralleli non rientrino: `.btn-action*`, `.btn-primary-*`, l'hover simulato con `onmouseover`, gli sfondi scritti a mano sui pulsanti, le classi `tm-btn` messe in una vista invece che dal componente |
|
||
| `tests/Feature/PiantineManagerTest.php` | La pagina di gestione: accesso riservato, KML accettato e rifiutato, collegamento e scollegamento di una piantina, rifiuto di un territorio che ne ha gia' una, cancellare il territorio **non** cancella il disegno (la regressione che la cascata causava a ogni import XML), niente PDF senza territorio, e l'anteprima che non tocca `pdf_path` ne' lo storico |
|
||
| `tests/Feature/PiantineEditorTest.php` | L'editor: piantina salvata senza territorio, territorio creato dalla scheda con zona/tipologia/confini, aggiornamento di uno esistente, zona e tipologia create sul posto e lasciate selezionate, numero e zona duplicati respinti, ridisegno che sostituisce senza duplicare, geometria non valida ignorata |
|
||
| `tests/Unit/PiantinaPdfServiceTest.php` | La cartolina: due pagine, la Localita' presa dalla zona e i confini davvero presenti nel PDF (estratti decomprimendo i flussi, non solo "non ha sollevato eccezioni"), il giro completo dello storico — sostituzione, ripristino reversibile, file sparito |
|
||
| `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata |
|
||
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
|
||
|
||
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
|
||
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
|
||
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
|
||
|
||
Quello che questi test **non** possono coprire: Alpine e il comportamento della
|
||
console — apertura automatica, scorrimento, evidenziazione delle zone — girano
|
||
nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
|
||
|
||
**Scoperto ancora, in ordine di quanto costerebbe sbagliarsi:**
|
||
|
||
- `Services/BackupArchive::extract()` sui **limiti anti zip-bomb**: numero di
|
||
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
|
||
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
|
||
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
|
||
l'ordine staging → database → file, il `failed()` che libera il posto, la
|
||
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
|
||
`password_change_required` acceso su tutte le righe. È la logica il cui
|
||
errore si scopre solo quando è troppo tardi.
|
||
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
|
||
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
|
||
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
|
||
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
|
||
priorità automatica, percentuale di percorrenza delle campagne.
|
||
- Il permesso come contratto: che un `assistente` **non** possa modificare o
|
||
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
|
||
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
|
||
verificherebbe tutto insieme.
|
||
|
||
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
||
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
||
|
||
### 2. Race su doppia assegnazione — chiusa il 09/10/2026
|
||
|
||
`Assegna::save()` e il salvataggio del Registro controllano e scrivono dentro
|
||
una transazione con `lockForUpdate()` sulla riga del territorio, e rifiutano
|
||
ogni periodo che si accavalla a un altro (`Assegnazione::sovrappostaA()`). Non
|
||
c'è ancora un vincolo nel database: l'import XML e i dati storici possono
|
||
contenere sovrapposizioni, che il controllo segnala solo quando si modifica
|
||
una di quelle righe.
|
||
|
||
### 3. Scrittura durante il rendering
|
||
|
||
`Assegnazione::shortPdfUrl()` genera e salva il codice d'accesso al primo
|
||
utilizzo, quindi una GET del registro può produrre decine di INSERT. Meglio
|
||
generare il codice alla creazione dell'assegnazione (observer) e far restituire
|
||
`null` al metodo quando manca.
|
||
|
||
### 4. Ricerca in memoria sui campi cifrati
|
||
|
||
Nome e cognome dei proclamatori sono cifrati, quindi la ricerca carica l'intera
|
||
tabella e filtra in PHP (`ProclamatoreIndex`, `Registro`). Accettabile sotto
|
||
qualche centinaio di record, non oltre. Se dovesse servire scalare: hash
|
||
deterministico ricercabile affiancato al campo cifrato.
|
||
|
||
### 5. Endpoint PDF pubblici senza throttle
|
||
|
||
`/p/{code}` e le rotte del visualizzatore sono senza autenticazione né rate
|
||
limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a
|
||
~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non
|
||
verrebbe né rallentato né registrato. Un `throttle:30,1` costerebbe una riga.
|
||
|
||
### 6. Restano da uniformare i colori che non sono pulsanti
|
||
|
||
I pulsanti sono a posto in tutta l'applicazione (vedi *Un solo pulsante per
|
||
tutta l'applicazione*), ma i **colori decorativi** sono ancora scritti a mano,
|
||
esadecimale per esadecimale, dentro gli attributi `style=`: le piastrelle delle
|
||
icone e le fasce delle schede nella dashboard, gli sfondi delle intestazioni di
|
||
tabella, i pannelli delle modali del Registro, i riquadri dei messaggi nel
|
||
layout.
|
||
|
||
Non sono un problema di accessibilità come lo erano i pulsanti — nessuno di
|
||
questi elementi si può mettere a fuoco — ma sono lo stesso genere di debito: lo
|
||
stesso verde compare in quattro tonalità leggermente diverse, e cambiare la
|
||
tinta dell'applicazione oggi significa cercare a mano tutte le occorrenze.
|
||
|
||
**Dal 28/08 metà del lavoro è fatta**: i token esistono (`:root` e `@theme` in
|
||
`app.css`, vedi *Il sistema visivo sta nei token*), e le scale `gray`/`indigo`
|
||
di Tailwind sono ridefinite, quindi tutto ciò che usa le utility è già
|
||
allineato. Resta da togliere gli `style=` con gli esadecimali scritti a mano e
|
||
farli citare i token. Il test `StileUnificatoTest` è il posto dove aggiungere la
|
||
guardia, quando si farà.
|
||
|
||
Fuori perimetro per scelta: `resources/views/pdf/*` le impagina dompdf, che di
|
||
Tailwind e delle nostre classi non sa niente. `StileUnificatoTest` esclude anche
|
||
`resources/views/vendor/`, che oggi non esiste — le viste di paginazione
|
||
pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
|
||
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
|
||
`pagination::`. L'esclusione resta a guardia di future viste di terze parti.
|
||
|
||
### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026
|
||
|
||
Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe`
|
||
e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
|
||
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
|
||
lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
|
||
|
||
---
|
||
|
||
## Trappole da conoscere
|
||
|
||
**Cloudflare taglia i caricamenti a 100 MB, e `MAX_UPLOAD_MB` non c'entra
|
||
niente.** I domini pubblici passano da Cloudflare, che sul piano free rifiuta i
|
||
corpi di richiesta oltre **100 MB**. Rifiuta sulla base del `Content-Length`,
|
||
quindi il `413` arriva dopo che il browser ha spedito appena un megabyte: la
|
||
barra di caricamento si pianta a una percentuale bassa e — senza i gestori
|
||
aggiunti alla console — non compare alcun messaggio. È **il** motivo per cui il
|
||
ripristino di un pacchetto grande sembra bloccarsi senza spiegazione.
|
||
|
||
Misurato il 29/07/2026 su `demo-termanager.pyconetwork.it`:
|
||
|
||
| Payload | Esito |
|
||
|---------|----------------------------------------------------|
|
||
| 150 MB | `413 Payload Too Large`, `Server: cloudflare`, 2,7 s |
|
||
| 100 MB | `524` (timeout dell'origine dopo 100 s di Cloudflare) |
|
||
|
||
Il pacchetto **non raggiunge mai nginx**, quindi alzare `MAX_UPLOAD_MB` non
|
||
sposta nulla: quel valore governa solo ciò che accade *dopo* Cloudflare. Le
|
||
strade per superarlo sono togliere il dominio da Cloudflare (DNS-only, anche
|
||
solo su un sottodominio d'amministrazione), caricare puntando direttamente
|
||
all'IP dell'origine, oppure usare il ripristino dai volumi. Il piano Business
|
||
arriva a 200 MB, che per un archivio con tutte le mappe è spesso ancora poco.
|
||
|
||
Nota sul `524`: anche restando sotto i 100 MB, Cloudflare chiude dopo 100
|
||
secondi se l'origine non ha ancora risposto. Un caricamento lento può quindi
|
||
fallire pur essendo dentro il limite di dimensione.
|
||
|
||
**Il JavaScript che vive dentro un attributo HTML.** Vale ancora per i blocchi
|
||
`x-data` di `backup-manager`, `zona-pericolosa` e della zona Import PDF, che
|
||
gestiscono i caricamenti. La console, che era il blocco più grosso, è stata
|
||
portata fuori in un `<script>` proprio per uscirne (vedi *La pagina "Backup e
|
||
import"*); il resto resta soggetto alla regola.
|
||
`x-data` finisce alla **prima virgoletta doppia**, comprese quelle dentro un
|
||
commento JavaScript: un banale `// un generico "errore"` ha chiuso l'attributo a
|
||
metà e riversato tutto il codice restante a video come testo — è successo due
|
||
volte. Nello stesso blocco, un `\'` scritto a mano nel sorgente Blade *chiude* la
|
||
stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
|
||
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
||
da nessuna parte; apostrofi italiani solo dentro backtick.
|
||
|
||
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
||
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
||
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
||
|
||
**`APP_KEY` è il singolo punto di perdita dati.** I nomi dei proclamatori sono
|
||
cifrati con questa chiave. Il backup del database senza la chiave è inutile per
|
||
quei campi. Se non la imposti, l'entrypoint ne genera una e la salva nel volume
|
||
`storage_data`, stampandola nei log: se quel volume viene perso, i dati sono
|
||
irrecuperabili.
|
||
|
||
**`APP_URL` deve combaciare col dominio servito.** L'applicazione rifiuta
|
||
richieste con `Host` diverso (protezione host header injection) e restituisce
|
||
400 senza spiegazioni. È la causa più comune di "il deploy è andato ma il sito
|
||
non si apre". `localhost` e `127.0.0.1` sono ammessi perché li usano gli health
|
||
check interni.
|
||
|
||
**Il volume `storage_data` non è solo cache.** Contiene i PDF dei territori, le
|
||
miniature e la chiave di fallback. Non va trattato come rigenerabile.
|
||
|
||
**Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente
|
||
riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine.
|
||
|
||
**L'export XML contiene dati sensibili.** Il file di export include gli hash
|
||
delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro.
|
||
Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli
|
||
hash. La pagina di export lo segnala, ma il controllo resta umano — la
|
||
funzione è già riservata all'amministratore (`settings.manage`).
|
||
|
||
---
|
||
|
||
## Come verificare che tutto funzioni
|
||
|
||
Dopo una modifica sostanziale, questo giro copre i percorsi critici:
|
||
|
||
C'è un solo `docker-compose.yml`, usato sia in produzione sia in locale. In
|
||
locale serve creare una volta la rete esterna attesa da Dokploy.
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
# nel .env: APP_URL=http://localhost:8080, APP_ENV=local,
|
||
# SESSION_SECURE_COOKIE=false, e le tre password
|
||
docker network create dokploy-network
|
||
|
||
# 0. La suite passa (non serve nient'altro acceso, ~15 s)
|
||
docker compose --profile test run --build --rm test
|
||
|
||
# 1. L'immagine builda
|
||
# Senza --target si costruisce l'ultimo stage, che e' `runtime`: se questo
|
||
# comando producesse un'immagine con phpunit dentro, qualcuno ha aggiunto
|
||
# uno stage in fondo al Dockerfile.
|
||
docker build -f docker/php/Dockerfile -t termanager2-check .
|
||
|
||
# 2. Stack pulito da zero
|
||
docker compose --profile dev down -v
|
||
docker compose --profile dev up -d --build
|
||
docker compose --profile dev ps # tutti i servizi healthy
|
||
|
||
# 3. L'applicazione risponde
|
||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up # 200
|
||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/login # 200
|
||
|
||
# 4. Il redeploy è idempotente
|
||
docker compose up -d --force-recreate app
|
||
docker compose logs app | grep -E "Migrations|Seed"
|
||
# atteso: "Migrations applied" + "Seed already applied"
|
||
|
||
# 5. La cifratura funziona
|
||
docker compose exec app php artisan tinker --execute="
|
||
\$p = App\Models\Proclamatore::create(['nome'=>'Test','cognome'=>'Verifica','attivo'=>true]);
|
||
\$raw = DB::table('proclamatori')->where('id',\$p->id)->value('nome');
|
||
echo \$raw === 'Test' ? 'ERRORE: dati in chiaro' : 'OK: cifrato';
|
||
"
|
||
```
|
||
|
||
È lo stesso file che gira in produzione: le uniche differenze sono i profili
|
||
`dev` (che aggiunge Mailpit) e `test` (che aggiunge la suite, effimera) e i
|
||
valori nel `.env`.
|
||
|
||
---
|
||
|
||
## Documentazione
|
||
|
||
| Documento | Contenuto |
|
||
|------------------------------------|--------------------------------------------|
|
||
| [README.md](README.md) | Deploy, configurazione, uso, riferimento |
|
||
| `HANDOFF.md` (questo file) | Stato, decisioni, debito tecnico |
|
||
|
||
La specifica funzionale originale (`TerManager2_v2.md`) è stata rimossa dal
|
||
working tree: descriveva funzionalità poi cambiate — in particolare un wizard
|
||
di primo avvio successivamente eliminato in favore della pagina Impostazioni —
|
||
e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide
|
||
(modello dati, regole di business, RBAC) sono confluite nel README.
|
||
|
||
Resta consultabile nella storia del repository:
|
||
|
||
```bash
|
||
git show 701f479:TerManager2_v2.md
|
||
```
|