15 KiB
Handoff — TerManager2
Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi README.md; qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: 25 luglio 2026.
Stato attuale
L'applicazione è pronta per il deploy in produzione su Dokploy. Lo stack è stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti, redeploy, login, tutte le pagine, code e scheduler.
| Aspetto | Stato |
|---|---|
| Deploy Dokploy (Compose) | ✅ verificato |
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
| Redeploy senza interventi | ✅ ~25 secondi |
| Sviluppo locale | ✅ stesso compose, profilo dev |
| Test automatici | ❌ assenti — vedi Cosa resta aperto |
Da dove veniamo
Il progetto aveva un difetto strutturale nel deploy: il Dockerfile compilava
l'applicazione in /app-src, ma il runtime girava da /var/www/html, popolato
solo dal bind mount del sorgente. L'immagine costruita non veniva mai usata.
L'entrypoint rieseguiva composer install e npm run build a ogni avvio dentro
il container. Su Dokploy, senza sorgente sull'host, significava build a runtime
a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
Da qui la ristrutturazione descritta sotto.
Decisioni architetturali
Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il contesto per ridiscuterla è qui.
L'immagine è autosufficiente
vendor/ e public/build sono compilati in stage separati del Dockerfile e
copiati nell'immagine finale. I container di produzione non montano il
sorgente e non eseguono build all'avvio.
Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend, serve un rebuild dell'immagine, non un semplice restart.
nginx e php-fpm nello stesso container
Con container separati, nginx avrebbe bisogno di una copia di public/ e del
volume storage solo per servire file statici. Per questa scala è complessità
senza beneficio. Sono coordinati da supervisor
(docker/supervisor/supervisord.conf), che abbatte il container se uno dei due
muore definitivamente — altrimenti resterebbe "up" senza servire nulla.
La configurazione arriva dall'ambiente, non da .env riscritto
Il vecchio entrypoint riscriveva .env con sed a ogni avvio. Ora Laravel
legge direttamente l'ambiente di processo, e docker-compose.yml si limita a
env_file: .env senza ridichiarare nulla.
Il .env è quindi l'unica fonte di configurazione. Una versione precedente
fissava nel compose i valori dettati dalla topologia (DB_HOST: mariadb e
simili) perché vincessero su un .env sbagliato, ma richiedeva gli anchor YAML
(x-app-env, <<:) ed è stata scartata a favore della leggibilità.
Il rovescio della medaglia: DB_HOST e REDIS_HOST devono valere mariadb e
redis, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il
container non trova più il database. È documentato in .env.example e nel
README, ma è il punto fragile di questa scelta.
Nota: perché la configurazione da ambiente funzioni, www.conf ha
clear_env = no. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe
nessuna variabile.
Fail-fast sulle variabili mancanti
Lo stack si rifiuta di partire senza APP_URL, DB_PASSWORD,
DB_ROOT_PASSWORD, REDIS_PASSWORD. Le password sono validate
dall'interpolazione compose (${VAR:?}), APP_URL dall'entrypoint (dove il
messaggio d'errore è più chiaro).
Deliberato: un errore immediato è meglio di un database in produzione con
password secret.
Modifica/cancellazione delle assegnazioni: solo amministratore
L'unico punto in cui un'assegnazione esistente si modifica o si cancella è
il CRUD del Registro (Livewire/Registro: openCreate/openEdit/save/
askDelete/deleteConfirmed), blindato su settings.manage a due livelli —
abort_if(..., 403) nel componente e @can('settings.manage') in
registro.blade.php, che nasconde pulsanti e modali. settings.manage ce l'ha
solo l'amministratore.
Di conseguenza gli altri ruoli non possono alterare le assegnazioni: la
pagina Assegna solo ne crea (territori.assign), la pagina Rientra solo le
chiude (territori.return). Nello specifico l'assistente (registro.view +
territori.assign + territori.return) vede il Registro, crea nuove
assegnazioni e le fa rientrare, ma non le modifica né le elimina.
Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su
syncPermissions() più sotto), un permesso settings.manage concesso a mano
all'assistente in passato sopravviverebbe a ogni re-seed. La migration
2026_07_25_000001_revoke_settings_manage_from_assistente_role chiude questo
drift per i database esistenti: revoca settings.manage dal ruolo assistente
se presente. È idempotente (no-op sul baseline) e volutamente irreversibile —
il down() non ripristina il permesso.
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_idderivato dall'idreale. Il rimappaggio delle FK in import si basa sull'attributolegacy_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). OraaddLegacyIdAttribute()usalegacy_idse presente, altrimenti l'idreale. -
Gli utenti sono UPSERT per email, non azzerati. Tutte le altre entità vengono cancellate e ricreate; gli utenti no. Azzerare la tabella
usersdurante 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_atvengono scritti conDB::table()->update(): preserva l'hash esatto (il casthashedrivaliderebbe il costo bcrypt e potrebbe rifiutare un hash generato conBCRYPT_ROUNDSdiverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica inapplyRowMeta()per le altre entità.
Un solo container esegue le migrazioni
Solo app migra. queue-worker e scheduler usano queue-entrypoint.sh, che
attende e non tocca lo schema. Due container in gara su artisan migrate è
il modo tipico di ritrovarsi con uno schema applicato a metà.
APP_KEY risolta una volta sola all'avvio
Prima veniva rigenerata da AppServiceProvider::boot() a ogni richiesta se
mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
provider solleva un'eccezione se manca, invece di rigenerarla.
Bug corretti — i più significativi
Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| Bug | Effetto | Dove |
|---|---|---|
key:generate a runtime |
Rotazione silenziosa della chiave → perdita irreversibile dei dati cifrati | AppServiceProvider |
| Import PDF da cartella mai accodato | Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata | Livewire/Settings/XmlExchange |
| Permessi non riapplicati su Livewire | Un permesso revocato restava attivo fino al reload della pagina | AppServiceProvider (addPersistentMiddleware) |
| Host header injection | Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari | bootstrap/app.php, nginx/default.conf |
| Default TTL link mai convertito | Link PDF validi 2 anni invece di 1 mese | migrazione 2026_07_18_000001 |
syncPermissions() nel seeder |
Un re-seed azzerava i permessi personalizzati dei ruoli | RolesAndPermissionsSeeder |
campagna_id manipolabile |
Rientro conteggiato su una campagna arbitraria | Livewire/Assegnazioni/Rientra (#[Locked]) |
pdftoppm senza timeout |
Un PDF malformato bloccava il worker per sempre | TerritorioThumbnailService |
| Collisione nomi file PDF | 1 A e 1-A scrivevano sullo stesso file → dati sovrascritti |
Jobs/ImportTerritoryPdfFolder |
| Zip senza limiti | Zip bomb → esaurimento memoria | TerritorioPdfImportDispatcher |
Export XML senza legacy_id |
Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | Livewire/Settings/XmlExchange (addLegacyIdAttribute) |
| Migrazioni thumbnail duplicate | migrate:rollback falliva a metà |
rimosse 2 migrazioni ridondanti |
Cosa resta aperto
In ordine di priorità.
1. Nessun test automatico
composer.json richiede PHPUnit ma la cartella tests/ non esiste. Ogni
verifica è manuale. È il debito più rilevante: le regole di business (anno
teocratico, stato territorio, priorità automatica, percentuale campagne) sono
esattamente il tipo di logica che va protetta da test.
Nota: config/database.php ridefinisce connections e lascia solo mysql.
Per usare SQLite in memoria nei test va riaggiunta la connessione sqlite.
2. Race su doppia assegnazione
Assegna::save() controlla che il territorio sia libero e poi crea la riga, ma
nulla a livello di database impedisce due assegnazioni aperte sullo stesso
territorio (doppio click, due schede). assegnazioneCorrente usa latestOfMany,
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi.
Rimedio: transazione con lockForUpdate(), oppure un indice univoco su colonna
generata che imponga una sola assegnazione aperta per territorio.
3. Scrittura durante il rendering
Assegnazione::shortPdfUrl() genera e salva il codice d'accesso al primo
utilizzo, quindi una GET del registro può produrre decine di INSERT. Meglio
generare il codice alla creazione dell'assegnazione (observer) e far restituire
null al metodo quando manca.
4. Ricerca in memoria sui campi cifrati
Nome e cognome dei proclamatori sono cifrati, quindi la ricerca carica l'intera
tabella e filtra in PHP (ProclamatoreIndex, Registro). Accettabile sotto
qualche centinaio di record, non oltre. Se dovesse servire scalare: hash
deterministico ricercabile affiancato al campo cifrato.
5. Endpoint PDF pubblici senza throttle
/p/{code} e le rotte del visualizzatore sono senza autenticazione né rate
limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a
~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non
verrebbe né rallentato né registrato. Un throttle:30,1 costerebbe una riga.
Trappole da conoscere
APP_KEY è il singolo punto di perdita dati. I nomi dei proclamatori sono
cifrati con questa chiave. Il backup del database senza la chiave è inutile per
quei campi. Se non la imposti, l'entrypoint ne genera una e la salva nel volume
storage_data, stampandola nei log: se quel volume viene perso, i dati sono
irrecuperabili.
APP_URL deve combaciare col dominio servito. L'applicazione rifiuta
richieste con Host diverso (protezione host header injection) e restituisce
400 senza spiegazioni. È la causa più comune di "il deploy è andato ma il sito
non si apre". localhost e 127.0.0.1 sono ammessi perché li usano gli health
check interni.
Il volume storage_data non è solo cache. Contiene i PDF dei territori, le
miniature e la chiave di fallback. Non va trattato come rigenerabile.
Modifiche a dipendenze o asset richiedono un rebuild. Non è più sufficiente
riavviare il container: vendor/ e public/build vivono dentro l'immagine.
L'export XML contiene dati sensibili. Il file di export include gli hash
delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro.
Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli
hash. La pagina di export lo segnala, ma il controllo resta umano — la
funzione è già riservata all'amministratore (settings.manage).
Come verificare che tutto funzioni
Dopo una modifica sostanziale, questo giro copre i percorsi critici:
C'è un solo docker-compose.yml, usato sia in produzione sia in locale. In
locale serve creare una volta la rete esterna attesa da Dokploy.
cp .env.example .env
# nel .env: APP_URL=http://localhost:8080, APP_ENV=local,
# SESSION_SECURE_COOKIE=false, e le tre password
docker network create dokploy-network
# 1. L'immagine builda
docker build -f docker/php/Dockerfile -t termanager2-check .
# 2. Stack pulito da zero
docker compose --profile dev down -v
docker compose --profile dev up -d --build
docker compose --profile dev ps # tutti i servizi healthy
# 3. L'applicazione risponde
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up # 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/login # 200
# 4. Il redeploy è idempotente
docker compose up -d --force-recreate app
docker compose logs app | grep -E "Migrations|Seed"
# atteso: "Migrations applied" + "Seed already applied"
# 5. La cifratura funziona
docker compose exec app php artisan tinker --execute="
\$p = App\Models\Proclamatore::create(['nome'=>'Test','cognome'=>'Verifica','attivo'=>true]);
\$raw = DB::table('proclamatori')->where('id',\$p->id)->value('nome');
echo \$raw === 'Test' ? 'ERRORE: dati in chiaro' : 'OK: cifrato';
"
È lo stesso file che gira in produzione: l'unica differenza è il profilo dev
(che aggiunge Mailpit) e i valori nel .env.
Documentazione
| Documento | Contenuto |
|---|---|
| README.md | Deploy, configurazione, uso, riferimento |
HANDOFF.md (questo file) |
Stato, decisioni, debito tecnico |
La specifica funzionale originale (TerManager2_v2.md) è stata rimossa dal
working tree: descriveva funzionalità poi cambiate — in particolare un wizard
di primo avvio successivamente eliminato in favore della pagina Impostazioni —
e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide
(modello dati, regole di business, RBAC) sono confluite nel README.
Resta consultabile nella storia del repository:
git show 701f479:TerManager2_v2.md