Files
termanager2/HANDOFF.md
2026-07-29 14:26:36 +02:00

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

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

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

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

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

Sola lettura per proclamatori e campagne (non-admin)

Stessa impostazione dei territori estesa ad altre due sezioni:

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

Tabelle-elenco responsive su mobile (portrait)

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

La "Validità link PDF (mesi)" è stata rimossa: il link condivisibile ora scade esattamente quando l'assegnazione raggiunge la soglia rientro (giorni_per_smarrito), cioè assigned_at + giorni_per_smarrito — la stessa data di Assegnazione::data_consegna_presunta e del chip "Scadenza" nel viewer. AssignmentPdfController::linkScaduto e ShortPdfLinkController calcolano ora addDays(giorni_per_smarrito) invece di addMonths(ttl); la scadenza vale solo per i non autenticati e il link muore comunque al rientro (is_aperta). Rimossi il campo Impostazioni e la proprietà assignment_link_ttl_months in SettingsEdit. La colonna assignment_link_ttl_hours resta nel DB (niente migration distruttiva): è ormai vestigiale per la logica ma l'export/import XML continua a leggerla/scriverla senza effetti.

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

Assegnare un territorio ne azzera il flag "prioritario"

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

Profilo utente, viewer PDF, privacy admin-only

Tre ritocchi minori ma con qualche implicazione:

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

Backup completo .zip, ripristino, azzeramento

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

Scelte non ovvie:

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

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

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

  • L'azzeramento ha due tempi, separati dal download. Il pulsante nella zona pericolosa (avviaAzzeramento) crea solo il backup e si ferma in attesa-download; la cancellazione parte da un secondo clic esplicito (confermaAzzeramento), che rifiuta se lo stato non registra un servito_at. 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.

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

  • Ordine del restore: staging → database → file. I file si estraggono prima in storage/app/backup-restore/<uuid>, così un archivio corrotto o fuori limite fallisce mentre i dati correnti sono ancora intatti; poi importXmlFromContent() in transazione; poi la sostituzione dei file. Il disco public viene svuotato, non fuso: i percorsi vivono nelle righe appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna schermata mostra e nessuna cancellazione raggiunge.

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

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

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

Export/Import XML: backup completo e round-trip

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

Tre scelte non ovvie:

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

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

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

Un solo container esegue le migrazioni

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

APP_KEY risolta una volta sola all'avvio

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


Bug corretti — i più significativi

Contesto utile se ti imbatti in comportamenti che sembrano regressioni.

Bug Effetto Dove
key:generate a runtime Rotazione silenziosa della chiave → perdita irreversibile dei dati cifrati AppServiceProvider
Import PDF da cartella mai accodato Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata Livewire/Settings/XmlExchange
Permessi non riapplicati su Livewire Un permesso revocato restava attivo fino al reload della pagina AppServiceProvider (addPersistentMiddleware)
Host header injection Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari bootstrap/app.php, nginx/default.conf
Default TTL link mai convertito Link PDF validi 2 anni invece di 1 mese migrazione 2026_07_18_000001
syncPermissions() nel seeder Un re-seed azzerava i permessi personalizzati dei ruoli RolesAndPermissionsSeeder
campagna_id manipolabile Rientro conteggiato su una campagna arbitraria Livewire/Assegnazioni/Rientra (#[Locked])
pdftoppm senza timeout Un PDF malformato bloccava il worker per sempre TerritorioThumbnailService
Collisione nomi file PDF 1 A e 1-A scrivevano sullo stesso file → dati sovrascritti Jobs/ImportTerritoryPdfFolder
Zip senza limiti Zip bomb → esaurimento memoria TerritorioPdfImportDispatcher
Export XML senza legacy_id Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) Livewire/Settings/XmlExchange (addLegacyIdAttribute)
Migrazioni thumbnail duplicate migrate:rollback falliva a metà rimosse 2 migrazioni ridondanti

Cosa resta aperto

In ordine di priorità.

1. Nessun test automatico

composer.json richiede PHPUnit ma la cartella tests/ non esiste. 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