Files
termanager2/README.md
2026-07-29 18:33:30 +02:00

854 lines
33 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)
**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
```
---
## 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
### 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 pagina 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,
poi quella delle fasi di lavoro (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** nella scheda di ripristino mostra il codice HTTP ricevuto: se
> leggi `413` con un pacchetto sotto `MAX_UPLOAD_MB`, il tetto è a monte.
### Zona pericolosa: azzera tutto
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**: 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**
### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente
- Filtri per zona, tipologia e stato
- 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
- Selezione territorio disponibile e proclamatore attivo
- Anno teocratico rilevato automaticamente dalla data
- Validazione: territorio non già assegnato, entrambi attivi
- Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene
chiesto se conteggiarlo
### 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
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e stato
- **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
---
## 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 |
| `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 |
| `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 assegnati nel periodo della campagna
- **Numeratore**: le assegnazioni contrassegnate come conteggiate
---
## 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
│ │ ├── 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, JS, viste Blade
├── routes/web.php # Rotte con i relativi permessi
├── 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.