Files
termanager2/README.md
Francesco Picone 7384a4b39d ++ proclamatori senza email e telefono; il link si copia o si condivide
- Migrazione: tolte le colonne email e telefono dei proclamatori (vuote
  in produzione). Tolti da form, scheda, elenco, XML (un file vecchio si
  importa ignorandoli).
- Invio del link: solo "Copia link" e "Condividi…" (menu di condivisione
  del telefono, navigator.share): il destinatario lo sceglie chi usa
  l'app. Via WhatsApp diretto, canale email, notifica LinkTerritorio,
  componente x-whatsapp.
- Elenco assegnati: "Condividi" al posto di WhatsApp; a condivisione
  fatta il link risulta inviato.
- Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:23 +02:00

974 lines
40 KiB
Markdown

# TerManager2
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali
e registro storico completo.
I dati personali dei proclamatori sono **cifrati nel database**, e ogni
operazione è tracciata in un **audit log** consultabile.
---
## Indice
**Per iniziare**
- [Deploy su Dokploy](#deploy-su-dokploy) — la via consigliata per la produzione
- [Deploy su altri sistemi](#deploy-su-altri-sistemi) — Coolify, Portainer, server con Docker
- [Sviluppo locale](#sviluppo-locale) — compreso come lanciare i [test automatici](#test-automatici)
**Gestione quotidiana**
- [Variabili d'ambiente](#variabili-dambiente)
- [Impostazioni applicative](#impostazioni-applicative)
- [Aggiornamenti](#aggiornamenti)
- [Backup e ripristino](#backup-e-ripristino)
- [Comandi utili](#comandi-utili)
- [Troubleshooting](#troubleshooting)
**Riferimento**
- [Funzionalità](#funzionalità)
- [Ruoli e permessi](#ruoli-e-permessi)
- [Sicurezza e GDPR](#sicurezza-e-gdpr)
- [Modello dati](#modello-dati)
- [Regole di business](#regole-di-business)
- [Architettura](#architettura)
---
## Deploy su Dokploy
### Come funziona
L'immagine Docker è **autosufficiente**: dipendenze PHP e asset frontend sono
compilati durante il build, non all'avvio. Nginx gira nello stesso container di
php-fpm. Un deploy quindi è semplicemente: *build → migrazioni → online*.
Non serve il codice sorgente sul server, non ci sono bind mount, e non viene
eseguito né `composer install` né `npm build` all'avvio.
| Servizio | Ruolo | Esposto |
|----------------|------------------------------------------------|---------|
| `app` | nginx + php-fpm — l'applicazione | via Traefik |
| `queue-worker` | Job in coda (import PDF massivi) | no |
| `scheduler` | Task pianificati (pulizia audit log) | no |
| `mariadb` | Database | no |
| `redis` | Cache, sessioni, code | no |
Solo `app` è raggiungibile dall'esterno. Database e Redis restano su una rete
interna senza rotta verso Internet.
---
### Passo 1 — Crea l'applicazione
Su Dokploy: **Create Application** → tipo **Compose**.
| Campo | Valore |
|---------------|---------------------|
| Repository | il tuo repo Git |
| Branch | `main` |
| Compose Path | `docker-compose.yml`|
### Passo 2 — Genera le credenziali
Sul tuo computer, esegui questi comandi e tieni da parte l'output:
```bash
echo "APP_KEY=base64:$(openssl rand -base64 32)"
echo "DB_PASSWORD=$(openssl rand -base64 24)"
echo "DB_ROOT_PASSWORD=$(openssl rand -base64 24)"
echo "REDIS_PASSWORD=$(openssl rand -base64 24)"
```
> **Conserva `APP_KEY` in un password manager.** I dati personali dei
> proclamatori sono cifrati con questa chiave: se la perdi, non sono più
> leggibili nemmeno avendo il backup del database.
### Passo 3 — Imposta le variabili d'ambiente
Nella sezione **Environment** di Dokploy, incolla questo blocco e sostituisci i
valori:
```bash
# --- Applicazione ---
APP_NAME=TerManager2
APP_ENV=production
APP_DEBUG=false
APP_TIMEZONE=Europe/Rome
# Deve combaciare con l'indirizzo da cui accedi, porta inclusa.
# per IP:porta -> http://IP-DEL-SERVER:8080
# con dominio -> https://territori.tuodominio.it
APP_URL=http://IP-DEL-SERVER:8080
# --- Chiavi e password (dal Passo 2) ---
APP_KEY=base64:...
DB_PASSWORD=...
DB_ROOT_PASSWORD=...
REDIS_PASSWORD=...
# --- Database (mariadb e' il nome del servizio, non cambiarlo) ---
DB_CONNECTION=mysql
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=termanager2
DB_USERNAME=termanager2
# --- Redis (idem: redis e' il nome del servizio) ---
REDIS_HOST=redis
REDIS_PORT=6379
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
# --- Primo amministratore (creato al primo avvio) ---
INITIAL_ADMIN_NAME=Nome Cognome
INITIAL_ADMIN_EMAIL=admin@tuodominio.it
INITIAL_ADMIN_PASSWORD=UnaPasswordSolida
# --- Porte esposte sull'host ---
APP_BIND=0.0.0.0 # 127.0.0.1 se ci metti davanti un reverse proxy
APP_PORT=8080
DB_BIND=127.0.0.1 # database raggiungibile solo via tunnel SSH
DB_PORT_HOST=3306
# --- Sessioni e log ---
# false con accesso HTTP, true solo in HTTPS: vedi .env.example
SESSION_SECURE_COOKIE=false
LOG_CHANNEL=stderr
LOG_LEVEL=warning
# --- Posta (SMTP esterno) ---
MAIL_MAILER=smtp
MAIL_HOST=smtp.tuoprovider.it
MAIL_PORT=587
MAIL_USERNAME=utente
MAIL_PASSWORD=password
MAIL_SCHEME=tls
MAIL_FROM_ADDRESS=noreply@tuodominio.it
```
> `APP_URL` deve corrispondere **esattamente** al dominio che servirai, schema
> `https://` incluso. L'applicazione rifiuta richieste con un `Host` diverso: è
> la protezione contro l'host header injection, ed è la causa più comune di
> "pagina che non si apre" al primo deploy.
### Passo 4 — Assegna il dominio (facoltativo)
Con la configurazione qui sopra l'applicazione risponde gia' su
`http://IP-DEL-SERVER:8080`. Questo passo serve solo se vuoi un dominio con
HTTPS — **consigliato**, perche' altrimenti password e sessioni viaggiano in
chiaro.
Quando lo fai, cambia anche: `APP_URL=https://tuodominio.it`,
`SESSION_SECURE_COOKIE=true` e `APP_BIND=127.0.0.1`.
**Domains** → **Add Domain**:
| Campo | Valore |
|----------------|-----------------------------|
| Service Name | `app` |
| Container Port | `80` |
| Host | il tuo dominio |
| HTTPS | attivo, Let's Encrypt |
Dokploy genera da sé le label Traefik: non vanno scritte nel compose.
### Passo 5 — Deploy
Premi **Deploy**. Il primo avvio esegue migrazioni, seed di ruoli e permessi, e
crea l'amministratore. Richiede in genere uno o due minuti.
Nei log dovresti vedere:
```
[✓] Storage directories ready.
[✓] APP_KEY provided via environment.
[✓] Database reachable.
[✓] Migrations applied.
[✓] Seed completed.
Initial admin created: admin@tuodominio.it
[✓] Caches warmed.
TerManager2 — ready
```
Accedi con le credenziali `INITIAL_ADMIN_*` e **cambia subito la password**.
### Se qualcosa non parte
Lo stack si rifiuta di avviarsi quando manca una variabile essenziale, con un
messaggio esplicito:
| Messaggio nei log | Cosa fare |
|------------------------------------------------------|----------------------------------|
| `required variable DB_PASSWORD is missing a value` | Imposta `DB_PASSWORD` |
| `required variable REDIS_PASSWORD is missing a value` | Imposta `REDIS_PASSWORD` |
| `[✗] APP_URL is not set` | Imposta `APP_URL` |
| `[✗] APP_URL must include the scheme` | Aggiungi `https://` davanti |
È voluto: meglio un errore immediato che un database in produzione con la
password di default.
---
## Deploy su altri sistemi
Lo stack è Docker Compose standard e funziona su **Coolify**, **Portainer**,
**CapRover** o un semplice server con Docker.
Le uniche due particolarità sono:
1. **Il file `.env`.** Dokploy lo genera dalle variabili della sua interfaccia.
Altrove crealo a mano: `cp .env.example .env` e compila i valori.
2. **La rete `dokploy-network`.** In fondo a `docker-compose.yml` c'è una rete
esterna che serve al Traefik di Dokploy per raggiungere il container. Hai
due possibilità:
- **Tenerla**, creandola una volta sola:
`docker network create dokploy-network`
- **Rimuoverla**: elimina `- dokploy-network` dalle reti del servizio `app`
e il blocco `dokploy-network:` in fondo al file.
Poi imposta nel `.env` la porta su cui esporre l'applicazione e avvia:
```bash
# ascolta su tutte le interfacce, per metterci davanti un reverse proxy
echo "APP_PORT=0.0.0.0:8080" >> .env
docker compose up -d --build
docker compose logs -f app
```
Infine metti il tuo reverse proxy (Nginx, Caddy, Traefik) davanti alla porta
`8080`, con certificato TLS.
---
## Sviluppo locale
Si usa lo stesso `docker-compose.yml` della produzione, con il profilo `dev`
che aggiunge Mailpit:
```bash
cp .env.example .env
docker network create dokploy-network # una volta sola
# nel .env, per l'uso locale:
# APP_URL=http://localhost:8080
# APP_ENV=local
# MAIL_MAILER=smtp MAIL_HOST=mailpit MAIL_PORT=1025
# e le tre password a piacere
# (SESSION_SECURE_COOKIE=false e APP_BIND/APP_PORT vanno gia' bene)
docker compose --profile dev up -d --build
```
| Cosa | Dove |
|---------------------|-------------------------------|
| Applicazione | http://localhost:8080 |
| Mailpit (posta test)| http://localhost:8025 |
| Login | quello che hai messo in `INITIAL_ADMIN_*` |
Se la porta 8080 è occupata, cambia `APP_PORT` nel `.env`
(es. `APP_PORT=127.0.0.1:8081`).
### Modifiche al codice senza ricostruire l'immagine
L'immagine è autosufficiente: `vendor/` e `public/build` stanno dentro, quindi
di norma una modifica al codice richiede `docker compose up -d --build`.
Per avere invece le modifiche PHP immediate, crea un
`docker-compose.override.yml` — Docker Compose lo applica da solo, senza
bisogno di alcun flag:
```yaml
services:
app:
environment:
PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1
volumes:
- ./:/var/www/html
# vendor e build restano quelli dell'immagine, non quelli dell'host
- /var/www/html/vendor
- /var/www/html/public/build
```
Il file è già in `.gitignore`, quindi resta solo tuo.
Per lavorare sugli asset frontend con hot reload:
```bash
npm install
npm run dev
```
### Test automatici
La suite gira in un container dedicato: l'immagine di produzione installa le
dipendenze senza quelle di sviluppo, quindi PHPUnit lì dentro non c'è.
```bash
docker compose --profile test run --build --rm test
# una sola classe
docker compose --profile test run --build --rm test --filter BackupArchiveTest
# un solo gruppo
docker compose --profile test run --build --rm test --testsuite Unit
```
Il container esce col codice di PHPUnit, quindi la riga di comando si incatena
come qualsiasi altro comando.
**`--build` non è facoltativo.** Il sorgente non è montato: senza ricostruire,
si eseguono i test dell'ultima immagine costruita, che possono essere di due
modifiche fa. Ometterlo ha senso solo per rilanciare la stessa suite subito
dopo, quando si sa che nulla è cambiato.
Cosa serve sapere:
- **Non serve nient'altro acceso.** `phpunit.xml` impone SQLite in memoria,
cache `array` e code `sync`: la suite passa a stack spento e non tocca né il
database di sviluppo né il volume `storage_data`.
- **`APP_KEY` di test è fissa e scritta in `phpunit.xml`.** Serve perché nome e
cognome dei proclamatori sono cifrati: senza chiave nemmeno un test che non
parla di cifratura riuscirebbe a scrivere una riga. Non ha alcun rapporto con
la chiave di produzione.
- **SQLite non è MariaDB.** Resta un punto che usa SQL specifico di MySQL —
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
provato sullo stack vero, non qui.
La logica JavaScript senza DOM (la ricerca nei campi di scelta,
`resources/js/scelta.js`) ha i suoi test in `tests/js/`, con il test runner di
Node:
```bash
npm run test:js
# oppure, senza Node installato:
docker run --rm -v "$PWD":/app -w /app node:20-alpine node --test tests/js/
```
---
## Variabili d'ambiente
Il container legge la configurazione dall'ambiente di processo (su Dokploy: le
variabili della UI, salvate in `.env`). Il file `docker-compose.yml` **non**
ridichiara le variabili applicative: le prende dal `.env` così com'è.
### Obbligatorie
| Variabile | Esempio | Note |
|---------------------|-------------------------------|-----------------------------------------|
| `APP_URL` | `https://territori.esempio.it`| Deve combaciare col dominio servito |
| `APP_KEY` | `base64:...` | Cifra i dati personali — **da custodire**|
| `DB_PASSWORD` | — | Nessun default |
| `DB_ROOT_PASSWORD` | — | Nessun default |
| `REDIS_PASSWORD` | — | Nessun default |
### Principali facoltative
| Variabile | Default | Descrizione |
|------------------------------|----------------|--------------------------------------|
| `APP_NAME` | `TerManager2` | Nome mostrato nell'interfaccia |
| `APP_ENV` | `production` | `local` in sviluppo |
| `APP_DEBUG` | `false` | **Mai `true` in produzione** |
| `APP_TIMEZONE` | `Europe/Rome` | Fuso orario |
| `LOG_LEVEL` | `warning` | `debug` per diagnosticare |
| `SESSION_SECURE_COOKIE` | `false` | `true` solo servendo in HTTPS |
| `MAX_UPLOAD_MB` | `2048` | Tetto dei file caricati: governa insieme nginx, PHP e la validazione |
| `APP_BIND` | `0.0.0.0` | `127.0.0.1` dietro un reverse proxy |
| `APP_PORT` | `8080` | Porta dell'applicazione sull'host |
| `DB_BIND` | `127.0.0.1` | Interfaccia della porta MariaDB |
| `DB_PORT_HOST` | `3306` | Cambiala se la 3306 e' occupata |
| `INITIAL_ADMIN_EMAIL` | — | Admin creato al primo avvio |
| `INITIAL_ADMIN_PASSWORD` | — | Idem |
| `INITIAL_ADMIN_NAME` | `Amministratore`| Idem |
| `MAIL_MAILER` | `log` | `smtp` per inviare davvero |
| `RUN_MIGRATIONS` | `true` | Migrazioni automatiche all'avvio |
| `RUN_DB_SEED_ON_FIRST_START` | `true` | Seed al primo avvio |
### Da non cambiare
Questi valori sono **nomi di servizi** definiti in `docker-compose.yml`, non
indirizzi di rete. Se li modifichi il container non trova più il database o
Redis:
```dotenv
DB_HOST=mariadb
REDIS_HOST=redis
```
Nemmeno `CACHE_STORE`, `SESSION_DRIVER` e `QUEUE_CONNECTION` andrebbero
spostati altrove: lo stack fornisce Redis e il queue worker consuma la coda
Redis, quindi puntarli su un driver diverso interrompe l'elaborazione dei job.
---
## Impostazioni applicative
Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
non da variabili d'ambiente:
| Impostazione | Descrizione |
|--------------------------------|------------------------------------------------------|
| Nome congregazione | Mostrato nell'intestazione e nei PDF |
| Logo | Immagine nell'intestazione |
| URL pubblico base | Base dei link PDF condivisibili |
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare |
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario |
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare |
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide |
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
| Download PDF visibile | Mostra o nasconde il pulsante di download |
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati |
---
## Aggiornamenti
Push su `main` → **Deploy** su Dokploy. Ad ogni avvio il container:
1. ricrea le directory di storage e risolve `APP_KEY`
2. attende il database
3. applica le migrazioni pendenti
4. salta il seed (già eseguito — il marker è nel volume storage)
5. rigenera le cache di configurazione, rotte e viste
Se le migrazioni falliscono il container **non parte**: preferibile a servire
traffico su uno schema applicato a metà.
Un aggiornamento ordinario non richiede alcun intervento manuale.
---
## Backup e ripristino
### Com'è fatta la pagina *Backup e import*
Cinque zone numerate, in ordine di quanto sono definitive. L'indice in cima
salta su ciascuna evidenziandola:
| # | Zona | A cosa serve |
|---|------|--------------|
| 1 | Backup completo | Crea e scarica il pacchetto `.zip` |
| 2 | Ripristina da backup | Ricarica un pacchetto: **sostituisce** dati e file |
| 3 | Import PDF territori | Associa i PDF ai territori (da archivio ZIP o da cartella) |
| 4 | Dati in formato XML | Esporta/importa i soli dati, converte i dump della vecchia app |
| 5 | Zona pericolosa | Azzera l'installazione |
In fondo alla finestra c'è **una sola console**, comune a tutte le operazioni:
si apre da sé quando qualcosa parte, scorre da sola man mano che arrivano le
righe e riporta sia quello che succede nel browser (file scelto, avanzamento del
caricamento, codici HTTP, timeout) sia quello che succede sul server, in ordine
di tempo e con la sorgente accanto a ogni riga. Il pulsante **Copia** serve
proprio a questo: il primo passo di ogni diagnosi è farsi mandare quel testo.
Se torni indietro a leggere, lo scorrimento automatico si sospende e compare
*Torna in fondo*.
### Dall'applicazione: pacchetto `.zip` (consigliato)
*Backup e import* → **Crea e scarica backup**. Produce un unico `.zip` con
tutti i dati gestionali, gli utenti e tutti i file caricati (PDF dei territori,
miniature, logo). È la via da usare per spostare o rimettere in piedi
un'installazione senza toccare i container.
L'archivio si prepara in background e la console mostra l'avanzamento reale
(file compressi / file totali); il download parte da solo appena è pronto.
Stessa cosa per il ripristino: prima la barra di caricamento del pacchetto,
accanto al pulsante, poi le fasi di lavoro nella console (estrazione, database,
file).
Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i
dati gestionali correnti *e* i file caricati.
> **Dopo un ripristino tutti gli utenti — amministratore compreso — devono
> impostare una nuova password.** Il pacchetto contiene gli hash delle password
> di ogni account: chi lo ha avuto in mano ha potuto tentarne il crack offline,
> quindi le vecchie credenziali non sono più considerate sicure. Al primo
> accesso l'applicazione resta bloccata sulla schermata di cambio password.
Il pacchetto **non** contiene `APP_KEY`: i nomi dei proclamatori sono cifrati
con quella chiave, quindi il ripristino va fatto su un'installazione che ha la
**stessa** `APP_KEY` di quando il backup è stato creato.
Il tetto per l'upload lo decide **`MAX_UPLOAD_MB`** nel `.env` (default: 2048,
cioè 2 GB). Da quel valore l'entrypoint genera `client_max_body_size` per nginx
e `upload_max_filesize`/`post_max_size` per PHP, e la validazione lo rilegge:
un solo numero per tutta la catena.
> Se un caricamento si pianta a una percentuale bassa **senza messaggi**, è
> quasi sempre questo il limite. Superato il tetto nginx risponde `413` dopo
> aver accettato zero byte: il browser si vede chiudere la connessione a metà
> invio e la barra resta ferma. Dalla versione attuale la pagina controlla la
> dimensione *prima* di iniziare e lo dice subito, ma se aggiorni un'installazione
> vecchia il sintomo è quello.
Per alzarlo servono un `MAX_UPLOAD_MB` più grande **e** un redeploy (i limiti si
scrivono all'avvio del container). Alzarlo non costa memoria — nginx accumula il
corpo su disco e PHP lavora su un file temporaneo — ma serve spazio libero nel
volume. Oltre qualche giga conviene comunque il backup dei volumi qui sotto: un
upload lunghissimo da browser è fragile.
> **Se il dominio passa da Cloudflare, comanda Cloudflare.** Sul piano free
> rifiuta i corpi di richiesta oltre **100 MB**, e lo fa in base al
> `Content-Length`: il `413` arriva dopo appena un megabyte spedito, quindi il
> pacchetto **non raggiunge mai nginx** e `MAX_UPLOAD_MB` non sposta nulla.
> C'è anche un tetto di 100 secondi: un caricamento lento può fallire con `524`
> pur restando sotto il limite di dimensione.
>
> Per superarlo: togliere il dominio da Cloudflare (DNS-only), anche solo su un
> sottodominio d'amministrazione; oppure caricare puntando direttamente all'IP
> dell'origine; oppure usare il ripristino dai volumi qui sotto. Il piano
> Business arriva a 200 MB, spesso ancora insufficienti.
>
> La **console** in fondo alla finestra mostra il codice HTTP ricevuto: se
> leggi `413` con un pacchetto sotto `MAX_UPLOAD_MB`, il tetto è a monte.
### Zona pericolosa: azzera tutto
Zona 5, in fondo alla stessa pagina: **Scarica backup e azzera tutto** riporta
l'applicazione allo stato di installazione appena eseguita. Va scritto
`AZZERA TUTTO` per sbloccare il pulsante, e l'operazione procede in due tempi:
1. Viene creato e scaricato un backup completo di tutto ciò che c'è.
2. L'operazione **si ferma e aspetta**, e la richiesta di conferma compare lì
nella zona 5, sotto il pulsante che l'ha avviata: controlli il file
scaricato, digiti la **tua password** e confermi con un secondo clic. Fino a
lì non viene toccato nulla, e puoi annullare. Dopo cinque password sbagliate
l'azzeramento resta bloccato per 5 minuti (l'accesso all'applicazione no).
3. Solo dopo la conferma vengono eliminati territori, proclamatori,
assegnazioni, campagne, zone, tipologie, anni di servizio, il registro
attività e tutti i file caricati; le impostazioni tornano ai default e i
ruoli vengono riseminati.
> **Tutti gli altri utenti vengono eliminati.** Resta solo l'account
> dell'amministratore che ha eseguito l'operazione, con la sua password
> attuale — come un'installazione nuova, che ha un amministratore solo.
> Cancellarli tutti lascerebbe l'applicazione senza nessuno che possa
> accedere fino al riavvio del container.
Il backup scaricato al punto 1 è l'unico modo per tornare indietro.
### Dai volumi: cosa salvare
| Volume | Contenuto | Nel backup |
|----------------|------------------------------------------------------|:----------:|
| `db_data` | Database MariaDB | ✅ |
| `storage_data` | PDF territori, miniature, log, `APP_KEY` di fallback | ✅ |
| `redis_data` | Cache e sessioni — rigenerabili | — |
Più il valore di **`APP_KEY`**. Il database da solo non basta: senza la chiave
i dati personali cifrati non sono leggibili.
### Backup
```bash
# Database
docker exec <container-db> mariadb-dump -u root -p"$DB_ROOT_PASSWORD" \
termanager2 | gzip > termanager2_$(date +%Y%m%d).sql.gz
# File caricati (PDF e miniature)
docker run --rm -v <progetto>_storage_data:/data -v "$PWD":/backup \
alpine tar czf /backup/storage_$(date +%Y%m%d).tar.gz -C /data .
```
Automatizzabile con cron:
```bash
0 2 * * * docker exec <container-db> mariadb-dump -u root -p"$DB_ROOT_PASSWORD" termanager2 | gzip > /backups/termanager2_$(date +\%Y\%m\%d).sql.gz
```
### Ripristino
```bash
gunzip -c termanager2_20260718.sql.gz | \
docker exec -i <container-db> mariadb -u root -p"$DB_ROOT_PASSWORD" termanager2
docker exec <container-app> php artisan migrate --force
docker exec <container-app> php artisan cache:clear
```
Assicurati che `APP_KEY` sia **la stessa** di quando il backup è stato creato.
---
## Comandi utili
Sostituisci `<app>` con il nome del container (lo trovi con `docker ps`).
```bash
# Log in tempo reale
docker logs -f <app>
# Stato delle migrazioni
docker exec -it <app> php artisan migrate:status
# Shell interattiva Laravel
docker exec -it <app> php artisan tinker
# Reimpostare la password di un utente
docker exec -it <app> php artisan tinker --execute="
\$u = App\Models\User::where('email','admin@tuodominio.it')->first();
\$u->password = Hash::make('NuovaPassword');
\$u->save();
"
# Creare un altro amministratore
docker exec -it <app> php artisan app:create-initial-admin \
--name="Nome" --email="mail@dominio.it" --password="Password"
# Pulizia manuale dell'audit log
docker exec -it <app> php artisan audit:cleanup
# Pulizia manuale degli archivi di backup residui (default: più vecchi di 24h)
docker exec -it <app> php artisan backup:cleanup
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
# Svuotare le cache
docker exec -it <app> php artisan optimize:clear
```
Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i
comandi qui sopra servono solo per anticiparle.
---
## Troubleshooting
### Il container `app` resta unhealthy
L'health check interroga `/up`. Guarda i log (`docker logs <app>`): quasi
sempre è il database irraggiungibile o una migrazione fallita.
### Errore 400 o pagina bianca sul dominio
`APP_URL` non corrisponde al dominio servito. Deve combaciare esattamente,
schema `https://` incluso. È la protezione contro l'host header injection.
### La pagina appare senza stile (CSS mancante)
Gli asset sono compilati dentro l'immagine. Se mancano, è fallito lo stage di
build frontend: cerca `npm run build` nel log di build del deploy.
### I job restano in coda
Verifica che `queue-worker` sia healthy e che `REDIS_PASSWORD` sia identica per
tutti i servizi.
### L'import PDF non termina
L'import ha un timeout di 15 minuti; oltre, il job viene marcato failed e i file
temporanei rimossi. Controlla i log del worker per individuare il PDF
responsabile.
### I dati cifrati non sono più leggibili
`APP_KEY` è cambiata. Ripristina il valore originale: non esiste altro modo di
recuperare i dati. Se la chiave è persa definitivamente, i proclamatori vanno
reinseriti (territori, assegnazioni e storico restano intatti — non sono
cifrati).
### Reset completo (solo sviluppo)
```bash
docker compose --profile dev down -v
docker compose --profile dev up -d --build
```
`-v` cancella i volumi: database, Redis e file caricati vengono persi.
---
## Funzionalità
### Dashboard
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
- Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
**da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante
in testata che apre l'elenco territori già filtrato
### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente
- **"Nuovo territorio"** apre il designer delle piantine già in disegno: il
territorio si crea dal confine disegnato (per chi non è amministratore resta
il form)
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
- Badge di stato colorati e indicatore di priorità (manuale o automatica)
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
- Link PDF pubblico condivisibile, a scadenza configurabile
- Soft delete con cestino (ripristino o eliminazione definitiva)
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background
### Proclamatori
- CRUD con nome e cognome **cifrati nel database**
- Conteggio dei territori attualmente assegnati
- **Anonimizzazione GDPR** irreversibile
- Soft delete con cestino
### Assegnazione e rientro
- **Scelta con ricerca** di territorio e proclamatore (anche nel Registro e nel
designer delle piantine): si scrive e compaiono le proposte più vicine —
uguale, inizia con, contiene, errore di battitura ("forse cercavi") — con
le parole in qualsiasi ordine, senza badare a maiuscole e accenti. Campo
vuoto: l'elenco completo. Pensata anche per il telefono
- I territori già assegnati si trovano cercando ("già assegnato a …") ma non
si possono scegliere; l'anteprima della miniatura segue la scelta
- Anno teocratico rilevato automaticamente dalla data
- Validazione: territorio non già assegnato, entrambi attivi
- **Invio del link dopo l'assegnazione**: una finestra chiede il canale —
*Copia link*, oppure *Condividi…* (il menu di condivisione del telefono:
WhatsApp, Telegram, SMS…, dove il browser lo offre) — e se il link è stato
inviato. Il destinatario lo sceglie chi usa l'app dai propri contatti: i
proclamatori non hanno numero né email. Solo dopo la risposta si va avanti;
altrimenti l'assegnazione resta segnalata **"Link ancora da inviare"**, con
"Invia link" per riprendere. Anche dall'elenco dei territori assegnati si
può copiare o condividere il link
- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra
nel browser, senza attese
- Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
compare un avviso di tempo di percorrenza molto alto
- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i
suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima)
### Campagne
- CRUD con date di inizio e fine
- Stato automatico: attiva, futura o conclusa
- Percentuale di percorrenza in tempo reale
- Dettaglio territorio per territorio
### Registro assegnazioni
- Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale
- Colonne: territorio, proclamatore, date, giorni, campagna
### Audit log
- Tracciamento automatico su tutti i modelli
- Eventi di flusso espliciti: assegnazione, rientro, login, logout
- Filtri per utente e tipo evento, con diff prima/dopo
- Pulizia automatica schedulata secondo la retention configurata
### Backup e import
- **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF
territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile
- Barra di avanzamento reale su backup e ripristino (lavoro svolto, non tempo)
- Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti
- **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato,
password dell'amministratore e conferma esplicita prima di procedere
- Import ed export XML dell'intero set di dati gestionali (senza i file)
- Conversione di dump SQL legacy in XML compatibile
- Import massivo dei PDF dei territori da cartella o archivio ZIP
### Zone e tipologie
- Gestione inline: aggiungi, rinomina, attiva/disattiva, elimina
- Non eliminabili se hanno territori associati
---
## Ruoli e permessi
Tre ruoli con permessi granulari (spatie/laravel-permission):
| Permesso | Amministratore | Assistente | Operatore |
|-----------------------|:--------------:|:----------:|:---------:|
| `settings.manage` | ✅ | | |
| `territori.manage` | ✅ | | |
| `proclamatori.manage` | ✅ | ✅ | |
| `campagne.manage` | ✅ | ✅ | |
| `registro.view` | ✅ | ✅ | |
| `registro.export` | ✅ | | |
| `territori.assign` | ✅ | ✅ | ✅ |
| `territori.return` | ✅ | ✅ | ✅ |
| `audit.view` | ✅ | | |
| `audit.export` | ✅ | | |
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
---
## Sicurezza e GDPR
### Sicurezza
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
AES-256-CBC tramite `APP_KEY`
- **Password**: hash bcrypt
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
richieste Livewire
- **CSRF**: token su tutti i form
- **Rate limiting**: sul login
- **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL`
- **Scheme forwarding**: lo schema è deciso dal server, non dall'header del
client, per non falsare la validazione dei link firmati
- **Security headers**: `X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
restituisce 404
### GDPR
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
cambiare — congregazione, conservazione del log, scadenza dei link,
download del PDF, server di posta, durata della sessione, indirizzo e HTTPS,
ruoli con i loro permessi, quanti dati sono conservati — si leggono al
momento da impostazioni e configurazione (`App\Livewire\Privacy`)
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e
stato. Niente telefono né email: il link lo manda chi assegna, scegliendo il
destinatario dai propri contatti, e l'app non vede quei contatti
- **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB
- **Audit trail**: registro completo delle operazioni
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
in chiaro
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
in un luogo protetto e cancellati quando non servono
---
## Modello dati
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
| `settings` | Configurazione singleton |
| `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
| `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`) |
| `activity_log` | Audit trail |
**Vincoli e indici principali**
- `territori.numero` e `anni_teocratici.label` — univoci
- `assegnazioni(territorio_id, returned_at)` — indice composito, è la ricerca
più frequente dell'applicazione
- `assegnazioni(proclamatore_id, returned_at)`, `assegnazioni(assigned_at)`
- `territori(attivo, deleted_at)`, `proclamatori(deleted_at)`
- Foreign key con `RESTRICT` su territorio e proclamatore, a protezione
dell'integrità dello storico
---
## Regole di business
### Anno teocratico
Va da **settembre ad agosto**: da settembre in poi l'anno è `corrente/corrente+1`,
prima è `corrente-1/corrente`. Viene creato automaticamente quando serve.
L'assegnazione conta per l'anno determinato dalla **data di assegnazione**.
### Stato del territorio
Calcolato dinamicamente, non memorizzato:
| Stato | Condizione |
|----------------|-----------------------------------------------------|
| `in_reparto` | Attivo, nessuna assegnazione aperta |
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
| `inattivo` | Contrassegnato come non attivo |
### Priorità
Un territorio è prioritario se lo è per **flag manuale** o perché non viene
assegnato da più mesi della soglia configurata. La soglia automatica **vince
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
quando la soglia è scattata.
### Percentuale di percorrenza delle campagne
- **Denominatore**: tutti i territori attivi
- **Numeratore**: i territori attivi distinti con almeno un'assegnazione
collegata alla campagna e contrassegnata come conteggiata al rientro (un
territorio percorso due volte conta una volta sola)
Al rientro la campagna viene proposta se il territorio era fuori almeno in
parte del periodo: assegnato durante la campagna, rientrato durante la
campagna, oppure fuori per tutta la sua durata. Se sia stato davvero percorso
lo decide l'operatore con la spunta. Se più campagne corrispondono viene
proposta la più recente. I territori assegnati durante la campagna e non
ancora rientrati compaiono nel dettaglio come "in corso".
---
## Architettura
### Stack
| Componente | Tecnologia |
|-------------------|--------------------------------------------|
| Framework | Laravel 11 |
| PHP | 8.3 (FPM) |
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
| Database | MariaDB 11 |
| Cache/Sessioni/Code| Redis 7 |
| Web server | Nginx, nello stesso container di php-fpm |
| RBAC | spatie/laravel-permission |
| Audit trail | spatie/laravel-activitylog |
| PDF | barryvdh/laravel-dompdf + poppler-utils |
| Build asset | Vite 6 (in fase di build immagine) |
### Struttura del progetto
```
TerManager2/
├── app/
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin
│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
│ ├── Http/Middleware/ # RequirePasswordChange
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
│ ├── Livewire/ # Tutti i componenti di interfaccia
│ ├── Models/ # 9 modelli Eloquent
│ ├── Providers/ # AppServiceProvider
│ └── Services/ # Miniature PDF, stato import, archivio backup
├── bootstrap/app.php # Middleware, proxy e host fidati
├── config/ # Configurazione Laravel
├── database/
│ ├── migrations/
│ └── seeders/ # Ruoli, permessi, dati di sviluppo
├── docker/
│ ├── nginx/default.conf # Vhost
│ ├── php/
│ │ ├── Dockerfile # Build multi-stage (runtime = ultimo stage)
│ │ ├── entrypoint.sh # Avvio: chiave, migrazioni, cache
│ │ ├── queue-entrypoint.sh # Avvio worker e scheduler
│ │ ├── php.ini
│ │ └── www.conf
│ └── supervisor/ # nginx + php-fpm nello stesso container
├── resources/
│ ├── css/app.css # Sistema di pulsanti, tabelle responsive, console
│ ├── js/
│ └── views/
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
│ └── livewire/ # Una vista per componente
├── routes/web.php # Rotte con i relativi permessi
├── tests/ # Suite PHPUnit (profilo compose "test")
├── phpunit.xml # Configurazione e ambiente dei test
├── docker-compose.yml # Unico, produzione e sviluppo
├── .env.example # Tutta la configurazione
├── HANDOFF.md # Stato del progetto per chi subentra
└── README.md
```
### Perché nginx e php-fpm nello stesso container
Con container separati, nginx avrebbe bisogno di una propria copia di `public/`
e del volume storage solo per servire i file statici. Per un'applicazione di
questa scala è complessità senza beneficio: un solo container significa un solo
health check, un solo target per il proxy e nessun volume condiviso da tenere
allineato.
---
## Licenza
Proprietaria. Tutti i diritti riservati.