# Handoff — TerManager2 Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. Ultimo aggiornamento: **25 luglio 2026**. --- ## Stato attuale L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti, redeploy, login, tutte le pagine, code e scheduler. | Aspetto | Stato | |-----------------------------|-------| | Deploy Dokploy (Compose) | ✅ verificato | | Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici | | Redeploy senza interventi | ✅ ~25 secondi | | Sviluppo locale | ✅ stesso compose, profilo `dev` | | Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) | --- ## Da dove veniamo Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.** L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro il container. Su Dokploy, senza sorgente sull'host, significava build a runtime a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione. Da qui la ristrutturazione descritta sotto. --- ## Decisioni architetturali Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il contesto per ridiscuterla è qui. ### L'immagine è autosufficiente `vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e copiati nell'immagine finale. I container di produzione **non montano il sorgente** e non eseguono build all'avvio. Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend, serve un rebuild dell'immagine, non un semplice restart. ### nginx e php-fpm nello stesso container Con container separati, nginx avrebbe bisogno di una copia di `public/` e del volume storage solo per servire file statici. Per questa scala è complessità senza beneficio. Sono coordinati da supervisor (`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due muore definitivamente — altrimenti resterebbe "up" senza servire nulla. ### La configurazione arriva dall'ambiente, non da `.env` riscritto Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel legge direttamente l'ambiente di processo, e `docker-compose.yml` si limita a `env_file: .env` senza ridichiarare nulla. Il `.env` è quindi l'**unica** fonte di configurazione. Una versione precedente fissava nel compose i valori dettati dalla topologia (`DB_HOST: mariadb` e simili) perché vincessero su un `.env` sbagliato, ma richiedeva gli anchor YAML (`x-app-env`, `<<:`) ed è stata scartata a favore della leggibilità. Il rovescio della medaglia: `DB_HOST` e `REDIS_HOST` devono valere `mariadb` e `redis`, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il container non trova più il database. È documentato in `.env.example` e nel README, ma è il punto fragile di questa scelta. Nota: perché la configurazione da ambiente funzioni, `www.conf` ha `clear_env = no`. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe nessuna variabile. ### Fail-fast sulle variabili mancanti Lo stack si rifiuta di partire senza `APP_URL`, `DB_PASSWORD`, `DB_ROOT_PASSWORD`, `REDIS_PASSWORD`. Le password sono validate dall'interpolazione compose (`${VAR:?}`), `APP_URL` dall'entrypoint (dove il messaggio d'errore è più chiaro). Deliberato: un errore immediato è meglio di un database in produzione con password `secret`. ### Modifica/cancellazione delle assegnazioni: solo amministratore L'unico punto in cui un'assegnazione **esistente** si modifica o si cancella è il CRUD del Registro (`Livewire/Registro`: `openCreate`/`openEdit`/`save`/ `askDelete`/`deleteConfirmed`), blindato su `settings.manage` a due livelli — `abort_if(..., 403)` nel componente e `@can('settings.manage')` in `registro.blade.php`, che nasconde pulsanti e modali. `settings.manage` ce l'ha solo l'`amministratore`. Di conseguenza gli altri ruoli **non** possono alterare le assegnazioni: la pagina Assegna solo ne crea (`territori.assign`), la pagina Rientra solo le chiude (`territori.return`). Nello specifico l'`assistente` (`registro.view` + `territori.assign` + `territori.return`) vede il Registro, crea nuove assegnazioni e le fa rientrare, ma non le modifica né le elimina. Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su `syncPermissions()` più sotto), un permesso `settings.manage` concesso a mano all'`assistente` in passato sopravviverebbe a ogni re-seed. La migration `2026_07_25_000001_revoke_settings_manage_from_assistente_role` chiude questo drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente` se presente. È idempotente (no-op sul baseline) e volutamente irreversibile — il `down()` non ripristina il permesso. Stesso principio per le **campagne**: creazione e visualizzazione restano su `campagne.manage` (che ha anche l'assistente), ma **modifica ed eliminazione sono riservate all'amministratore** (`settings.manage`). Il gate è su tre livelli: la route `campagne/{campagna}/edit` è nel gruppo `settings.manage`; `CampagnaEdit::mount()`/`save()` e `CampagnaIndex::deleteCampagna()` chiamano `$this->authorize('settings.manage')` (l'index è raggiungibile da chi ha `campagne.manage`, quindi il delete va protetto nel metodo, non solo in route); i pulsanti Modifica/Elimina in `campagna-index`/`campagna-show` sono sotto `@can('settings.manage')`. Nota: `Campagna` non ha soft delete — l'eliminazione è definitiva. Per i **territori** vale un modello leggermente diverso, perché la visualizzazione va aperta a tutti gli operativi: lista (`territori.index`) e dettaglio (`territori.show`) sono ora su `territori.assign` (che hanno admin, assistente e operatore); creazione/modifica/cestino restano su `territori.manage` (solo admin). Attenzione a due punti: - **Ordine delle rotte.** Le rotte letterali `territori/create` e `territori/cestino` devono restare registrate PRIMA del wildcard `territori/{territorio}`, altrimenti verrebbero catturate dalla rotta di dettaglio. - **`TerritorioIndex` perde la copertura del middleware persistente.** Il suo componente è ora servito da una rotta rilassata (`territori.assign`), quindi i suoi metodi di mutazione (`toggleActive`, `togglePriority`, `deleteTerritorio`) NON sono più protetti dal middleware di route e hanno un `$this->authorize('territori.manage')` interno — senza, un operativo potrebbe invocarli via `/livewire/update` col pulsante nascosto. Per difesa in profondità lo stesso check è stato aggiunto anche a `TerritorioCreate::save`, `TerritorioEdit::save`/`removePdf`, `TerritorioCestino::restore`/`forceDelete` (che restano comunque su rotte `territori.manage`). I pulsanti Modifica/toggle/Elimina/Nuovo/Cestino in `territorio-index`/`territorio-show` sono sotto `@can('territori.manage')`; il link "Dettaglio" resta visibile a tutti. Il menu laterale mostra "Territori" a chi ha `territori.assign`. ### Export/Import XML: backup completo e round-trip `Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali, non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`), i campi settings prima omessi (`public_base_url`, `logo_path`, `pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete (`deleted_at`) di proclamatori/territori, i path file dei territori (`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni (`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`, `pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità. L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così un export si reimporta senza perdite. Tre scelte non ovvie: - **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import **perdeva territori e assegnazioni** (riferimenti non risolti). Ora `addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale. - **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users` durante un restore rischierebbe di **estromettere l'amministratore** che sta importando (backup che precede il suo account, o file parziale). Gli account esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è senza perdite e non blocca fuori nessuno. - **Hash password e timestamp scritti col query builder.** Dopo il `save()` del modello, password/`remember_token`/`email_verified_at`/`created_at`/ `updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità. ### Un solo container esegue le migrazioni Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che attende e non tocca lo schema. Due container in gara su `artisan migrate` è il modo tipico di ritrovarsi con uno schema applicato a metà. ### `APP_KEY` risolta una volta sola all'avvio Prima veniva rigenerata da `AppServiceProvider::boot()` a ogni richiesta se mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il provider **solleva un'eccezione** se manca, invece di rigenerarla. --- ## Bug corretti — i più significativi Contesto utile se ti imbatti in comportamenti che sembrano regressioni. | Bug | Effetto | Dove | |-----|---------|------| | `key:generate` a runtime | Rotazione silenziosa della chiave → **perdita irreversibile** dei dati cifrati | `AppServiceProvider` | | Import PDF da cartella mai accodato | Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata | `Livewire/Settings/XmlExchange` | | Permessi non riapplicati su Livewire | Un permesso revocato restava attivo fino al reload della pagina | `AppServiceProvider` (`addPersistentMiddleware`) | | Host header injection | Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari | `bootstrap/app.php`, `nginx/default.conf` | | Default TTL link mai convertito | Link PDF validi 2 anni invece di 1 mese | migrazione `2026_07_18_000001` | | `syncPermissions()` nel seeder | Un re-seed azzerava i permessi personalizzati dei ruoli | `RolesAndPermissionsSeeder` | | `campagna_id` manipolabile | Rientro conteggiato su una campagna arbitraria | `Livewire/Assegnazioni/Rientra` (`#[Locked]`) | | `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` | | Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` | | Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` | | Export XML senza `legacy_id` | Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | `Livewire/Settings/XmlExchange` (`addLegacyIdAttribute`) | | Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti | --- ## Cosa resta aperto In ordine di priorità. ### 1. Nessun test automatico `composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste**. 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. ```bash cp .env.example .env # nel .env: APP_URL=http://localhost:8080, APP_ENV=local, # SESSION_SECURE_COOKIE=false, e le tre password docker network create dokploy-network # 1. L'immagine builda docker build -f docker/php/Dockerfile -t termanager2-check . # 2. Stack pulito da zero docker compose --profile dev down -v docker compose --profile dev up -d --build docker compose --profile dev ps # tutti i servizi healthy # 3. L'applicazione risponde curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up # 200 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/login # 200 # 4. Il redeploy è idempotente docker compose up -d --force-recreate app docker compose logs app | grep -E "Migrations|Seed" # atteso: "Migrations applied" + "Seed already applied" # 5. La cifratura funziona docker compose exec app php artisan tinker --execute=" \$p = App\Models\Proclamatore::create(['nome'=>'Test','cognome'=>'Verifica','attivo'=>true]); \$raw = DB::table('proclamatori')->where('id',\$p->id)->value('nome'); echo \$raw === 'Test' ? 'ERRORE: dati in chiaro' : 'OK: cifrato'; " ``` È lo stesso file che gira in produzione: l'unica differenza è il profilo `dev` (che aggiunge Mailpit) e i valori nel `.env`. --- ## Documentazione | Documento | Contenuto | |------------------------------------|--------------------------------------------| | [README.md](README.md) | Deploy, configurazione, uso, riferimento | | `HANDOFF.md` (questo file) | Stato, decisioni, debito tecnico | La specifica funzionale originale (`TerManager2_v2.md`) è stata rimossa dal working tree: descriveva funzionalità poi cambiate — in particolare un wizard di primo avvio successivamente eliminato in favore della pagina Impostazioni — e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide (modello dati, regole di business, RBAC) sono confluite nel README. Resta consultabile nella storia del repository: ```bash git show 701f479:TerManager2_v2.md ```