From cf78550516743c67a4644d5601a9a7a4873df6ae Mon Sep 17 00:00:00 2001 From: francesco Date: Sun, 19 Jul 2026 16:49:10 +0200 Subject: [PATCH] Ristruttura il deploy: immagine autosufficiente per Dokploy Il Dockerfile compilava l'applicazione in /app-src mentre 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, questo significava build a runtime a ogni deploy. Ora vendor/ e public/build sono compilati in stage separati e copiati nell'immagine finale. I container di produzione non montano il sorgente e non eseguono build all'avvio: un redeploy e' build, migrazioni, online. - Dockerfile multi-stage (composer, node, runtime) - nginx e php-fpm nello stesso container via supervisor: con container separati nginx avrebbe bisogno di una copia di public/ e del volume storage solo per servire file statici - clear_env = no in www.conf, senza cui php-fpm nasconde a Laravel tutte le variabili d'ambiente - configurazione da ambiente di processo invece di .env riscritto con sed; il compose fissa solo i valori dettati dalla topologia - fail-fast su APP_URL, DB_PASSWORD, DB_ROOT_PASSWORD, REDIS_PASSWORD - servizio scheduler dedicato: schedule:work non veniva mai eseguito, quindi la pulizia dell'audit log non e' mai partita - solo il container app esegue le migrazioni, per evitare che due container in gara lascino uno schema applicato a meta' - healthcheck separati per worker e scheduler, che non espongono HTTP - docker-compose.dev.yml per lo sviluppo locale - documentazione unificata in README.md e HANDOFF.md Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 32 +- .env.example | 88 +- HANDOFF.md | 249 ++++++ README.md | 1263 +++++++++++++--------------- TerManager2_v2.md | 640 -------------- docker-compose.dev.yml | 149 ++++ docker-compose.yml | 228 +++-- docker/nginx/Dockerfile | 2 - docker/nginx/default.conf | 84 +- docker/php/Dockerfile | 158 ++-- docker/php/entrypoint.sh | 367 ++++---- docker/php/queue-entrypoint.sh | 40 + docker/php/www.conf | 17 +- docker/supervisor/supervisord.conf | 38 + 14 files changed, 1657 insertions(+), 1698 deletions(-) create mode 100644 HANDOFF.md delete mode 100644 TerManager2_v2.md create mode 100644 docker-compose.dev.yml delete mode 100644 docker/nginx/Dockerfile create mode 100644 docker/php/queue-entrypoint.sh create mode 100644 docker/supervisor/supervisord.conf diff --git a/.dockerignore b/.dockerignore index 69179c7..8b8a1a4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,22 +1,46 @@ +# Keep the build context small and, more importantly, keep host state out of +# the image. vendor/ and public/build are produced by dedicated build stages — +# copying a host-built copy in would shadow them with whatever happened to be +# on the developer's machine. + .git .gitignore +.github + +# Never bake secrets into an image layer. .env +.env.* +!.env.example + vendor node_modules + storage/logs/* storage/framework/cache/* storage/framework/sessions/* storage/framework/views/* +storage/app/public/territori-pdf/* +storage/app/public/territori-thumbnails/* +storage/app/livewire-tmp/* +storage/app/.app_key +storage/app/.db_seeded bootstrap/cache/* + public/build public/storage +public/hot + db_data redis_data + docker-compose.yml +docker-compose.dev.yml docker-compose.override.yml -README.md -TerManager2_v2.md -.idea -.vscode + +*.md +*.sql *.swp *.swo +.idea +.vscode +.phpunit.result.cache diff --git a/.env.example b/.env.example index 99aff95..60deb9d 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,88 @@ -APP_NAME=TerManager2 -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=Europe/Rome -APP_URL=https://demo-termanager.pyconetwork.it -ASSET_URL=https://demo-termanager.pyconetwork.it +# ============================================================================= +# TerManager2 — environment template +# +# LOCAL DEVELOPMENT: copy to .env and adjust. +# cp .env.example .env && docker compose -f docker-compose.dev.yml up -d +# +# PRODUCTION (Dokploy): do NOT ship a .env file. Set these as environment +# variables in the Dokploy UI instead — the container reads them directly. +# Variables marked [REQUIRED] have no safe default and the stack will refuse +# to start without them. +# ============================================================================= -APP_PORT=8080 -SEED_DEV_DATA=false -RUN_DB_SEED_ON_FIRST_START=true -ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true -INITIAL_ADMIN_NAME= +APP_NAME=TerManager2 +APP_ENV=production +APP_DEBUG=false +APP_TIMEZONE=Europe/Rome + +# [REQUIRED] Generate with: php artisan key:generate --show +# Proclamatori personal data is encrypted with this key. If it changes or is +# lost, that data becomes permanently unreadable. Back it up. +APP_KEY= + +# [REQUIRED] Public URL, including scheme. Used for links in PDFs and emails, +# and to validate the incoming Host header. +APP_URL=https://territori.example.org + +LOG_CHANNEL=stderr +LOG_LEVEL=warning + +# ----------------------------------------------------------------------------- +# First-run bootstrap +# ----------------------------------------------------------------------------- +# The admin account created on first deploy. Change the password after login. +INITIAL_ADMIN_NAME=Amministratore INITIAL_ADMIN_EMAIL= INITIAL_ADMIN_PASSWORD= +# Leave these at their defaults; they exist as escape hatches. +RUN_MIGRATIONS=true +RUN_DB_SEED_ON_FIRST_START=true +ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true + +# ----------------------------------------------------------------------------- +# Database — hostnames are fixed by docker-compose.yml +# ----------------------------------------------------------------------------- DB_CONNECTION=mysql DB_HOST=mariadb DB_PORT=3306 DB_DATABASE=termanager2 DB_USERNAME=termanager2 -DB_PASSWORD=secret -DB_ROOT_PASSWORD=rootsecret +# [REQUIRED] No defaults on purpose — generate with: openssl rand -base64 24 +DB_PASSWORD= +DB_ROOT_PASSWORD= + +# ----------------------------------------------------------------------------- +# Redis (cache, sessions, queue) +# ----------------------------------------------------------------------------- REDIS_HOST=redis -REDIS_PASSWORD=redissecret REDIS_PORT=6379 +# [REQUIRED] openssl rand -base64 24 +REDIS_PASSWORD= CACHE_STORE=redis SESSION_DRIVER=redis +SESSION_SECURE_COOKIE=true QUEUE_CONNECTION=redis +# ----------------------------------------------------------------------------- +# Mail — external SMTP. Use MAIL_MAILER=log to disable delivery entirely. +# ----------------------------------------------------------------------------- MAIL_MAILER=smtp -MAIL_HOST=mailpit -MAIL_PORT=1025 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="noreply@termanager2.local" +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SCHEME=tls +MAIL_FROM_ADDRESS=noreply@example.org MAIL_FROM_NAME="${APP_NAME}" +# ----------------------------------------------------------------------------- +# Local development only — ignored in production +# ----------------------------------------------------------------------------- +# Host port for docker-compose.dev.yml. In production Traefik handles routing +# and no port is published. +APP_PORT=8080 MAILPIT_UI_PORT=8025 +SEED_DEV_DATA=false diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..3cad02f --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,249 @@ +# Handoff — TerManager2 + +Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso +quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non +può dare: perché le cose sono come sono, e cosa resta aperto. + +Ultimo aggiornamento: **18 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 | ✅ `docker-compose.dev.yml` | +| Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) | + +--- + +## Da dove veniamo + +Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava +l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato +solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.** +L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro +il container. Su Dokploy, senza sorgente sull'host, significava build a runtime +a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione. + +Da qui la ristrutturazione descritta sotto. + +--- + +## Decisioni architetturali + +Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il +contesto per ridiscuterla è qui. + +### L'immagine è autosufficiente + +`vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e +copiati nell'immagine finale. I container di produzione **non montano il +sorgente** e non eseguono build all'avvio. + +Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend, +serve un rebuild dell'immagine, non un semplice restart. + +### nginx e php-fpm nello stesso container + +Con container separati, nginx avrebbe bisogno di una copia di `public/` e del +volume storage solo per servire file statici. Per questa scala è complessità +senza beneficio. Sono coordinati da supervisor +(`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due +muore definitivamente — altrimenti resterebbe "up" senza servire nulla. + +### La configurazione arriva dall'ambiente, non da `.env` riscritto + +Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel +legge direttamente l'ambiente di processo, e `docker-compose.yml` usa +`env_file` senza ridichiarare le variabili. + +Fissati nel compose solo i valori che sono una proprietà *della topologia* +(`DB_HOST: mariadb`, `CACHE_STORE: redis`, `LOG_CHANNEL: stderr`, …). Questi +**vincono** su quanto scritto nel `.env`, così un file copiato da una macchina +di sviluppo non rompe il container. + +Nota: perché questo 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`. + +### 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` | +| 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. + +--- + +## Come verificare che tutto funzioni + +Dopo una modifica sostanziale, questo giro copre i percorsi critici: + +Si usa il compose di **sviluppo**: quello di produzione richiede la rete +esterna `dokploy-network` e non pubblica porte sull'host, quindi non è +verificabile in locale così com'è. + +```bash +cp .env.example .env # se non c'è già + +# 1. L'immagine builda +docker build -f docker/php/Dockerfile -t termanager2-check . + +# 2. Stack pulito da zero +docker compose -f docker-compose.dev.yml down -v +docker compose -f docker-compose.dev.yml up -d --build +docker compose -f docker-compose.dev.yml 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 -f docker-compose.dev.yml up -d --force-recreate app +docker compose -f docker-compose.dev.yml logs app | grep -E "Migrations|Seed" +# atteso: "Migrations applied" + "Seed already applied" + +# 5. La cifratura funziona +docker compose -f docker-compose.dev.yml 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'; +" +``` + +Per provare davvero il compose di produzione serve creare la rete +(`docker network create dokploy-network`) e aggiungere un override che pubblichi +la porta 80 del servizio `app`. + +--- + +## Documentazione + +| Documento | Contenuto | +|------------------------------------|--------------------------------------------| +| [README.md](README.md) | Deploy, configurazione, uso, riferimento | +| `HANDOFF.md` (questo file) | Stato, decisioni, debito tecnico | + +La specifica funzionale originale (`TerManager2_v2.md`) è stata rimossa dal +working tree: descriveva funzionalità poi cambiate — in particolare un wizard +di primo avvio successivamente eliminato in favore della pagina Impostazioni — +e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide +(modello dati, regole di business, RBAC) sono confluite nel README. + +Resta consultabile nella storia del repository: + +```bash +git show 701f479:TerManager2_v2.md +``` diff --git a/README.md b/README.md index 3193c90..5219bf9 100644 --- a/README.md +++ b/README.md @@ -1,759 +1,694 @@ # TerManager2 -Applicazione web per la **gestione dell'assegnazione e rientro di territori** (cartoline), statistiche di percorrenza e campagne speciali per congregazioni. +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 -- [Panoramica](#panoramica) -- [Stack tecnologico](#stack-tecnologico) -- [Requisiti di sistema](#requisiti-di-sistema) -- [Installazione da zero](#installazione-da-zero) -- [Configurazione](#configurazione) -- [Migrazione su nuovo server](#migrazione-su-nuovo-server) +**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) -- [Struttura del progetto](#struttura-del-progetto) -- [Funzionalità principali](#funzionalità-principali) -- [Ruoli e permessi (RBAC)](#ruoli-e-permessi-rbac) + +**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) -- [Produzione](#produzione) -- [Licenza](#licenza) +- [Architettura](#architettura) --- -## Panoramica +## Deploy su Dokploy -**TerManager2** gestisce: +### Come funziona -- **Territori** (cartoline) con PDF allegato, stato dinamico (in reparto / assegnato / da rientrare / prioritario / inattivo), zona e tipologia. -- **Proclamatori** (assegnatari) con dati personali **cifrati a livello di colonna** nel database (AES-256-CBC tramite `APP_KEY`). -- **Assegnazioni** con data assegnazione/rientro, anno teocratico, conteggio opzionale in campagna. -- **Campagne** (periodi speciali) con percentuali di percorrenza in tempo reale. -- **Dashboard** con media mensile di percorrenza, campagna attiva, e tre liste rapide (da assegnare, prioritari, da rientrare). -- **Registro** delle assegnazioni filtrabile per anno teocratico, zona, stato. -- **Audit Log** completo di tutte le azioni utente con diff before/after. +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. --- -## Stack tecnologico +### Passo 1 — Crea l'applicazione -| Componente | Tecnologia | -|------------------|-----------------------------------------------| -| **Framework** | Laravel 11.x | -| **PHP** | 8.3-FPM | -| **UI** | Livewire 3.5 + TailwindCSS 4.0 + Alpine.js | -| **Database** | MariaDB 11 | -| **Cache/Session**| Redis 7 (Alpine) | -| **Web Server** | Nginx 1.25 (Alpine) | -| **Auth** | Laravel Breeze 2.0 | -| **RBAC** | spatie/laravel-permission 6.4 | -| **Audit Trail** | spatie/laravel-activitylog 4.8 | -| **Asset Build** | Vite 6.0 + @tailwindcss/vite | -| **Node.js** | 20 LTS (nel container PHP) | -| **Mail (dev)** | Mailpit | -| **Container** | Docker Compose (6 servizi) | +Su Dokploy: **Create Application** → tipo **Compose**. ---- +| Campo | Valore | +|---------------|---------------------| +| Repository | il tuo repo Git | +| Branch | `main` | +| Compose Path | `docker-compose.yml`| -## Requisiti di sistema +### Passo 2 — Genera le credenziali -- **Docker** >= 24.0 e **Docker Compose** >= 2.20 -- **Git** per clonare il repository -- **Porte libere** (configurabili nel `.env`): - -| Porta | Servizio | Variabile `.env` | -|---------|-------------------------|-------------------| -| `8080` | Applicazione web (Nginx)| `APP_PORT` | -| `1025` | SMTP Mailpit | `MAIL_PORT` | -| `8025` | UI Mailpit | `MAILPIT_UI_PORT` | - -> MariaDB (`3306`) e Redis (`6379`) non espongono porte sull'host: sono accessibili solo dalla rete interna Docker. - ---- - -## Installazione da zero - -### Passo 1 — Clona il repository +Sul tuo computer, esegui questi comandi e tieni da parte l'output: ```bash -git clone termanager2 -cd termanager2 +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)" ``` -### Passo 2 — Crea il file `.env` +> **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_URL=https://territori.tuodominio.it +APP_TIMEZONE=Europe/Rome + +# --- Chiavi e password (dal Passo 2) --- +APP_KEY=base64:... +DB_PASSWORD=... +DB_ROOT_PASSWORD=... +REDIS_PASSWORD=... + +# --- Database --- +DB_DATABASE=termanager2 +DB_USERNAME=termanager2 + +# --- Primo amministratore (creato al primo avvio) --- +INITIAL_ADMIN_NAME=Nome Cognome +INITIAL_ADMIN_EMAIL=admin@tuodominio.it +INITIAL_ADMIN_PASSWORD=UnaPasswordSolida + +# --- Sessioni --- +SESSION_SECURE_COOKIE=true + +# --- 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 + +**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. **La rete `dokploy-network`.** In fondo a `docker-compose.yml` c'è una rete + esterna che serve al Traefik di Dokploy per raggiungere il container. Su + altri sistemi rimuovila e pubblica una porta: + + ```yaml + services: + app: + ports: + - "8080:80" + networks: + - internal # rimuovi "- dokploy-network" + + networks: + internal: + driver: bridge + # rimuovi il blocco dokploy-network + ``` + + Poi metti il tuo reverse proxy (Nginx, Caddy, Traefik) davanti alla porta + `8080`, con certificato TLS. + +2. **Il file `.env`.** Dokploy lo genera dalle variabili della sua interfaccia. + Altrove crealo a mano nella cartella del progetto, con lo stesso contenuto + del [Passo 3](#passo-3--imposta-le-variabili-dambiente). + +Dopodiché: + +```bash +docker compose up -d --build +docker compose logs -f app +``` + +--- + +## Sviluppo locale ```bash cp .env.example .env +docker compose -f docker-compose.dev.yml up -d --build ``` -### Passo 3 — Configura le variabili obbligatorie +| Cosa | Dove | +|---------------------|-------------------------------| +| Applicazione | http://localhost:8080 | +| Mailpit (posta test)| http://localhost:8025 | +| Login | `admin@termanager2.local` / `password` | -Apri `.env` con un editor e imposta **almeno** queste variabili: +A differenza della produzione, l'ambiente di sviluppo monta il codice sorgente +in bind mount — le modifiche PHP sono immediate — e pubblica le porte +sull'host. + +Se la porta 8080 è occupata: ```bash -nano .env +APP_PORT=8081 docker compose -f docker-compose.dev.yml up -d ``` -```dotenv -# --- Credenziali admin iniziale (OBBLIGATORIE al primo avvio) --- -INITIAL_ADMIN_NAME="Mario Rossi" -INITIAL_ADMIN_EMAIL=admin@esempio.it -INITIAL_ADMIN_PASSWORD=UnaPasswordSicura123 - -# --- URL dell'applicazione (adatta al tuo dominio/IP) --- -APP_URL=http://localhost:8080 -ASSET_URL=http://localhost:8080 - -# --- Password database (cambia i default in produzione!) --- -DB_PASSWORD=una_password_sicura -DB_ROOT_PASSWORD=una_root_password_sicura -REDIS_PASSWORD=una_redis_password_sicura -``` - -> **Importante**: `INITIAL_ADMIN_NAME`, `INITIAL_ADMIN_EMAIL` e `INITIAL_ADMIN_PASSWORD` sono obbligatorie. -> Se mancano e il database è vuoto, il container si avvia ma mostra un warning e non potrai accedere. - -### Passo 4 — Imposta i permessi - -Il container PHP gira con UID/GID `1000`. I file devono appartenere a questo utente: +Per lavorare sugli asset frontend con hot reload: ```bash -# Crea le cartelle necessarie -mkdir -p storage/app/public \ - storage/framework/cache/data \ - storage/framework/sessions \ - storage/framework/views \ - storage/logs \ - bootstrap/cache - -# Imposta proprietario e permessi -sudo chown -R 1000:1000 . -chmod -R 775 storage bootstrap/cache -``` - -### Passo 5 — Avvia i container (primo avvio) - -```bash -docker compose up -d --build -``` - -Questo comando: -1. **Costruisce** le immagini Docker (PHP + Nginx) -2. **Avvia** 6 servizi: `app`, `nginx`, `queue-worker`, `mariadb`, `redis`, `mailpit` -3. L'entrypoint del container `app` esegue automaticamente: - - Copia `.env.example` → `.env` (se mancante) - - Installa le dipendenze Composer (`vendor/`) - - Genera `APP_KEY` (se vuota) e la salva in `storage/app/.app_key` - - Installa le dipendenze NPM (`node_modules/`) - - Compila gli asset frontend CSS/JS (`public/build/`) - - Crea il symlink `public/storage` → `storage/app/public` - - Esegue le migrazioni database - - Esegue il seed iniziale (ruoli e permessi) - - Crea l'account admin iniziale - - Mette in cache config, routes e views - -### Passo 6 — Verifica che tutto funzioni - -```bash -# Controlla che tutti i container siano "healthy" -docker compose ps -``` - -Output atteso — tutti i container devono essere `Up` (e `healthy` dove previsto): - -``` -NAME STATUS PORTS -termanager2_app Up (healthy) 9000/tcp -termanager2_nginx Up 0.0.0.0:8080->80/tcp -termanager2_queue Up 9000/tcp -termanager2_db Up (healthy) 3306/tcp -termanager2_redis Up (healthy) 6379/tcp -termanager2_mail Up 0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp -``` - -Se un container non è healthy, controlla i log: - -```bash -# Log del container app (il più importante) -docker compose logs app --tail=100 - -# Log di tutti i container -docker compose logs --tail=50 -``` - -### Passo 7 — Accedi all'applicazione - -Apri il browser su: **http://localhost:8080** (o la porta configurata in `APP_PORT`) - -Accedi con le credenziali impostate in `INITIAL_ADMIN_EMAIL` e `INITIAL_ADMIN_PASSWORD`. - ---- - -## Configurazione - -### Variabili d'ambiente principali (`.env`) - -| Variabile | Default | Descrizione | -|-----------------------|----------------------|------------------------------------------| -| `APP_KEY` | (auto-generata) | Chiave AES-256 per cifratura dati. **Mai condividere.** Viene generata automaticamente al primo avvio | -| `APP_URL` | da `.env.example` | URL completo dell'applicazione (es. `https://miodominio.it`) | -| `ASSET_URL` | da `.env.example` | URL base per gli asset CSS/JS (normalmente uguale a `APP_URL`) | -| `APP_PORT` | `8080` | Porta host su cui Nginx espone l'app | -| `APP_ENV` | `local` | Ambiente: `local` (sviluppo) o `production` | -| `APP_DEBUG` | `true` | Mostra errori dettagliati. **Impostare `false` in produzione** | -| `SEED_DEV_DATA` | `false` | Se `true`, il seed include dati demo di test | -| `RUN_DB_SEED_ON_FIRST_START` | `true` | Se `true`, esegue il seed automatico solo al primo avvio | -| `ENSURE_INITIAL_ADMIN_ON_EMPTY_DB` | `true` | Se `true`, crea admin iniziale quando non esistono utenti | -| `INITIAL_ADMIN_NAME` | (vuoto) | Nome dell'admin iniziale — **obbligatorio al primo avvio** | -| `INITIAL_ADMIN_EMAIL` | (vuoto) | Email dell'admin iniziale — **obbligatorio al primo avvio** | -| `INITIAL_ADMIN_PASSWORD` | (vuoto) | Password dell'admin iniziale (min 8 caratteri) — **obbligatorio al primo avvio** | -| `DB_DATABASE` | `termanager2` | Nome database MariaDB | -| `DB_USERNAME` | `termanager2` | Utente database | -| `DB_PASSWORD` | `secret` | Password database — **cambiare in produzione** | -| `DB_ROOT_PASSWORD` | `rootsecret` | Password root MariaDB — **cambiare in produzione** | -| `REDIS_PASSWORD` | `redissecret` | Password Redis — **cambiare in produzione** | -| `MAIL_PORT` | `1025` | Porta SMTP (Mailpit in dev, SMTP reale in prod) | -| `MAILPIT_UI_PORT` | `8025` | Porta UI Mailpit per debug email | - -### Configurazione applicativa - -Dopo il primo accesso, la configurazione si gestisce dalla sezione **Impostazioni** nel menu (solo Amministratore): - -- Nome congregazione -- Soglia mesi per priorità automatica territori -- Soglia giorni per "da rientrare" -- Retention giorni audit log - -### Credenziali iniziali - -Le credenziali admin non sono hardcoded. Al primo avvio devi impostare nel `.env`: - -- `INITIAL_ADMIN_NAME` -- `INITIAL_ADMIN_EMAIL` -- `INITIAL_ADMIN_PASSWORD` - -Se il database è vuoto e queste variabili non sono valorizzate, il container `app` mostra un warning e non verrà creato l'account admin. - ---- - -## Migrazione su nuovo server - -Quando sposti TerManager2 su un nuovo server, segui questa procedura: - -### 1. Copia i file sul nuovo server - -```bash -# Dal vecchio server: crea un archivio (escludi vendor, node_modules e volumi Docker) -tar czf termanager2-backup.tar.gz \ - --exclude='vendor' \ - --exclude='node_modules' \ - --exclude='public/build' \ - --exclude='.git' \ - termanager2/ - -# Copia sul nuovo server -scp termanager2-backup.tar.gz utente@nuovo-server:/home/utente/Docker/ - -# Sul nuovo server: estrai -cd /home/utente/Docker -tar xzf termanager2-backup.tar.gz -cd termanager2 -``` - -### 2. Verifica il file `.env` - -```bash -# Controlla che .env esista e contenga i valori corretti -cat .env - -# Adatta APP_URL e ASSET_URL al nuovo dominio/IP -nano .env -``` - -> **Critico**: se il nuovo server ha un URL/IP diverso, aggiorna `APP_URL` e `ASSET_URL`. - -### 3. Imposta i permessi - -```bash -sudo chown -R 1000:1000 . -chmod -R 775 storage bootstrap/cache -``` - -### 4. Ricostruisci e avvia - -```bash -docker compose up -d --build -``` - -### 5. Forza la ricompilazione degli asset - -Se la pagina appare senza stile (CSS mancante), ricompila gli asset: - -```bash -# Ricompila CSS e JS dentro il container -docker compose exec -u root app bash -c "npm install --no-audit --no-fund && npm run build" - -# Correggi i permessi dei file generati -docker compose exec -u root app chown -R 1000:1000 public/build node_modules -``` - -### 6. (Opzionale) Ripristina il database - -Se hai un dump SQL dal vecchio server: - -```bash -# Copia il dump nel container MariaDB -docker cp backup.sql termanager2_db:/tmp/backup.sql - -# Importa il dump -docker compose exec mariadb mysql -u root -p"$(grep DB_ROOT_PASSWORD .env | cut -d= -f2)" termanager2 < /tmp/backup.sql - -# Esegui le eventuali migrazioni mancanti -docker compose exec app php artisan migrate --force - -# Pulisci la cache -docker compose exec app php artisan optimize:clear +npm install +npm run dev ``` --- -## Struttura del progetto +## Variabili d'ambiente -``` -TerManager2/ -├── app/ -│ ├── Console/Commands/ # AuditCleanup (pulizia log schedulata) -│ ├── Http/Middleware/ # Middleware HTTP applicativi -│ ├── Livewire/ -│ │ ├── Assegnazioni/ # Assegna, Rientra -│ │ ├── Auth/ # Login -│ │ ├── Campagne/ # Index, Create, Edit, Show -│ │ ├── Proclamatori/ # Index, Create, Edit, Show, Cestino -│ │ ├── Settings/ # SettingsEdit, ZoneIndex, TipologieIndex -│ │ ├── Territori/ # Index, Create, Edit, Show, Cestino -│ │ ├── AuditLog.php # Log attività (filtri, diff) -│ │ ├── Home.php # Dashboard -│ │ └── Registro.php # Registro assegnazioni -│ ├── Models/ # 8 modelli Eloquent -│ └── Providers/ # AppServiceProvider (Gate admin) -├── bootstrap/ # app.php (middleware), providers.php -├── config/ # app, auth, database, session -├── database/ -│ ├── migrations/ # 10 migrazioni ordinate -│ └── seeders/ # Roles, Dev, Database seeders -├── docker/ -│ ├── nginx/default.conf # Vhost con security headers -│ └── php/ -│ ├── Dockerfile # PHP 8.3-FPM + estensioni + Composer + Node 20 -│ └── php.ini # 64M upload, opcache, Europe/Rome -├── public/index.php # Entry point Laravel -├── resources/ -│ ├── css/app.css # TailwindCSS 4 import -│ ├── js/ # app.js, bootstrap.js (Axios) -│ └── views/ -│ ├── components/layouts/ # app.blade.php (sidebar), guest.blade.php -│ └── livewire/ # Tutte le viste dei componenti -├── routes/ -│ ├── web.php # Tutte le rotte con permessi -│ └── console.php # Schedule audit:cleanup daily -├── .env.example # Template configurazione -├── artisan # CLI Laravel -├── composer.json # Dipendenze PHP -├── docker-compose.yml # 5 servizi (app, nginx, mariadb, redis, mailpit) -├── package.json # Dipendenze Node (Vite, TailwindCSS) -└── vite.config.js # Vite + Laravel plugin + TailwindCSS plugin +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` | `true` | `false` solo su HTTP locale | +| `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 | + +### Fissate dal compose + +Questi valori sono imposti da `docker-compose.yml` e **ignorano** quanto scritto +nel `.env`, perché descrivono la topologia dello stack: `DB_HOST`, `DB_PORT`, +`REDIS_HOST`, `REDIS_PORT`, `CACHE_STORE`, `SESSION_DRIVER`, +`QUEUE_CONNECTION`, `LOG_CHANNEL`. + +Serve a evitare che un `.env` copiato da una macchina di sviluppo (con, per +dire, `DB_HOST=127.0.0.1`) rompa il container. + +--- + +## 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 + +### 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 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 _storage_data:/data -v "$PWD":/backup \ + alpine tar czf /backup/storage_$(date +%Y%m%d).tar.gz -C /data . ``` ---- +Automatizzabile con cron: -## Funzionalità principali +```bash +0 2 * * * docker exec mariadb-dump -u root -p"$DB_ROOT_PASSWORD" termanager2 | gzip > /backups/termanager2_$(date +\%Y\%m\%d).sql.gz +``` -### Dashboard (Home) -- Anno teocratico corrente (auto-creato se mancante, Set→Ago) -- Media mensile di percorrenza (territori rientrati / mesi trascorsi) -- Campagna attiva con barra di progresso percentuale -- Tre liste rapide con azione diretta: - - **Da assegnare**: territori in reparto, ordinati per priorità - - **Prioritari**: flag manuale O soglia automatica (la soglia vince sempre) - - **Da rientrare**: assegnazioni oltre la soglia giorni configurata +### Ripristino -### Territori -- CRUD completo con upload/sostituzione PDF -- Filtri per zona, tipologia, stato (in_reparto, assegnato, da_rientrare, inattivo) -- Badge di stato colorati + indicatore priorità (Manuale/Automatico) -- Dettaglio con PDF viewer inline e storico assegnazioni per anno teocratico -- Toggle attivo/inattivo e prioritario -- Soft delete con cestino (ripristino o eliminazione definitiva) +```bash +gunzip -c termanager2_20260718.sql.gz | \ + docker exec -i mariadb -u root -p"$DB_ROOT_PASSWORD" termanager2 -### Proclamatori -- CRUD con campi nome/cognome **cifrati nel DB** (AES-256-CBC) -- Ricerca in-memory (necessaria per campi cifrati, ottimizzata per < 200 record) -- Conteggio territori attualmente assegnati -- Toggle attivo/inattivo -- **Anonimizzazione GDPR** (sostituzione dati con placeholder irreversibile) -- Soft delete con cestino +docker exec php artisan migrate --force +docker exec php artisan cache:clear +``` -### Assegnazione / Rientro -- Selezione territorio disponibile + proclamatore attivo -- Data assegnazione con auto-detection anno teocratico -- Validazione: territorio non già assegnato, entrambi attivi -- Rientro con calcolo automatico giorni -- **Prompt retroattivo campagna**: se l'assegnazione ricade nel range di una campagna, viene chiesto se conteggiare il rientro - -### Campagne -- CRUD con date inizio/fine e descrizione -- Stato automatico (Attiva/Futura/Conclusa) -- Percentuale percorrenza in tempo reale (conteggiati / totali assegnati nel range) -- Dettaglio con lista territorio-per-territorio dei conteggiati - -### Registro Assegnazioni -- Filtri: anno teocratico, zona, stato (aperte/chiuse), ricerca testo -- Colonne: territorio, proclamatore, date, giorni, campagna - -### Audit Log -- Log automatico su tutti i modelli (spatie/laravel-activitylog) -- Log manuale per eventi di flusso (assign, return, login, logout) -- Filtri per utente, tipo evento, ricerca testo -- Dettaglio con diff before/after (properties JSON espanso) -- Pulizia automatica schedulata (`audit:cleanup` — retention configurabile) - -### Impostazioni -- Nome congregazione -- Soglia mesi priorità automatica -- Soglia giorni per "da rientrare" -- Retention giorni audit log - -### XML Exchange -- Conversione dump SQL legacy in XML compatibile con TerManager2 -- Import XML nell'app (sostituzione dati gestionali: impostazioni, zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni) -- Export XML dei dati correnti - -### Zone e Tipologie -- CRUD inline (aggiungi, rinomina, attiva/disattiva, elimina) -- Protezione: non eliminabile se ha territori associati - ---- - -## Ruoli e permessi (RBAC) - -Tre ruoli con permessi granulari gestiti da **spatie/laravel-permission**: - -| Permesso | Amministratore | Assistente | Operatore | -|---------------------|:--------------:|:----------:|:---------:| -| `settings.manage` | ✅ | | | -| `territori.manage` | ✅ | | | -| `proclamatori.manage`| ✅ | ✅ | | -| `campagne.manage` | ✅ | ✅ | | -| `territori.assign` | ✅ | ✅ | ✅ | -| `territori.return` | ✅ | ✅ | ✅ | -| `registro.view` | ✅ | ✅ | | -| `registro.export` | ✅ | | | -| `audit.view` | ✅ | | | -| `audit.export` | ✅ | | | - -L'amministratore ha un bypass completo via `Gate::before` nel `AppServiceProvider`. - -Il menu sidebar mostra solo le voci per cui l'utente ha permesso. - ---- - -## Sicurezza e GDPR - -### Sicurezza -- **Cifratura a riposo**: nome e cognome proclamatori cifrati con `encrypted` cast (AES-256-CBC via `APP_KEY`) -- **Password**: hash bcrypt (standard Laravel) -- **RBAC**: middleware `permission:` su ogni rotta -- **CSRF**: token integrato in tutti i form Livewire -- **Rate limiting**: login con throttle configurabile -- **Security headers**: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection (via Nginx) -- **Sessioni**: Redis con cookie `termanager2_session` (httpOnly, secure in produzione) - -### GDPR -- **Minimizzazione**: solo nome, cognome e stato attivo per i proclamatori -- **Cifratura a riposo**: dati illeggibili con accesso diretto al DB -- **Audit trail**: log completo di tutte le operazioni sui dati -- **Diritto all'oblio**: metodo `anonimizza()` sul Proclamatore (sostituisce dati con placeholder) -- **Retention**: pulizia automatica dei log audit oltre il periodo configurato -- **Ruoli e autorizzazioni**: accesso ai dati solo per chi ha permessi specifici -- **No log di dati sensibili**: l'audit log registra `proclamatore_id` ma non nome/cognome in chiaro nei `properties` - ---- - -## Modello dati - -### Tabelle principali (10 migrazioni) - -| Tabella | Descrizione | -|-------------------|--------------------------------------------------------------| -| `users` | Utenti app con auth standard Laravel | -| `settings` | Configurazione singleton (congregazione, soglie, retention) | -| `zone` | Zone territoriali (id, nome, attivo) | -| `tipologie` | Tipologie territorio (id, nome, attivo) | -| `proclamatori` | Assegnatari con nome/cognome cifrati, soft delete | -| `territori` | Cartoline con zona, tipologia, PDF, soft delete | -| `anni_teocratici` | Periodi Set→Ago con label univoca | -| `campagne` | Periodi speciali con date e descrizione | -| `assegnazioni` | Join territorio↔proclamatore con date, anno, campagna | -| `activity_log` | Audit trail (spatie/laravel-activitylog) | - -### Indici e vincoli -- `territori.numero` — UNIQUE -- `anni_teocratici.label` — UNIQUE -- `assegnazioni(territorio_id, returned_at)` — INDEX composito -- `assegnazioni(proclamatore_id, returned_at)` — INDEX composito -- `assegnazioni(anno_teocratico_id)` — INDEX -- FK con `RESTRICT` su territorio e proclamatore (protezione integrità) - ---- - -## Regole di business - -### Anno teocratico -- **Settembre → Agosto**: se mese ≥ 9 → anno = `corrente/corrente+1`; se mese ≤ 8 → anno = `corrente-1/corrente` -- Creato automaticamente se non esiste (metodo `AnnoTeocratico::perData()`) -- L'assegnazione conta per l'anno determinato dalla **data di assegnazione** - -### Stato territorio (calcolato dinamicamente) -- **in_reparto**: attivo, nessuna assegnazione aperta -- **assegnato**: ha assegnazione con `returned_at = null` -- **da_rientrare**: assegnato da più giorni della soglia configurata -- **inattivo**: `attivo = false` - -### Priorità -- **Manuale**: flag `prioritario = true` impostato dall'admin -- **Automatica**: nessuna assegnazione negli ultimi N mesi (soglia configurabile) -- La soglia automatica **vince sempre** sul flag manuale (l'admin può aggiungere ma non rimuovere se la soglia scatta) - -### Campagne — percentuale percorrenza -- **Denominatore**: tutti i territori assegnati nel range della campagna -- **Numeratore**: assegnazioni conteggiate in campagna (`counted_in_campaign = true`) -- Il prompt retroattivo appare al rientro se l'assegnazione è nel range di una campagna +Assicurati che `APP_KEY` sia **la stessa** di quando il backup è stato creato. --- ## Comandi utili -### Gestione container +Sostituisci `` con il nome del container (lo trovi con `docker ps`). ```bash -# Avviare i container (dopo il primo build) -docker compose up -d +# Log in tempo reale +docker logs -f -# Avviare con ricostruzione immagini (dopo modifiche a Dockerfile o dipendenze) -docker compose up -d --build +# Stato delle migrazioni +docker exec -it php artisan migrate:status -# Fermare i container (i dati persistono nei volumi) -docker compose down +# Shell interattiva Laravel +docker exec -it php artisan tinker -# Fermare e CANCELLARE tutti i dati (database, redis, uploads) -docker compose down -v --remove-orphans +# Reimpostare la password di un utente +docker exec -it php artisan tinker --execute=" + \$u = App\Models\User::where('email','admin@tuodominio.it')->first(); + \$u->password = Hash::make('NuovaPassword'); + \$u->save(); +" -# Stato dei container -docker compose ps +# Creare un altro amministratore +docker exec -it php artisan app:create-initial-admin \ + --name="Nome" --email="mail@dominio.it" --password="Password" -# Log in tempo reale (tutti i container) -docker compose logs -f +# Pulizia manuale dell'audit log +docker exec -it php artisan audit:cleanup -# Log di un singolo container -docker compose logs app --tail=100 -docker compose logs nginx --tail=100 -docker compose logs mariadb --tail=100 - -# Riavviare un singolo container -docker compose restart app -``` - -### Shell interattiva - -```bash -# Shell nel container app (come utente 1000) -docker compose exec app bash - -# Shell nel container app come root (per operazioni di sistema) -docker compose exec -u root app bash - -# Shell nel container MariaDB -docker compose exec mariadb mysql -u root -p -``` - -### Laravel / Artisan - -```bash -# Eseguire migrazioni -docker compose exec app php artisan migrate --force - -# Seed del database -docker compose exec app php artisan db:seed --force - -# Pulizia manuale audit log -docker compose exec app php artisan audit:cleanup - -# Cache configurazione (produzione) -docker compose exec app php artisan config:cache -docker compose exec app php artisan route:cache -docker compose exec app php artisan view:cache - -# Svuotare TUTTA la cache -docker compose exec app php artisan optimize:clear -``` - -### Asset frontend (CSS/JS) - -```bash -# Compilare asset per produzione -docker compose exec -u root app bash -c "npm install --no-audit --no-fund && npm run build" -docker compose exec -u root app chown -R 1000:1000 public/build node_modules - -# Compilare asset per sviluppo (hot reload, richiede porta 5173 libera) -docker compose exec app npm run dev -``` - -### Backup e ripristino database - -```bash -# Backup del database (sostituisci le credenziali) -docker compose exec mariadb mysqldump -u root -prootsecret termanager2 > backup_$(date +%Y%m%d).sql - -# Ripristino da backup -docker compose exec -T mariadb mysql -u root -prootsecret termanager2 < backup_20250101.sql -docker compose exec app php artisan migrate --force -docker compose exec app php artisan optimize:clear +# Svuotare le cache +docker exec -it php artisan optimize:clear ``` --- ## Troubleshooting +### Il container `app` resta unhealthy + +L'health check interroga `/up`. Guarda i log (`docker logs `): 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) -**Sintomo**: la pagina di login appare con testo non formattato, senza colori né layout. +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. -**Causa**: il file CSS compilato manca da `public/build/assets/`. Succede tipicamente dopo una migrazione su nuovo server. +### I job restano in coda -**Soluzione**: +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 -# Ricompila gli asset come root (evita problemi di permessi) -docker compose exec -u root app bash -c "npm install --no-audit --no-fund && npm run build" - -# Correggi i permessi -docker compose exec -u root app chown -R 1000:1000 public/build node_modules +docker compose -f docker-compose.dev.yml down -v +docker compose -f docker-compose.dev.yml up -d --build ``` -### Il container `app` non diventa healthy - -**Sintomo**: `docker compose ps` mostra `app` come `starting` o `unhealthy`. - -**Cosa controllare**: - -```bash -# Guarda i log per capire dove si blocca -docker compose logs app --tail=200 -``` - -Cause comuni: -- **MariaDB non pronto**: l'entrypoint ripete il tentativo 10 volte. Attendi qualche secondo. -- **Credenziali admin mancanti**: se `ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true` e le variabili `INITIAL_ADMIN_*` sono vuote, il container mostra un warning. -- **Errore di permessi**: esegui `sudo chown -R 1000:1000 .` sulla cartella del progetto. - -### Errore "Permission denied" su storage o bootstrap/cache - -```bash -sudo chown -R 1000:1000 storage bootstrap/cache -chmod -R 775 storage bootstrap/cache -``` - -### La `APP_KEY` è cambiata e i dati cifrati non sono leggibili - -La `APP_KEY` viene salvata in `storage/app/.app_key` per persistere tra i riavvii. Se perdi questo file: - -- I **nomi e cognomi dei proclamatori** (cifrati con AES-256) diventano illeggibili -- Le **sessioni** vengono invalidate - -**Prevenzione**: dopo il primo avvio, salva il valore di `APP_KEY` dal file `.env` in un luogo sicuro. - -### Errore npm "EACCES" o "vite: not found" - -```bash -# Installa le dipendenze npm come root -docker compose exec -u root app npm install --no-audit --no-fund - -# Verifica che vite sia installato -docker compose exec app ls node_modules/.bin/vite - -# Compila come root -docker compose exec -u root app npm run build - -# Poi correggi i permessi -docker compose exec -u root app chown -R 1000:1000 public/build node_modules -``` - -### Reset completo (ripartire da zero) - -> **Attenzione**: questo cancella il database, i file Redis e tutti i dati applicativi. - -```bash -docker compose down -v --remove-orphans -sudo chown -R 1000:1000 . -rm -rf node_modules public/build vendor storage/app/.app_key storage/framework/.runtime_db_seeded -docker compose up -d --build -``` +`-v` cancella i volumi: database, Redis e file caricati vengono persi. --- -## Produzione +## Funzionalità -### Checklist deploy in produzione +### 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** -1. **Variabili d'ambiente** — modifica nel `.env`: - ```dotenv - APP_ENV=production - APP_DEBUG=false - APP_URL=https://tuodominio.it - ASSET_URL=https://tuodominio.it - ``` +### 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 -2. **Password sicure** — cambia TUTTI i default: - ```dotenv - DB_PASSWORD= - DB_ROOT_PASSWORD= - REDIS_PASSWORD= - ``` +### Proclamatori +- CRUD con nome e cognome **cifrati nel database** +- Conteggio dei territori attualmente assegnati +- **Anonimizzazione GDPR** irreversibile +- Soft delete con cestino -3. **HTTPS** — configura un reverse proxy (Traefik, Nginx, Caddy) con certificato SSL/TLS davanti alla porta `APP_PORT` +### 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 -4. **Cache** — al primo avvio viene fatto automaticamente dall'entrypoint, ma dopo modifiche: - ```bash - docker compose exec app php artisan config:cache - docker compose exec app php artisan route:cache - docker compose exec app php artisan view:cache - ``` +### Campagne +- CRUD con date di inizio e fine +- Stato automatico: attiva, futura o conclusa +- Percentuale di percorrenza in tempo reale +- Dettaglio territorio per territorio -5. **Backup** — programma backup regolari del database: - ```bash - # Cron job giornaliero (esempio) - 0 2 * * * cd /path/to/termanager2 && docker compose exec -T mariadb mysqldump -u root -prootsecret termanager2 | gzip > /backups/termanager2_$(date +\%Y\%m\%d).sql.gz - ``` +### Registro assegnazioni +- Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale +- Colonne: territorio, proclamatore, date, giorni, campagna -6. **APP_KEY** — salva il valore di `APP_KEY` dal `.env` in un luogo sicuro (password manager, vault). Senza di essa i dati cifrati dei proclamatori sono irrecuperabili. +### 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 -7. **Mailpit** — in produzione rimuovi il servizio `mailpit` dal `docker-compose.yml` e configura un server SMTP reale: - ```dotenv - MAIL_MAILER=smtp - MAIL_HOST=smtp.tuoprovider.it - MAIL_PORT=587 - MAIL_USERNAME=utente@tuodominio.it - MAIL_PASSWORD=password-smtp - MAIL_ENCRYPTION=tls - MAIL_FROM_ADDRESS=noreply@tuodominio.it - ``` +### XML Exchange +- Conversione di dump SQL legacy in XML compatibile +- Import ed export XML dell'intero set di dati gestionali + +### 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 dell'applicazione | +| `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, app:create-initial-admin +│ ├── Http/Controllers/ # PDF pubblici, import, login +│ ├── Jobs/ # Import PDF massivo (in coda) +│ ├── Livewire/ # Tutti i componenti di interfaccia +│ ├── Models/ # 9 modelli Eloquent +│ ├── Providers/ # AppServiceProvider +│ └── Services/ # Miniature PDF, stato import +├── 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 # Produzione +├── docker-compose.dev.yml # Sviluppo locale +├── 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 -Progetto privato — tutti i diritti riservati. +Proprietaria. Tutti i diritti riservati. diff --git a/TerManager2_v2.md b/TerManager2_v2.md deleted file mode 100644 index db92b80..0000000 --- a/TerManager2_v2.md +++ /dev/null @@ -1,640 +0,0 @@ -# TerManager2 — Specifiche funzionali + istruzioni per container (Laravel) - -> Documento operativo per realizzare **TerManager2**, applicazione Laravel per la gestione dell’assegnazione/rientro di cartoline territorio, statistiche di percorrenza e campagne speciali. -> -> **Obiettivo del documento**: chiarire requisiti, proporre un’architettura “container-ready”, definire modello dati e regole di calcolo, e fornire una **scomposizione in sottotask** con checklist di controllo a fine attività. - ---- - -## 1) Visione d’insieme - -**TerManager2** gestisce: -- **Territori** (cartoline) con PDF, stato (in reparto / assegnato / da rientrare / prioritario / inattivo) e proprietà (Zona, Tipologia, ecc.). -- **Proclamatori** (assegnatari) con dati **illeggibili in DB** (cifratura applicativa a livello di colonna) e gestione **GDPR**. -- **Assegnazioni** con data assegnazione/rientro, teocratic year, opzionale “conteggio campagna”. -- **Campagne** (periodi speciali) con percentuali di percorrenza e dashboard. -- **Statistiche** (media mensile di percorrenza nell’anno teocratico, indicatori di giacenza e “smarriti”). - -**Ruoli (RBAC)**: -- **Amministratore**: tutto (impostazioni, territori, proclamatori, campagne, registro, proprietà territorio, assegnazione/rientro). -- **Assistente**: proclamatori, campagne, assegnazione/rientro. -- **Operatore**: assegnazione/rientro. - ---- - -## 2) Requisiti non funzionali (sicurezza, GDPR, UX) - -### 2.1 Sicurezza e dati illeggibili in DB -Requisito: i dati dei proclamatori devono risultare **illeggibili** se si accede direttamente al database. - -**Scelta consigliata (Laravel)**: -- Password utente: **hash** (bcrypt/argon2id) (standard Laravel). -- Dati sensibili (nome, cognome, eventuali note): **cifratura applicativa per colonna**. - - Usare **Laravel Eloquent encrypted casts** (es. `protected $casts = ['nome' => 'encrypted', ...];`) oppure un **Custom Cast** che cifri/decifri con AES-256-GCM. - - La chiave di cifratura è `APP_KEY` (gestita come secret in ambiente). **Mai** versionarla. - -**Nota importante**: la cifratura applicativa protegge “a riposo” nel DB. Per un livello ulteriore: -- cifrare anche **backup**; -- considerare cifratura volume/disco a livello host. - -### 2.2 GDPR — criteri base da rispettare -Implementare almeno: -- **Minimizzazione**: salvare solo ciò che serve (nome/cognome e stato attivo). -- **Ruoli e autorizzazioni**: accesso ai dati solo per chi ha permessi. -- **Audit trail**: log di operazioni critiche (creazione/modifica/eliminazione, assegnazione/rientro, cambio stato territorio). -- **Retention**: definire policy su conservazione storici (es. registro assegnazioni conservato per X anni — parametro impostazioni). -- **Diritto all’oblio**: possibilità di **disattivare/anonimizzare** un proclamatore (senza rompere lo storico). -- **Esportazione**: esportazione dati (CSV/PDF) su richiesta (opzionale ma consigliato). -- **Consenso e informativa**: pagina “Privacy / Informativa” e log di accettazione (se necessario al contesto). - -### 2.3 UI/UX -- Responsive “mobile-first” (smartphone/tablet), grafica minimale e moderna. -- Layout con: - - header con logo + congregazione; - - menu laterale (collassabile su mobile); - - contenuti centrali con card e tabelle filtrabili. - -Tecnologie UI consigliate: -- **TailwindCSS** + **Livewire** (rapido, server-driven) **oppure** Inertia + Vue/React. -- Componenti: ricerca veloce, paginazione, badge di stato, drawer per dettagli. - ---- - -## 3) Architettura applicativa (Laravel) - -### 3.1 Componenti -- Laravel (>=10) con: - - Auth (Breeze/Jetstream o Fortify) + **RBAC** (Spatie Laravel Permission consigliato). - - DB: MySQL/MariaDB o PostgreSQL. - - Storage: `storage/app` per PDF (in dev) + volume Docker; in produzione valutare S3/MinIO. - - Queue: Redis (opzionale ma utile per report/esportazioni). - - Scheduler: `artisan schedule:work` per job (es. calcoli, pulizie, report). - -### 3.2 Concetti chiave di dominio -- **Anno teocratico**: da **Settembre** (anno precedente) ad **Agosto** (anno corrente). -- **Percorrenza**: giorni tra assegnazione e rientro (nell’anno teocratico corrente) aggregati e convertiti in media mensile. -- **Campagna**: periodo con start/end; a ogni rientro si chiede se quel rientro conta per la campagna (anche retroattivo se assegnato durante il periodo ma rientrato dopo). - ---- - -## 4) Modello dati (proposta) - -> I nomi campi sono indicativi; adeguare naming (snake_case) e indici. - -### 4.1 Tabelle - -#### `users` -- auth utenti app (admin/assistente/operatore) -- campi standard Laravel (`email`, `password`, `name`, ecc.). - -#### `roles`, `permissions`, `model_has_roles`, ... -- se si usa Spatie Permission. - -#### `settings` -- configurazioni globali: - - `congregazione_nome` - - soglia `giorni_giacenza_da_assegnare` - - soglia `giorni_giacenza_prioritari` - - soglia `giorni_per_smarrito` - - `home_limit_list` (default 10) - - altre. - -#### `proclamatori` -- `id` -- `nome` **encrypted** -- `cognome` **encrypted** -- `attivo` boolean -- timestamps - -> (Opzionale) `display_name_hash` per ricerche rapide senza decifrare tutto (hash normalizzato di nome+cognome). Attenzione: l’hash può comunque essere dato personale; trattarlo come tale. - -#### `territori` -- `id` -- `numero` (string/int) **univoco** -- `zona_id` -- `tipologia_id` -- `note` (text) -- `confini` (text) -- `pdf_path` (string) -- `attivo` boolean -- `prioritario` boolean -- timestamps - -#### `zone` e `tipologie` -- `id`, `nome`, `attivo`. - -#### `anni_teocratici` -- `id` -- `label` (es. `2025-2026`) -- `start_date` (1 settembre) -- `end_date` (31 agosto) -- univoco per `label`. - -#### `campagne` -- `id` -- `start_date` -- `end_date` -- `descrizione` -- `attiva` (derivabile da date, ma può essere utile una view/flag) -- timestamps - -#### `assegnazioni` -- `id` -- `territorio_id` -- `proclamatore_id` -- `anno_teocratico_id` -- `assigned_at` -- `returned_at` (nullable) -- `counted_in_campaign` boolean nullable (null = non chiesto/non applicabile) -- `campaign_id` nullable (se conteggiata) -- `note` nullable -- `created_by` (user_id) -- `returned_by` (user_id) nullable -- timestamps - -#### `audit_logs` (o `activity_log`) — per pagina Log attività -- tabella dedicata a tracciare azioni utenti (vedi §6.7) -- se si usa un pacchetto, mantenere naming/colonne del pacchetto; altrimenti creare tabella custom. - -### 4.2 Vincoli e indici (essenziali) -- `territori.numero` unique -- `assegnazioni`: - - indice su (`territorio_id`, `returned_at`) - - indice su (`proclamatore_id`, `returned_at`) - - indice su (`anno_teocratico_id`) -- integrità referenziale con `ON DELETE CASCADE` dove sensato. - - **Attenzione**: requisito: eliminazione territorio => eliminazione su tutto il registro assegnazioni. Quindi `assegnazioni.territorio_id` con cascade. - ---- - -## 5) Regole di business e calcoli - -### 5.1 Anno teocratico (creazione automatica) -- All’avvio Home o in fase di assegnazione/rientro: - 1. determinare data odierna; - 2. se mese >= 9 (settembre) -> anno teocratico = `anno_corrente-anno_corrente+1`; - 3. se mese <= 8 -> anno teocratico = `anno_corrente-1-anno_corrente`; - 4. se non esiste in DB, crearlo con start_date=1/09 e end_date=31/08. - -### 5.2 Stato territorio -- **In reparto**: nessuna assegnazione aperta (ultima assegnazione `returned_at` non null) e attivo. -- **Assegnato a X**: esiste assegnazione aperta (`returned_at` null). -- **Smarrito / da rientrare**: assegnazione aperta da più giorni rispetto a `settings.giorni_per_smarrito`. -- **Prioritario**: flag `prioritario=true` oppure giacenza in reparto > soglia `giorni_giacenza_prioritari`. -- **Inattivo**: `attivo=false` (non assegnabile). - -### 5.3 “Territori da assegnare” (Home) -- Territori **in reparto** con giacenza > `giorni_giacenza_da_assegnare`. -- Mostrare primi N (`home_limit_list`). -- Azione: assegnazione rapida a proclamatore. - -### 5.4 “Territori prioritari” (Home) -- Territori in reparto con giacenza > `giorni_giacenza_prioritari` **oppure** `prioritario=true`. -- Azione: assegnazione rapida. - -### 5.5 “Territori da rientrare” (Home) -- Territori con assegnazione aperta da più giorni di `giorni_per_smarrito`. -- Azione: rientro rapido (sull’attuale assegnatario). - -### 5.6 Media mensile di percorrenza (Home) -Definizione richiesta: media mensile di percorrenza di tutti i territori nell’anno teocratico corrente. - -Interpretazione operativa (coerente con testo): -- Per ogni **assegnazione chiusa** (returned_at valorizzato) nell’anno teocratico corrente: - - `giorni = returned_at - assigned_at` (in giorni, >=0) -- Sommare tutti i `giorni` e normalizzare su base mensile. - -Proposta formula (trasparente e verificabile): -- `giorni_totali = Σ giorni` per tutte le assegnazioni chiuse dell’anno teocratico corrente -- `mesi_trascorsi = numero mesi dall’inizio anno teocratico a oggi (min 1)` -- `media_mensile = giorni_totali / mesi_trascorsi` - -> Alternativa: media per territorio o per assegnazione; scegliere una e documentarla in impostazioni. L’importante è che sia consistente e testata. - -### 5.7 Campagne (Home + Tab Campagne) -- Campagna attiva se `today` tra `start_date` e `end_date`. -- Percentuale percorrenza campagna: - - `territori_percorsi_in_campagna / territori_totali_pertinenti`. - -Chiarimento necessario per “totali pertinenti”: suggerito: -- denominatore = numero di territori rientrati **conteggiati campagna** durante il periodo **+** territori assegnati nel periodo ma non ancora rientrati? (da definire). - -Per evitare ambiguità e mantenere semplice: -- **Proposta**: percentuale = (numero assegnazioni chiuse conteggiate campagna) / (numero assegnazioni chiuse totali che hanno `assigned_at` nel range campagna) * 100. - -#### Avviso rientro “con o senza campagna” (retroattivo) -Alla chiusura di un’assegnazione: -- se esiste campagna tale che `assigned_at` è compreso nel range campagna **(anche se oggi è dopo end_date)**, mostrare prompt: - - “Questo rientro conta per la campagna X?” - - se sì: `counted_in_campaign=true` e `campaign_id`. - - se no: `counted_in_campaign=false`. -- se non applicabile: lasciare null. - -#### Vista “territori percorsi e in quanto tempo” -Per campagna selezionata: -- lista assegnazioni conteggiate in campagna con: - - territorio, proclamatore (mostrato in chiaro solo in UI), assigned_at, returned_at, giorni. - ---- - -## 6) Pagine e funzionalità (corrette/riordinate) - -### 6.1 Wizard iniziale (primo avvio) -Se mancano impostazioni base (`settings` vuoto): -- Step 1: Nome congregazione + logo (upload). -- Step 2: soglie (giacenza, prioritari, smarrito) + limite liste Home. -- Step 3: conferma e creazione admin (se necessario). - -### 6.2 Home -Header: -- sinistra: logo + nome congregazione -- destra: nome utente + ruolo + logout - -Sidebar: -- voci variabili in base ai permessi. - -Contenuto centrale: -1) **Anno teocratico in corso** (auto-creazione se non esiste) -2) **Media mensile di percorrenza** (anno teocratico corrente) -3) **Campagna in corso** (se attiva): descrizione + percentuale + pulsante “Dettagli in tempo reale” -4) **Assegnazione rapida** (3 colonne/zone): - - Territori da assegnare - - Territori prioritari - - Territori da rientrare - Ogni lista: - - mostra top N - - ricerca veloce - - pulsante “Vedi tutti” (pagina completa con filtri) - -### 6.3 Gestione Territori -Lista filtrabile con colonne: -- Numero -- Zona / Tipologia -- Stato (badge) -- Ultimo rientro -- Azioni: Visualizza / Modifica / Elimina / Attiva-Disattiva - -Dettaglio territorio (“Visualizza”): -- dati completi + PDF viewer -- storico assegnazioni raggruppato per anno teocratico - -Regole: -- Eliminazione territorio => rimuove anche tutte le assegnazioni (cascade). -- Territorio inattivo non è assegnabile e non appare nelle liste “da assegnare/prioritari” (a meno di filtro admin). - -### 6.4 Gestione Proclamatori -Lista filtrabile: -- Proclamatore (nome/cognome decifrati in UI) -- Attivo -- Territori attualmente in assegnazione (conteggio + link) -- Azioni: Visualizza / Modifica / Elimina / Disattiva - -Regole consigliate: -- Eliminazione fisica solo se nessuno storico (oppure prevedere “anonimizza”). - -### 6.5 Gestione Campagne -- Form creazione campagna (start, end, descrizione) -- Evidenza campagna attiva -- Elenco campagne completate (indipendente dall’anno teocratico) -- Dettaglio: elenco assegnazioni conteggiate, tempi, export. - -### 6.6 Registro assegnazioni -Lista filtrabile per: -- anno teocratico -- territorio -- zona -- tipologia -- proclamatore - -Output: -- tabella con assegnazioni (assegnato, rientrato, giorni, campagna sì/no) -- export CSV/PDF (opzionale) - -### 6.7 Log attività (Audit trail) — **REQUISITO OBBLIGATORIO** - -> Deve essere disponibile una **pagina di log** che riporti **tutte le azioni degli utenti** (Amministratore, Assistente, Operatore) e in particolare le operazioni di: -> - **Assegnazione** territorio -> - **Rientro** territorio (con/senza campagna) -> - **Creazione / modifica / cancellazione** di: Territori, Proclamatori, Campagne, Zone, Tipologie, Impostazioni -> - **Attiva/Disattiva** territorio o proclamatore -> - **Upload / sostituzione / eliminazione** PDF territorio -> - (Consigliato) **Login/Logout**, tentativi falliti, export dati - -#### 6.7.1 Scopo -- Tracciabilità e accountability (chi ha fatto cosa e quando). -- Supporto a sicurezza e requisiti GDPR (audit delle operazioni sui dati). - -#### 6.7.2 Permessi di accesso alla pagina log -- **Visualizzazione log**: `audit.view` (almeno Amministratore). - - Opzionale: consentire anche ad Assistente una vista “limitata” (solo eventi di assegnazione/rientro e senza IP/user-agent). -- **Esportazione log**: `audit.export` (solo Amministratore). - -#### 6.7.3 Dati minimi da registrare per ogni evento -Registrare per ogni azione: -- `id` -- `created_at` (data/ora evento) -- `causer_user_id` (utente che ha eseguito) -- `causer_role_snapshot` (ruolo al momento dell’azione) -- `event` (es. `created`, `updated`, `deleted`, `assigned`, `returned`, `activated`, `deactivated`, `uploaded_pdf`) -- `subject_type` + `subject_id` (entità coinvolta: Territorio/Proclamatore/Campagna/Assegnazione/Setting...) -- `description` (testo leggibile) -- `properties` (JSON) con dettagli evento: - - **prima/dopo** per update (diff campi) - - motivazioni/flag (es. `counted_in_campaign=true`) - - valori di soglia usati in calcoli/decisioni (se rilevanti) -- (Consigliato) `ip_address` e `user_agent` (dato personale → retention e minimizzazione) - -> Nota GDPR: retention configurabile (es. 365/730 giorni). Evitare di salvare nei log dati sensibili in chiaro non necessari: per Proclamatori salvare `proclamatore_id` e non nome/cognome. - -#### 6.7.4 Implementazione consigliata (senza vincoli) -- È possibile implementare il log con tabella custom (`audit_logs`) oppure usare un pacchetto dedicato. -- Scelta spesso adottata in Laravel: **spatie/laravel-activitylog** per logging automatico su modelli + logging manuale per eventi “di flusso” (assegnazione/rientro). - -#### 6.7.5 UI pagina Log -Funzioni minime: -- Tabella: Data/Ora, Utente, Ruolo, Azione, Oggetto, Esito, Dettagli. -- Filtri: date, utente, ruolo, tipo evento, entità. -- Dettaglio evento (drawer/modal): diff before/after + metadati. -- Paginazione + ricerca veloce. - ---- - -## 7) Containerizzazione (Docker) — sviluppo e produzione - -### 7.1 Struttura consigliata repository -``` -termanager2/ - docker/ - nginx/ - default.conf - php/ - Dockerfile - php.ini - .env.example - docker-compose.yml - composer.json - package.json - ... -``` - -> In dev si può usare `npm run dev` in un container separato o sul host. - -### 7.6 Produzione (note rapide) -- Impostare `APP_ENV=production`, `APP_DEBUG=false`. -- Usare immagini “buildate” senza montare volume codice. -- Gestire segreti con Docker secrets o secret manager. -- Abilitare HTTPS con reverse proxy (Traefik/Nginx) e HSTS. -- Backup cifrati e rotazione. - ---- - -## 8) RBAC: permessi e gating UI - -Definire permessi granulari (anche se i ruoli sono solo 3): -- `settings.manage` -- `proclamatori.manage` -- `territori.manage` -- `campagne.manage` -- `registro.view` -- `territori.assign` -- `territori.return` -- `audit.view` -- `audit.export` (opzionale) - -Mappatura ruoli: -- Admin: tutti (incluso `audit.view` e opzionale `audit.export`) -- Assistente: proclamatori.manage, campagne.manage, territori.assign, territori.return -- Operatore: territori.assign, territori.return - -Applicare: -- Middleware route (`can:` / `role:` / `permission:`) -- Condizionali menu sidebar - -## 8-bis) API routes / schermate (URL, metodi, middleware) - -> Nota: di seguito sono indicate **route Web** (UI) e (opzionalmente) **route API**. Anche se l’app nasce “web”, formalizzare le route aiuta a: -> - applicare correttamente `auth` + permessi, -> - mantenere coerenza tra schermate e controller, -> - facilitare test e manutenzione. - -### 8-bis.1 Convenzioni middleware -- `auth` su tutte le rotte applicative. -- `permission:` (o `can:`) per schermate/azioni. -- `throttle` (consigliato) su login e su endpoint di export. - -### 8-bis.2 Mappa schermate principali -- **Home/Dashboard**: `/` (GET) -- **Wizard iniziale**: `/setup/*` (GET/POST) -- **Territori**: `/territori` (lista), `/territori/create`, `/territori/{id}` (dettaglio), `/territori/{id}/edit` -- **Proclamatori**: `/proclamatori` + CRUD -- **Campagne**: `/campagne` + CRUD + dettaglio -- **Registro**: `/registro` (filtri) -- **Audit log**: `/audit` (lista) + `/audit/{id}` (dettaglio) - -### 8-bis.3 Route Web (UI) — dettaglio - -#### Autenticazione -- `GET /login` → form login | middleware: `guest` -- `POST /login` → login | middleware: `guest`, `throttle:login` -- `POST /logout` → logout | middleware: `auth` - -#### Wizard iniziale (primo avvio) -- `GET /setup` → step 1 | middleware: `setup.required` -- `POST /setup/step-1` → salva congregazione | middleware: `setup.required` -- `POST /setup/step-2` → salva soglie | middleware: `setup.required` -- `POST /setup/finish` → completa setup | middleware: `setup.required` - -> `setup.required` è un middleware custom che intercetta l’assenza di settings e forza il wizard. - -#### Home -- `GET /` → dashboard | middleware: `auth` - -#### Territori -- `GET /territori` → lista | middleware: `auth`, `permission:territori.manage` -- `GET /territori/create` → form create | middleware: `auth`, `permission:territori.manage` -- `POST /territori` → store | middleware: `auth`, `permission:territori.manage` -- `GET /territori/{territorio}` → dettaglio | middleware: `auth`, `permission:territori.manage` -- `GET /territori/{territorio}/edit` → form edit | middleware: `auth`, `permission:territori.manage` -- `PUT /territori/{territorio}` → update | middleware: `auth`, `permission:territori.manage` -- `DELETE /territori/{territorio}` → delete (cascade assegnazioni) | middleware: `auth`, `permission:territori.manage` -- `PATCH /territori/{territorio}/toggle-active` → attiva/disattiva | middleware: `auth`, `permission:territori.manage` -- `PATCH /territori/{territorio}/toggle-priority` → prioritario sì/no | middleware: `auth`, `permission:territori.manage` -- `POST /territori/{territorio}/pdf` → upload/sostituzione PDF | middleware: `auth`, `permission:territori.manage` -- `DELETE /territori/{territorio}/pdf` → rimuovi PDF | middleware: `auth`, `permission:territori.manage` - -#### Proclamatori -- `GET /proclamatori` → lista | middleware: `auth`, `permission:proclamatori.manage` -- `GET /proclamatori/create` → form create | middleware: `auth`, `permission:proclamatori.manage` -- `POST /proclamatori` → store | middleware: `auth`, `permission:proclamatori.manage` -- `GET /proclamatori/{proclamatore}` → dettaglio | middleware: `auth`, `permission:proclamatori.manage` -- `GET /proclamatori/{proclamatore}/edit` → form edit | middleware: `auth`, `permission:proclamatori.manage` -- `PUT /proclamatori/{proclamatore}` → update | middleware: `auth`, `permission:proclamatori.manage` -- `DELETE /proclamatori/{proclamatore}` → delete/anonimizza (policy) | middleware: `auth`, `permission:proclamatori.manage` -- `PATCH /proclamatori/{proclamatore}/toggle-active` → attiva/disattiva | middleware: `auth`, `permission:proclamatori.manage` - -#### Campagne -- `GET /campagne` → lista + campagna attiva | middleware: `auth`, `permission:campagne.manage` -- `GET /campagne/create` → form create | middleware: `auth`, `permission:campagne.manage` -- `POST /campagne` → store | middleware: `auth`, `permission:campagne.manage` -- `GET /campagne/{campagna}` → dettaglio + tempi real-time | middleware: `auth`, `permission:campagne.manage` -- `GET /campagne/{campagna}/edit` → form edit | middleware: `auth`, `permission:campagne.manage` -- `PUT /campagne/{campagna}` → update | middleware: `auth`, `permission:campagne.manage` -- `DELETE /campagne/{campagna}` → delete | middleware: `auth`, `permission:campagne.manage` - -#### Assegnazioni (azioni core) -- `POST /assegnazioni/assign` → assegna territorio a proclamatore | middleware: `auth`, `permission:territori.assign` -- `POST /assegnazioni/{assegnazione}/return` → rientro territorio | middleware: `auth`, `permission:territori.return` - -> Nota: `assign` e `return` devono sempre scrivere su **audit log**. - -#### Registro -- `GET /registro` → lista filtrabile | middleware: `auth`, `permission:registro.view` -- (Opzionale) `GET /registro/export` → export | middleware: `auth`, `permission:registro.export` - -#### Audit log -- `GET /audit` → lista log | middleware: `auth`, `permission:audit.view` -- `GET /audit/{log}` → dettaglio log | middleware: `auth`, `permission:audit.view` -- (Opzionale) `GET /audit/export` → export | middleware: `auth`, `permission:audit.export` - -### 8-bis.4 Route API (opzionali, se si usa UI “dinamica”) -> Se si adotta Livewire spesso bastano le Web route. Se si usa SPA (Inertia/Vue/React) o si vuole un frontend più interattivo, prevedere API JSON: -- `GET /api/territori?filters=...` | `auth:sanctum`, `permission:territori.manage` -- `GET /api/home/quicklists` | `auth:sanctum` -- `POST /api/assegnazioni/assign` | `auth:sanctum`, `permission:territori.assign` -- `POST /api/assegnazioni/{id}/return` | `auth:sanctum`, `permission:territori.return` -- `GET /api/campagne/{id}/realtime` | `auth:sanctum`, `permission:campagne.manage` - ---- - -## 9) Checklist di funzionalità (con sottotask e ricontrollo) - -> Regola di lavoro: **ogni funzionalità va spezzata in sottotask**, ognuno con test + verifica UI. A completamento, ricontrollare l’intera funzionalità end-to-end. - -### EPIC A — Setup progetto e base security -**Sottotask** -1. Creazione progetto Laravel + repo + CI base (lint/test). -2. Setup Auth (login/logout) + password policy. -3. Setup RBAC (ruoli/permessi) + seed iniziale admin. -4. Setup cifratura colonne proclamatori + test round-trip. -5. Logging audit (azioni critiche) + retention log. - -**Ricontrollo (DoD)** -- Login funziona, utenti e ruoli corretti. -- Dati proclamatori illeggibili in DB (verifica diretta tabella). -- Rotte protette da permessi. - -### EPIC B — Wizard iniziale + Settings -**Sottotask** -1. Model/settings + migrazione. -2. Wizard UI 3 step + validazione. -3. Blocco app finché wizard non completato (redirect). - -**Ricontrollo** -- Primo avvio porta al wizard. -- Home mostra logo/nome congregazione. - -### EPIC C — Gestione Territori -**Sottotask** -1. Tabelle territori/zone/tipologie + seed. -2. CRUD territori + upload PDF. -3. Lista filtrabile + badge di stato. -4. Dettaglio con PDF viewer + storico per anno teocratico. -5. Attiva/disattiva + prioritario. -6. Delete con cascade su assegnazioni. - -**Ricontrollo** -- Stati corretti e coerenti. -- PDF visibile e scaricabile. -- Eliminazione rimuove storico come richiesto. - -### EPIC D — Gestione Proclamatori -**Sottotask** -1. CRUD proclamatori (encrypted fields). -2. Lista con filtro + indicazione territori assegnati. -3. Disattivazione / (opzionale) anonimizzazione. - -**Ricontrollo** -- Dati illeggibili in DB. -- Non assegnabile se attivo=false. - -### EPIC E — Assegnazione e rientro (core) -**Sottotask** -1. Flusso assegnazione: selezione territorio + proclamatore + conferma. -2. Prevenire doppia assegnazione (vincolo logico). -3. Flusso rientro: chiusura assegnazione + calcolo giorni. -4. Prompt campagna (retroattivo) al rientro. -5. Audit: created_by/returned_by. - -**Ricontrollo** -- Un territorio non può avere due assegnazioni aperte. -- Prompt campagna compare quando dovuto. - -### EPIC F — Home Dashboard -**Sottotask** -1. Calcolo anno teocratico corrente + autocreazione. -2. Calcolo media mensile percorrenza. -3. Liste rapide (da assegnare/prioritari/da rientrare) con filtri e “vedi tutti”. -4. UI responsive. - -**Ricontrollo** -- Numeri coerenti con registro. -- Liste mostrano gli stessi risultati delle pagine complete. - -### EPIC G — Campagne -**Sottotask** -1. CRUD campagne. -2. Indicazione campagna attiva. -3. Percentuale e dettaglio “in tempo reale”. -4. Lista campagne completate. - -**Ricontrollo** -- Retroattività corretta. -- Dettaglio mostra tempi e territori. - -### EPIC H — Registro e statistiche -**Sottotask** -1. Vista registro per territorio con filtri richiesti. -2. Export (opzionale). -3. Test prestazioni su query con indici. - -**Ricontrollo** -- Filtri corretti. -- Dati coerenti con assegnazioni. - -### EPIC I — Audit Log (pagina log e tracciamento eventi) -**Sottotask** -1. Definizione eventi da loggare (CRUD + flussi assegnazione/rientro + login/logout opzionale). -2. Implementazione persistenza log (tabella custom o activity log) + migrazioni. -3. Hook automatici su modelli (created/updated/deleted) e log manuali per `assign/return`. -4. Pagina `/audit` con filtri, paginazione, dettaglio evento (before/after). -5. RBAC: `audit.view` e (opzionale) `audit.export`. -6. Retention configurabile + job scheduler (opzionale) per pulizia log. - -**Ricontrollo** -- Ogni operazione critica genera un record log. -- Il log non espone dati sensibili non necessari (es. nomi proclamatori in chiaro nei properties). -- Filtri e dettaglio mostrano correttamente chi/cosa/quando e diff. - ---- - -## 10) Test consigliati -- **Unit test**: calcolo anno teocratico, stati territorio, logica campagne. -- **Feature test**: RBAC per pagina, assegnazione/rientro, wizard. -- **Security test**: accesso non autorizzato, CSRF, rate limit login. -- **UX test**: mobile viewport, tabelle filtrabili, azioni rapide. -- **Audit test**: ogni create/update/delete e assign/return produce log con subject/causer corretti. - ---- - -## 11) “Done” globale (criteri di completamento) -- Tutte le funzionalità richieste presenti e testate. -- Responsività verificata su smartphone/tablet. -- Dati proclamatori cifrati e illeggibili in DB. -- Log operazioni critiche + policy di retention. -- Backup e gestione segreti definiti (per produzione). - ---- - -### Allegato — Note di chiarezza (minime) -- La formula di “media mensile” e la definizione di denominatore percentuale campagna vanno **documentate** e rese consistenti con i test. -- La cancellazione territorio con cascade è conforme al requisito, ma valutare impatto GDPR/contabile: in alternativa soft-delete + “nascondi dallo storico”. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..88c72ef --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,149 @@ +# ============================================================================= +# TerManager2 — local development +# +# cp .env.example .env +# docker compose -f docker-compose.dev.yml up -d --build +# open http://localhost:8080 (Mailpit UI on http://localhost:8025) +# +# This is a standalone file, not an overlay: it bind-mounts the source so edits +# apply without a rebuild, publishes ports to the host, and swaps the external +# Traefik network for a local one. Production uses docker-compose.yml. +# ============================================================================= + +x-dev-env: &dev-env + APP_NAME: ${APP_NAME:-TerManager2} + APP_ENV: local + APP_DEBUG: "true" + APP_KEY: ${APP_KEY:-} + APP_URL: http://localhost:${APP_PORT:-8080} + APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Rome} + + DB_CONNECTION: mysql + DB_HOST: mariadb + DB_PORT: 3306 + DB_DATABASE: ${DB_DATABASE:-termanager2} + DB_USERNAME: ${DB_USERNAME:-termanager2} + DB_PASSWORD: ${DB_PASSWORD:-secret} + + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-redissecret} + + CACHE_STORE: redis + SESSION_DRIVER: redis + SESSION_SECURE_COOKIE: "false" + QUEUE_CONNECTION: redis + + LOG_CHANNEL: stderr + LOG_LEVEL: debug + + MAIL_MAILER: smtp + MAIL_HOST: mailpit + MAIL_PORT: 1025 + MAIL_FROM_ADDRESS: noreply@termanager2.local + MAIL_FROM_NAME: TerManager2 + +services: + app: + build: + context: . + dockerfile: docker/php/Dockerfile + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:80" + environment: + <<: *dev-env + # Pick up source edits without restarting php-fpm. + PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1 + RUN_DB_SEED_ON_FIRST_START: "true" + SEED_DEV_DATA: ${SEED_DEV_DATA:-false} + INITIAL_ADMIN_NAME: ${INITIAL_ADMIN_NAME:-Amministratore} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@termanager2.local} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-password} + volumes: + # Source is bind-mounted for live edits. vendor/ and public/build stay as + # anonymous volumes so the host (which has neither) doesn't mask what the + # image built. + - ./:/var/www/html + - /var/www/html/vendor + - /var/www/html/public/build + networks: + - termanager2 + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + + queue-worker: + build: + context: . + dockerfile: docker/php/Dockerfile + restart: unless-stopped + environment: + <<: *dev-env + PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1 + volumes: + - ./:/var/www/html + - /var/www/html/vendor + - /var/www/html/public/build + networks: + - termanager2 + depends_on: + app: + condition: service_healthy + entrypoint: ["queue-entrypoint.sh"] + command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--sleep=3", "--tries=1", "--timeout=600"] + + mariadb: + image: mariadb:11 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${DB_DATABASE:-termanager2} + MARIADB_USER: ${DB_USERNAME:-termanager2} + MARIADB_PASSWORD: ${DB_PASSWORD:-secret} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret} + ports: + # Exposed locally so you can attach a GUI client. Never in production. + - "${DB_PORT_HOST:-3306}:3306" + volumes: + - db_data_dev:/var/lib/mysql + networks: + - termanager2 + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redissecret}"] + volumes: + - redis_data_dev:/data + networks: + - termanager2 + healthcheck: + test: ["CMD-SHELL", "redis-cli -a '${REDIS_PASSWORD:-redissecret}' ping | grep -q PONG"] + interval: 10s + timeout: 5s + retries: 10 + + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + ports: + - "${MAIL_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + networks: + - termanager2 + +volumes: + db_data_dev: + redis_data_dev: + +networks: + termanager2: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 6c05d67..ba8f405 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,132 +1,186 @@ +# ============================================================================= +# TerManager2 — production stack (Dokploy) +# +# The application image is self-contained: PHP dependencies and compiled +# frontend assets are baked in at build time, and nginx runs alongside php-fpm +# inside the app container. There are no bind mounts and no build steps at +# boot, so a redeploy is: build image, migrate, serve. +# +# Configuration comes from the .env file, which Dokploy writes from the +# variables you set in its UI. This file does not re-declare them — it only +# pins the handful of values dictated by the topology below (service names, +# which datastores exist), because those must match this file, not .env. +# +# For local development use docker-compose.dev.yml instead. +# ============================================================================= + +x-app: &app + build: + context: . + dockerfile: docker/php/Dockerfile + restart: unless-stopped + # required:false so `docker compose config` and a bare checkout still work; + # a missing .env then surfaces as the explicit DB_PASSWORD error below. + env_file: + - path: .env + required: false + volumes: + # Uploaded territory PDFs, thumbnails, logs and the persisted APP_KEY. + - storage_data:/var/www/html/storage + networks: + - internal + +# Values that are a property of THIS compose file rather than of the +# deployment, so they override whatever .env happens to say. A DB_HOST of +# 127.0.0.1 left over from a local .env would otherwise break the container. +x-app-env: &app-env + DB_CONNECTION: mysql + DB_HOST: mariadb + DB_PORT: 3306 + REDIS_HOST: redis + REDIS_PORT: 6379 + # The stack ships Redis and the worker below consumes the redis queue; + # pointing these elsewhere would silently break job processing. + CACHE_STORE: redis + SESSION_DRIVER: redis + QUEUE_CONNECTION: redis + # Logs go to the container's stderr, where `docker logs` and the Dokploy + # log viewer can see them, rather than into a file inside the volume. + LOG_CHANNEL: stderr + services: app: - build: - context: . - dockerfile: docker/php/Dockerfile - container_name: termanager2_app - restart: unless-stopped - # Runs as root so the entrypoint can mkdir/chown the bind-mounted storage; - # php-fpm workers drop to www-data via docker/php/www.conf. - working_dir: /var/www/html - volumes: - - ./:/var/www/html + <<: *app + environment: + <<: *app-env + # Sources never change in a built image, so skip the stat() on every hit. + PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0 networks: - - termanager2 + - internal + - dokploy-network depends_on: mariadb: condition: service_healthy redis: condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "nc -z 127.0.0.1 9000"] - interval: 5s - timeout: 3s - retries: 20 - start_period: 45s - environment: - - PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 - # DB/Redis creds must be passed into the container so the entrypoint can - # upsert them into .env. Defaults match the mariadb/redis services below. - - DB_HOST=${DB_HOST:-mariadb} - - DB_DATABASE=${DB_DATABASE:-termanager2} - - DB_USERNAME=${DB_USERNAME:-termanager2} - - DB_PASSWORD=${DB_PASSWORD:-secret} - - REDIS_HOST=${REDIS_HOST:-redis} - - REDIS_PASSWORD=${REDIS_PASSWORD:-redissecret} - - SEED_DEV_DATA=${SEED_DEV_DATA:-false} - - RUN_DB_SEED_ON_FIRST_START=${RUN_DB_SEED_ON_FIRST_START:-true} - - ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true} - - INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-} - - INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-} - - INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-} - - nginx: - build: - context: . - dockerfile: docker/nginx/Dockerfile - container_name: termanager2_nginx - restart: unless-stopped - ports: - - "${APP_PORT:-8080}:80" - volumes: - - ./:/var/www/html:ro - networks: - - termanager2 - depends_on: - app: - condition: service_healthy queue-worker: - build: - context: . - dockerfile: docker/php/Dockerfile - container_name: termanager2_queue - restart: unless-stopped - # Same UID as php-fpm workers, so storage files stay consistent. - user: "www-data" - working_dir: /var/www/html - volumes: - - ./:/var/www/html - networks: - - termanager2 + <<: *app + user: www-data + environment: + <<: *app-env + PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0 depends_on: app: condition: service_healthy - entrypoint: ["php"] - command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"] + entrypoint: ["queue-entrypoint.sh"] + # --max-time recycles the worker hourly so a leaked handle or grown heap + # can't accumulate indefinitely; restarting it is Docker's job. + command: + - php + - artisan + - queue:work + - redis + - --queue=default + - --sleep=3 + - --tries=3 + - --timeout=600 + - --max-time=3600 + # Overrides the image's nginx-based HEALTHCHECK (no web server here). + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + scheduler: + <<: *app + user: www-data environment: - - PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 + <<: *app-env + depends_on: + app: + condition: service_healthy + entrypoint: ["queue-entrypoint.sh"] + # `schedule:work` is the container-native replacement for a host crontab — + # it wakes every minute and dispatches whatever routes/console.php defines + # (currently the audit-log cleanup). + command: ["php", "artisan", "schedule:work"] + # Same reason as above: this container runs neither nginx nor php-fpm, so + # the image's HTTP health check would mark it unhealthy forever. + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'schedule:work' > /dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s mariadb: image: mariadb:11 - container_name: termanager2_db restart: unless-stopped + # Interpolated (not env_file) because these are the *image's* own bootstrap + # variables under different names. The `:?` guards are what make a deploy + # with no configured passwords fail loudly instead of coming up with + # whatever default happened to be lying around. environment: MARIADB_DATABASE: ${DB_DATABASE:-termanager2} MARIADB_USER: ${DB_USERNAME:-termanager2} - MARIADB_PASSWORD: ${DB_PASSWORD:-secret} - MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret} + MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required} + MARIADB_AUTO_UPGRADE: "1" + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --innodb-buffer-pool-size=256M volumes: - db_data:/var/lib/mysql networks: - - termanager2 + - internal healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s - retries: 5 + retries: 10 + start_period: 30s redis: image: redis:7-alpine - container_name: termanager2_redis restart: unless-stopped - command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret} + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + command: + - redis-server + - --appendonly + - "yes" + - --requirepass + - ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + - --maxmemory + - 256mb + # Sessions and cache live here; evict volatile keys rather than OOM. + - --maxmemory-policy + - allkeys-lru volumes: - redis_data:/data networks: - - termanager2 + - internal healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"] + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"] interval: 10s timeout: 5s - retries: 5 - - mailpit: - image: axllent/mailpit:latest - container_name: termanager2_mail - restart: unless-stopped - ports: - - "${MAIL_PORT:-1025}:1025" - - "${MAILPIT_UI_PORT:-8025}:8025" - networks: - - termanager2 + retries: 10 + start_period: 10s volumes: db_data: - driver: local redis_data: - driver: local + storage_data: networks: - termanager2: + # Datastores stay on an isolated network with no route to the outside. + internal: driver: bridge + # Dokploy's shared Traefik network. Only `app` joins it, and Dokploy attaches + # the routing labels when you assign a domain to that service in the UI. + dokploy-network: + external: true diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile deleted file mode 100644 index cd6b871..0000000 --- a/docker/nginx/Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM nginx:1.25-alpine -COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index c88b05d..46a6a35 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -1,11 +1,21 @@ server { - listen 80; - server_name localhost; + listen 80 default_server; + server_name _; root /var/www/html/public; - index index.php index.html; + index index.php; charset utf-8; - client_max_body_size 64M; + + # Territory PDF/ZIP imports are the largest uploads; keep this in step with + # upload_max_filesize / post_max_size in docker/php/php.ini. + client_max_body_size 256M; + + # Behind Dokploy's Traefik, the real client IP arrives in X-Forwarded-For. + real_ip_header X-Forwarded-For; + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; + real_ip_recursive on; location / { try_files $uri $uri/ /index.php?$query_string; @@ -14,34 +24,70 @@ server { location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } + # Vite emits content-hashed filenames, so these are safe to cache hard. + location /build/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + # Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public). + # Served directly by nginx rather than round-tripping through PHP. + location /storage/ { + expires 7d; + add_header Cache-Control "public"; + try_files $uri =404; + } + error_page 404 /index.php; - location ~ \.php$ { - fastcgi_pass app:9000; - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + location ~ ^/index\.php(/|$) { + fastcgi_pass 127.0.0.1:9000; + fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param DOCUMENT_ROOT $realpath_root; fastcgi_hide_header X-Powered-By; - # Forward proxy headers so Laravel can validate signed URLs behind reverse proxy - fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; - fastcgi_param HTTP_X_FORWARDED_HOST $http_host; - fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port; + # PDF generation and ZIP imports can outrun the default 60s. + fastcgi_read_timeout 300; + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; - # Trust external proxy proto, or default to https for signed URL validation - set $fwd_proto $http_x_forwarded_proto; - if ($fwd_proto = "") { - set $fwd_proto "https"; - } - fastcgi_param HTTP_X_FORWARDED_PROTO $fwd_proto; + # Set server-side, NOT taken from the client's header: honouring a + # client-supplied X-Forwarded-Proto would let an attacker declare the + # connection plaintext and skew signed-URL validation and secure-cookie + # decisions. The value is written by the entrypoint from APP_URL's + # scheme (see /etc/nginx/conf.d/forwarded-proto.conf) — https in + # production behind Traefik, http for local plain-HTTP development. + fastcgi_param HTTP_X_FORWARDED_PROTO $app_forwarded_proto; + # X-Forwarded-Port is deliberately NOT set here. nginx already passes + # the incoming header through as HTTP_X_FORWARDED_PORT, and Traefik + # sends the real public port; overriding it with $server_port (always + # 80 inside the container) would corrupt generated URLs. + + internal; + } + + # Any other .php file is not a valid entrypoint — Laravel routes everything + # through index.php. This blocks execution of uploaded/stray scripts. + location ~ \.php$ { + return 404; } location ~ /\.(?!well-known).* { deny all; } - # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml image/svg+xml; } diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index fb04585..214401d 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,23 +1,74 @@ -FROM php:8.3-fpm +# syntax=docker/dockerfile:1 -# System dependencies -RUN apt-get update && apt-get install -y \ - git \ - curl \ - netcat-openbsd \ - libpng-dev \ - libjpeg62-turbo-dev \ - libfreetype6-dev \ - libonig-dev \ - libxml2-dev \ - libzip-dev \ - libicu-dev \ - zip \ - unzip \ - supervisor \ - poppler-utils \ +# ============================================================================= +# Stage 1 — PHP dependencies (no dev, production autoloader) +# ============================================================================= +FROM composer:2 AS vendor + +WORKDIR /app + +# --no-scripts: artisan isn't available yet, package discovery would fail. +# Autoload is optimized in the runtime stage once the full source is present. +COPY composer.json composer.lock ./ +RUN composer install \ + --no-interaction \ + --no-dev \ + --prefer-dist \ + --no-progress \ + --no-scripts \ + --ignore-platform-reqs + +# ============================================================================= +# Stage 2 — Frontend assets (Vite + Tailwind) +# ============================================================================= +FROM node:20-alpine AS assets + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then \ + npm ci --no-audit --no-fund; \ + else \ + npm install --no-audit --no-fund; \ + fi + +# Tailwind 4 scans blade/php sources for class names, so they must be present. +COPY vite.config.js ./ +COPY resources ./resources +COPY app ./app +COPY config ./config +RUN npm run build + +# ============================================================================= +# Stage 3 — Runtime +# ============================================================================= +FROM php:8.3-fpm AS runtime + +# nginx + supervisor run in-image alongside php-fpm: a separate nginx container +# would need its own copy of public/ plus the storage volume just to serve +# static files, which is more moving parts than this app warrants. +# poppler-utils provides pdftoppm/pdfinfo used by TerritorioThumbnailService. +# Node is deliberately absent here: assets are built in stage 2. +# The -dev packages are kept rather than purged. Their runtime counterparts +# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so +# purging means pinning package names that break whenever the php base image +# moves to a new Debian. A few tens of MB is worth not having that breakage. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + netcat-openbsd \ + procps \ + nginx \ + supervisor \ + poppler-utils \ + libpng-dev \ + libjpeg62-turbo-dev \ + libfreetype6-dev \ + libonig-dev \ + libxml2-dev \ + libzip-dev \ + libicu-dev \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install \ + && docker-php-ext-install -j"$(nproc)" \ pdo_mysql \ mbstring \ exif \ @@ -32,52 +83,47 @@ RUN apt-get update && apt-get install -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Install Composer +# Kept only so `composer dump-autoload` can run below; harmless at runtime. COPY --from=composer:2 /usr/bin/composer /usr/bin/composer -# Install Node.js 20 LTS -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y nodejs \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini +COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf +COPY docker/nginx/default.conf /etc/nginx/sites-available/default +COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf -# Build application in staging area -WORKDIR /app-src - -# Install PHP dependencies first for better Docker layer caching -COPY composer.json composer.lock ./ -RUN composer install --no-interaction --prefer-dist --no-progress --no-scripts - -# Install Node dependencies first for better Docker layer caching -COPY package.json package-lock.json* ./ -RUN if [ -f package-lock.json ]; then \ - npm ci --no-audit --no-fund; \ - else \ - npm install --no-audit --no-fund; \ - fi - -# Copy full source and build frontend assets -COPY . . -RUN mkdir -p bootstrap/cache storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/app -RUN if [ ! -f .env ] && [ -f .env.example ]; then cp .env.example .env; fi -RUN date -u +%Y%m%d%H%M%S > .image-build-id -RUN composer dump-autoload --optimize --no-interaction -RUN npm run build - -# Runtime working directory WORKDIR /var/www/html -# Copy PHP configuration -COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini +# Everything the app needs is baked into the image. Runtime containers do NOT +# bind-mount the source, so there is no composer/npm work at boot. +COPY --chown=www-data:www-data . . +COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor +COPY --from=assets --chown=www-data:www-data /app/public/build ./public/build -# Copy PHP-FPM pool config -COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf +# Writable paths. storage/ is replaced by a named volume at runtime; this is +# the skeleton the entrypoint repopulates when that volume starts out empty. +RUN mkdir -p \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + storage/app/public \ + bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache \ + && chmod -R 775 storage bootstrap/cache + +RUN composer dump-autoload --optimize --no-dev --no-interaction \ + && php artisan package:discover --ansi -# Copy entrypoint COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh +COPY docker/php/queue-entrypoint.sh /usr/local/bin/queue-entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/queue-entrypoint.sh -EXPOSE 9000 +EXPOSE 80 + +# /up is Laravel's built-in health endpoint (registered in bootstrap/app.php), +# so this exercises the full nginx -> php-fpm -> framework path, not just a port. +HEALTHCHECK --interval=10s --timeout=5s --start-period=120s --retries=12 \ + CMD curl -fsS http://127.0.0.1/up >/dev/null || exit 1 ENTRYPOINT ["entrypoint.sh"] -CMD ["php-fpm"] +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 93ffed2..cfd2e53 100755 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -1,246 +1,205 @@ #!/bin/bash set -Eeuo pipefail -echo "=========================================" -echo " TerManager2 - Entrypoint" -echo "=========================================" +# ============================================================================= +# TerManager2 — application container entrypoint +# +# The image is self-contained: vendor/ and public/build are baked in at build +# time. This script only does what genuinely cannot happen until runtime — +# resolve APP_KEY, wait for the database, migrate, and warm the caches. +# +# Configuration comes from real environment variables (Dokploy injects them). +# No .env file is written; Laravel reads the process environment directly. +# ============================================================================= -warn() { - echo "[!] $*" -} +log() { echo "[*] $*"; } +ok() { echo "[✓] $*"; } +warn() { echo "[!] $*" >&2; } +die() { echo "[✗] $*" >&2; exit 1; } + +echo "=========================================" +echo " TerManager2 — starting" +echo "=========================================" retry() { - local attempts="$1" - local delay="$2" - shift 2 - + local attempts="$1" delay="$2"; shift 2 local n=1 until "$@"; do if [ "$n" -ge "$attempts" ]; then return 1 fi - warn "Command failed (attempt ${n}/${attempts}). Retrying in ${delay}s..." + warn "Attempt ${n}/${attempts} failed. Retrying in ${delay}s..." sleep "$delay" n=$((n + 1)) done } -upsert_env() { - local key="$1" - local value="$2" - local env_file="$3" +# ----------------------------------------------------------------------------- +# 0. Required configuration +# APP_URL anchors every generated link (assignment PDFs, e-mails) and is +# matched against the incoming Host header, so a wrong or missing value +# surfaces as blank pages or 400s much later. Fail here instead, where the +# message is unambiguous. +# ----------------------------------------------------------------------------- +if [ -z "${APP_URL:-}" ]; then + die "APP_URL is not set. Set it to the public URL of the application, e.g. https://territori.example.org" +fi - if grep -q "^${key}=" "$env_file"; then - sed -i "s|^${key}=.*|${key}=${value}|" "$env_file" - else - echo "${key}=${value}" >> "$env_file" - fi -} +case "$APP_URL" in + http://*|https://*) ;; + *) die "APP_URL must include the scheme (https://...), got: ${APP_URL}" ;; +esac -read_env_value() { - local key="$1" - local env_file="$2" +# ----------------------------------------------------------------------------- +# 1. Writable directories +# The storage volume mounts empty on a first deploy, which hides the +# skeleton baked into the image — recreate it every boot. +# ----------------------------------------------------------------------------- +mkdir -p \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + storage/app/public \ + bootstrap/cache - if [ ! -f "$env_file" ]; then - return 0 - fi - - local line - line=$(grep -E "^${key}=" "$env_file" | tail -n 1 || true) - - if [ -z "$line" ]; then - return 0 - fi - - local value="${line#*=}" - value="${value%\"}" - value="${value#\"}" - printf '%s' "$value" -} - -# ----------------------------------------------- -# 0. Application code is bind-mounted from host -# ----------------------------------------------- -echo "[✓] Using bind-mounted application code from host." - -# ----------------------------------------------- -# 0b. Create required directories & fix permissions -# ----------------------------------------------- -mkdir -p storage/framework/{cache,sessions,views} -mkdir -p storage/logs -mkdir -p storage/app -mkdir -p bootstrap/cache if [ "$(id -u)" = "0" ]; then chown -R www-data:www-data storage bootstrap/cache + chmod -R 775 storage bootstrap/cache fi +ok "Storage directories ready." -# ----------------------------------------------- -# 1. .env file (must exist before composer/artisan) -# ----------------------------------------------- -if [ ! -f .env ]; then - echo "[*] Creating .env from .env.example..." - cp .env.example .env -fi - -# Override .env values with Docker environment variables (if set) -# This ensures Dokploy env vars take precedence -if [ -n "${APP_KEY:-}" ]; then - upsert_env "APP_KEY" "${APP_KEY}" .env -fi -if [ -n "${APP_URL:-}" ]; then - upsert_env "APP_URL" "${APP_URL}" .env -fi -if [ -n "${DB_HOST:-}" ]; then - upsert_env "DB_HOST" "${DB_HOST}" .env -fi -if [ -n "${DB_DATABASE:-}" ]; then - upsert_env "DB_DATABASE" "${DB_DATABASE}" .env -fi -if [ -n "${DB_USERNAME:-}" ]; then - upsert_env "DB_USERNAME" "${DB_USERNAME}" .env -fi -if [ -n "${DB_PASSWORD:-}" ]; then - upsert_env "DB_PASSWORD" "${DB_PASSWORD}" .env -fi -if [ -n "${REDIS_HOST:-}" ]; then - upsert_env "REDIS_HOST" "${REDIS_HOST}" .env -fi -if [ -n "${REDIS_PASSWORD:-}" ]; then - upsert_env "REDIS_PASSWORD" "${REDIS_PASSWORD}" .env -fi -echo "[✓] .env file ready." - -# ----------------------------------------------- -# 2. Composer install (must run before artisan commands) -# ----------------------------------------------- -if [ ! -f vendor/autoload.php ]; then - echo "[*] Installing Composer dependencies..." - retry 3 5 composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress -else - echo "[✓] Composer dependencies already installed." -fi - -# ----------------------------------------------- -# 3. Application key (persisted in storage volume) -# ----------------------------------------------- +# ----------------------------------------------------------------------------- +# 2. APP_KEY +# Personal data of proclamatori is encrypted at column level with this key. +# Losing it means losing that data, so a generated key is persisted into the +# storage volume and reused across redeploys. +# ----------------------------------------------------------------------------- KEY_FILE="/var/www/html/storage/app/.app_key" -if [ -f "$KEY_FILE" ]; then - # Restore key from persistent volume - STORED_KEY=$(cat "$KEY_FILE") - upsert_env "APP_KEY" "$STORED_KEY" .env - echo "[✓] Application key restored from storage." -elif grep -q "^APP_KEY=$" .env; then - # No stored key and .env has empty key: generate new one - echo "[*] Generating application key..." - php artisan key:generate --ansi - # Save to persistent volume - grep '^APP_KEY=' .env | cut -d= -f2- > "$KEY_FILE" - echo "[✓] Application key saved to persistent storage." +if [ -n "${APP_KEY:-}" ]; then + ok "APP_KEY provided via environment." + printf '%s' "$APP_KEY" > "$KEY_FILE" +elif [ -s "$KEY_FILE" ]; then + APP_KEY="$(cat "$KEY_FILE")" + export APP_KEY + ok "APP_KEY restored from persistent storage." else - # .env already has a key (from env var override or .env.example), persist it - grep '^APP_KEY=' .env | cut -d= -f2- > "$KEY_FILE" - echo "[✓] Application key already set, saved to persistent storage." + APP_KEY="base64:$(head -c 32 /dev/urandom | base64)" + export APP_KEY + printf '%s' "$APP_KEY" > "$KEY_FILE" + warn "No APP_KEY was provided — generated one and persisted it to the" + warn "storage volume. Copy it into the Dokploy environment now:" + warn " APP_KEY=${APP_KEY}" + warn "If that volume is ever lost, encrypted personal data becomes" + warn "unrecoverable." fi -# ----------------------------------------------- -# 4. NPM install & build assets -# ----------------------------------------------- -if [ ! -d node_modules ]; then - echo "[*] Installing NPM dependencies..." - if [ -f package-lock.json ]; then - retry 3 5 npm ci --no-audit --no-fund +chmod 600 "$KEY_FILE" 2>/dev/null || true +if [ "$(id -u)" = "0" ]; then + chown www-data:www-data "$KEY_FILE" 2>/dev/null || true +fi + +# ----------------------------------------------------------------------------- +# 2b. Forwarded scheme for nginx. +# Derived from APP_URL rather than from the client's X-Forwarded-Proto, +# which is attacker-controlled. https in production behind Traefik; http +# when developing locally over plain HTTP, so redirects don't jump scheme. +# ----------------------------------------------------------------------------- +case "${APP_URL:-}" in + http://*) FORWARDED_PROTO="http" ;; + *) FORWARDED_PROTO="https" ;; +esac + +cat > /etc/nginx/conf.d/forwarded-proto.conf < storage/app/public) +# ----------------------------------------------------------------------------- +if [ ! -e public/storage ]; then + php artisan storage:link --quiet && ok "Storage symlink created." +else + ok "Storage symlink present." +fi + +# ----------------------------------------------------------------------------- +# 4. Drop caches from the previous image before touching the database. +# A stale bootstrap/cache/config.php would otherwise shadow the new env. +# ----------------------------------------------------------------------------- +php artisan config:clear --quiet || warn "config:clear failed; continuing." +php artisan route:clear --quiet || warn "route:clear failed; continuing." +php artisan view:clear --quiet || warn "view:clear failed; continuing." + +# ----------------------------------------------------------------------------- +# 5. Wait for the database, then migrate. +# ----------------------------------------------------------------------------- +log "Waiting for database at ${DB_HOST:-mariadb}:${DB_PORT:-3306}..." +if ! retry 30 2 nc -z "${DB_HOST:-mariadb}" "${DB_PORT:-3306}"; then + die "Database never became reachable. Check DB_HOST/DB_PORT and that the mariadb service is healthy." +fi +ok "Database reachable." + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + log "Running migrations..." + retry 5 3 php artisan migrate --force --no-interaction \ + || die "Migrations failed. Refusing to start with a half-migrated schema." + ok "Migrations applied." +else + echo "[i] RUN_MIGRATIONS=false — skipping migrations." +fi + +# ----------------------------------------------------------------------------- +# 6. Seed reference data (roles, permissions, settings) once. +# The marker lives in the storage volume so redeploys don't reseed. +# ----------------------------------------------------------------------------- +SEED_MARKER="/var/www/html/storage/app/.db_seeded" + +if [ "${RUN_DB_SEED_ON_FIRST_START:-true}" = "true" ] && [ ! -f "$SEED_MARKER" ]; then + log "First start — seeding reference data..." + if retry 3 3 php artisan db:seed --force --no-interaction; then + touch "$SEED_MARKER" + ok "Seed completed." else - retry 3 5 npm install --no-audit --no-fund + warn "Seed failed. The app will start, but roles/permissions may be missing." fi else - echo "[✓] NPM dependencies already installed." + ok "Seed already applied (or disabled)." fi -if [ ! -f public/build/manifest.json ]; then - echo "[*] Building frontend assets..." - npm run build -else - echo "[✓] Frontend assets already built." -fi - -# ----------------------------------------------- -# 5. Storage link -# ----------------------------------------------- -if [ ! -L public/storage ]; then - echo "[*] Creating storage symlink..." - php artisan storage:link -else - echo "[✓] Storage symlink already exists." -fi - -# ----------------------------------------------- -# 6b. Clear any stale cached config from a previous deploy -# (a cached bootstrap/cache/config.php would override the .env above) -# ----------------------------------------------- -echo "[*] Clearing stale cached config..." -php artisan config:clear || warn "config:clear failed; continuing startup." - -# ----------------------------------------------- -# 7. Run migrations -# ----------------------------------------------- -echo "[*] Running database migrations..." -retry 10 3 php artisan migrate --force - -# ----------------------------------------------- -# 7b. Seed database on first container startup only -# ----------------------------------------------- -SEED_MARKER_FILE="/var/www/html/storage/framework/.runtime_db_seeded" -RUN_DB_SEED_ON_FIRST_START="${RUN_DB_SEED_ON_FIRST_START:-true}" - -if [ "$RUN_DB_SEED_ON_FIRST_START" = "true" ]; then - if [ ! -f "$SEED_MARKER_FILE" ]; then - echo "[*] First startup detected. Running database seed..." - retry 5 3 php artisan db:seed --force - touch "$SEED_MARKER_FILE" - echo "[✓] Database seed completed and startup marker saved." +# ----------------------------------------------------------------------------- +# 7. Initial administrator +# ----------------------------------------------------------------------------- +if [ "${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}" = "true" ]; then + if [ -n "${INITIAL_ADMIN_EMAIL:-}" ] && [ -n "${INITIAL_ADMIN_PASSWORD:-}" ]; then + log "Ensuring initial administrator..." + php artisan app:create-initial-admin \ + --name="${INITIAL_ADMIN_NAME:-Amministratore}" \ + --email="${INITIAL_ADMIN_EMAIL}" \ + --password="${INITIAL_ADMIN_PASSWORD}" \ + --no-interaction \ + || warn "Initial admin creation failed (it may already exist)." else - echo "[✓] Seed already executed on a previous startup." + warn "INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD not set — no admin created." + warn "Set them in the Dokploy environment to get a login on first deploy." fi -else - echo "[i] RUN_DB_SEED_ON_FIRST_START=false, skipping automatic seed." fi -# ----------------------------------------------- -# 7c. Ensure initial admin exists (first startup) -# ----------------------------------------------- -ENSURE_INITIAL_ADMIN_ON_EMPTY_DB="${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}" - -INITIAL_ADMIN_NAME_VALUE="${INITIAL_ADMIN_NAME:-$(read_env_value INITIAL_ADMIN_NAME .env)}" -INITIAL_ADMIN_EMAIL_VALUE="${INITIAL_ADMIN_EMAIL:-$(read_env_value INITIAL_ADMIN_EMAIL .env)}" -INITIAL_ADMIN_PASSWORD_VALUE="${INITIAL_ADMIN_PASSWORD:-$(read_env_value INITIAL_ADMIN_PASSWORD .env)}" - -if [ "$ENSURE_INITIAL_ADMIN_ON_EMPTY_DB" = "true" ]; then - echo "[*] Ensuring initial administrator account..." - if ! php artisan app:create-initial-admin \ - --name="$INITIAL_ADMIN_NAME_VALUE" \ - --email="$INITIAL_ADMIN_EMAIL_VALUE" \ - --password="$INITIAL_ADMIN_PASSWORD_VALUE" \ - --no-interaction; then - warn "Initial admin creation failed. Set INITIAL_ADMIN_NAME, INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD." - fi -else - echo "[i] ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=false, skipping initial admin creation check." -fi - -# ----------------------------------------------- -# 8. Cache config/routes/views -# ----------------------------------------------- -echo "[*] Caching configuration..." -php artisan config:cache || warn "config:cache failed; continuing startup." -php artisan route:cache || warn "route:cache failed; continuing startup." -php artisan view:cache || warn "view:cache failed; continuing startup." +# ----------------------------------------------------------------------------- +# 8. Warm caches. Done last, so the config cache captures the final env. +# ----------------------------------------------------------------------------- +log "Warming caches..." +php artisan config:cache --quiet || warn "config:cache failed; continuing." +php artisan route:cache --quiet || warn "route:cache failed; continuing." +php artisan view:cache --quiet || warn "view:cache failed; continuing." +ok "Caches warmed." echo "=========================================" -echo " TerManager2 - Ready!" +echo " TerManager2 — ready" echo "=========================================" -# ----------------------------------------------- -# Execute CMD (default: php-fpm) -# ----------------------------------------------- exec "$@" diff --git a/docker/php/queue-entrypoint.sh b/docker/php/queue-entrypoint.sh new file mode 100644 index 0000000..1a9e36a --- /dev/null +++ b/docker/php/queue-entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -Eeuo pipefail + +# ============================================================================= +# TerManager2 — queue worker entrypoint +# +# Deliberately does NOT migrate or seed: the app container owns schema changes. +# Two containers racing on `artisan migrate` is how you get a half-applied +# schema. This one only waits for its dependencies and starts working. +# ============================================================================= + +warn() { echo "[!] $*" >&2; } + +# APP_KEY must match the app container or encrypted payloads won't decrypt. +KEY_FILE="/var/www/html/storage/app/.app_key" +if [ -z "${APP_KEY:-}" ] && [ -s "$KEY_FILE" ]; then + APP_KEY="$(cat "$KEY_FILE")" + export APP_KEY +fi + +echo "[*] Queue worker waiting for database and redis..." +for _ in $(seq 1 60); do + if nc -z "${DB_HOST:-mariadb}" "${DB_PORT:-3306}" \ + && nc -z "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}"; then + echo "[✓] Dependencies reachable." + break + fi + sleep 2 +done + +# Wait for the app container to finish migrating before consuming jobs, so a +# job doesn't hit a table that doesn't exist yet. +for _ in $(seq 1 60); do + if php artisan migrate:status --no-interaction >/dev/null 2>&1; then + break + fi + sleep 2 +done + +exec "$@" diff --git a/docker/php/www.conf b/docker/php/www.conf index c68d265..0ed3089 100644 --- a/docker/php/www.conf +++ b/docker/php/www.conf @@ -1,7 +1,10 @@ [www] user = www-data group = www-data -listen = 0.0.0.0:9000 + +; nginx now runs in the same container, so php-fpm no longer needs to be +; reachable from the network. Binding to loopback removes that exposure. +listen = 127.0.0.1:9000 listen.owner = www-data listen.group = www-data @@ -11,3 +14,15 @@ pm.start_servers = 4 pm.min_spare_servers = 2 pm.max_spare_servers = 6 pm.max_requests = 500 + +; Surface worker output in `docker logs` instead of swallowing it. +catch_workers_output = yes +decorate_workers_output = no + +; php-fpm sanitizes the environment by default, which would hide every Dokploy +; env var from Laravel. clear_env=no is what makes config-from-env work. +clear_env = no + +; Log slow requests (PDF import/generation) to help diagnose timeouts. +slowlog = /proc/self/fd/2 +request_slowlog_timeout = 30s diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf new file mode 100644 index 0000000..9011815 --- /dev/null +++ b/docker/supervisor/supervisord.conf @@ -0,0 +1,38 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid + +# Both programs log to the container's stdout/stderr so `docker logs` and the +# Dokploy log viewer show everything without shelling into the container. + +[program:php-fpm] +command=php-fpm --nodaemonize --force-stderr +autostart=true +autorestart=true +startretries=3 +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +startretries=3 +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +# If either process dies for good, take the whole container down so Docker's +# restart policy (and Dokploy's health check) can react. Without this the +# container stays "up" while serving nothing. +[eventlistener:process_exit] +command=bash -c "printf 'READY\\n'; while read -r line; do echo \"Process exited: $line\" >&2; kill -SIGTERM $PPID; done < /dev/stdin" +events=PROCESS_STATE_FATAL