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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:49:10 +02:00
parent abd6e5c21b
commit cf78550516
14 changed files with 1657 additions and 1698 deletions

View File

@@ -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 .git
.gitignore .gitignore
.github
# Never bake secrets into an image layer.
.env .env
.env.*
!.env.example
vendor vendor
node_modules node_modules
storage/logs/* storage/logs/*
storage/framework/cache/* storage/framework/cache/*
storage/framework/sessions/* storage/framework/sessions/*
storage/framework/views/* 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/* bootstrap/cache/*
public/build public/build
public/storage public/storage
public/hot
db_data db_data
redis_data redis_data
docker-compose.yml docker-compose.yml
docker-compose.dev.yml
docker-compose.override.yml docker-compose.override.yml
README.md
TerManager2_v2.md *.md
.idea *.sql
.vscode
*.swp *.swp
*.swo *.swo
.idea
.vscode
.phpunit.result.cache

View File

@@ -1,42 +1,88 @@
APP_NAME=TerManager2 # =============================================================================
APP_ENV=local # TerManager2 — environment template
APP_KEY= #
APP_DEBUG=true # LOCAL DEVELOPMENT: copy to .env and adjust.
APP_TIMEZONE=Europe/Rome # cp .env.example .env && docker compose -f docker-compose.dev.yml up -d
APP_URL=https://demo-termanager.pyconetwork.it #
ASSET_URL=https://demo-termanager.pyconetwork.it # 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 APP_NAME=TerManager2
SEED_DEV_DATA=false APP_ENV=production
RUN_DB_SEED_ON_FIRST_START=true APP_DEBUG=false
ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true APP_TIMEZONE=Europe/Rome
INITIAL_ADMIN_NAME=
# [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_EMAIL=
INITIAL_ADMIN_PASSWORD= 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_CONNECTION=mysql
DB_HOST=mariadb DB_HOST=mariadb
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=termanager2 DB_DATABASE=termanager2
DB_USERNAME=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_HOST=redis
REDIS_PASSWORD=redissecret
REDIS_PORT=6379 REDIS_PORT=6379
# [REQUIRED] openssl rand -base64 24
REDIS_PASSWORD=
CACHE_STORE=redis CACHE_STORE=redis
SESSION_DRIVER=redis SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
# -----------------------------------------------------------------------------
# Mail — external SMTP. Use MAIL_MAILER=log to disable delivery entirely.
# -----------------------------------------------------------------------------
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=
MAIL_PORT=1025 MAIL_PORT=587
MAIL_USERNAME=null MAIL_USERNAME=
MAIL_PASSWORD=null MAIL_PASSWORD=
MAIL_ENCRYPTION=null MAIL_SCHEME=tls
MAIL_FROM_ADDRESS="noreply@termanager2.local" MAIL_FROM_ADDRESS=noreply@example.org
MAIL_FROM_NAME="${APP_NAME}" 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 MAILPIT_UI_PORT=8025
SEED_DEV_DATA=false

249
HANDOFF.md Normal file
View File

@@ -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
```

1231
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -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:<permesso>` (o `can:<permesso>`) 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”.

149
docker-compose.dev.yml Normal file
View File

@@ -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

View File

@@ -1,132 +1,186 @@
services: # =============================================================================
app: # 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: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
container_name: termanager2_app
restart: unless-stopped restart: unless-stopped
# Runs as root so the entrypoint can mkdir/chown the bind-mounted storage; # required:false so `docker compose config` and a bare checkout still work;
# php-fpm workers drop to www-data via docker/php/www.conf. # a missing .env then surfaces as the explicit DB_PASSWORD error below.
working_dir: /var/www/html env_file:
- path: .env
required: false
volumes: volumes:
- ./:/var/www/html # Uploaded territory PDFs, thumbnails, logs and the persisted APP_KEY.
- storage_data:/var/www/html/storage
networks: networks:
- termanager2 - 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:
<<: *app
environment:
<<: *app-env
# Sources never change in a built image, so skip the stat() on every hit.
PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0
networks:
- internal
- dokploy-network
depends_on: depends_on:
mariadb: mariadb:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy 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: queue-worker:
build: <<: *app
context: . user: www-data
dockerfile: docker/php/Dockerfile environment:
container_name: termanager2_queue <<: *app-env
restart: unless-stopped PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0
# 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
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
entrypoint: ["php"] entrypoint: ["queue-entrypoint.sh"]
command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"] # --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: 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: mariadb:
image: mariadb:11 image: mariadb:11
container_name: termanager2_db
restart: unless-stopped 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: environment:
MARIADB_DATABASE: ${DB_DATABASE:-termanager2} MARIADB_DATABASE: ${DB_DATABASE:-termanager2}
MARIADB_USER: ${DB_USERNAME:-termanager2} MARIADB_USER: ${DB_USERNAME:-termanager2}
MARIADB_PASSWORD: ${DB_PASSWORD:-secret} MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret} 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: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
networks: networks:
- termanager2 - internal
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 10
start_period: 30s
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: termanager2_redis
restart: unless-stopped 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: volumes:
- redis_data:/data - redis_data:/data
networks: networks:
- termanager2 - internal
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"] test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 10
start_period: 10s
mailpit:
image: axllent/mailpit:latest
container_name: termanager2_mail
restart: unless-stopped
ports:
- "${MAIL_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
networks:
- termanager2
volumes: volumes:
db_data: db_data:
driver: local
redis_data: redis_data:
driver: local storage_data:
networks: networks:
termanager2: # Datastores stay on an isolated network with no route to the outside.
internal:
driver: bridge 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

View File

@@ -1,2 +0,0 @@
FROM nginx:1.25-alpine
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,11 +1,21 @@
server { server {
listen 80; listen 80 default_server;
server_name localhost; server_name _;
root /var/www/html/public; root /var/www/html/public;
index index.php index.html; index index.php;
charset utf-8; 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 / { location / {
try_files $uri $uri/ /index.php?$query_string; try_files $uri $uri/ /index.php?$query_string;
@@ -14,34 +24,70 @@ server {
location = /favicon.ico { access_log off; log_not_found off; } location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { 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; error_page 404 /index.php;
location ~ \.php$ { location ~ ^/index\.php(/|$) {
fastcgi_pass app:9000; fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params; 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; fastcgi_hide_header X-Powered-By;
# Forward proxy headers so Laravel can validate signed URLs behind reverse proxy # PDF generation and ZIP imports can outrun the default 60s.
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; fastcgi_read_timeout 300;
fastcgi_param HTTP_X_FORWARDED_HOST $http_host; fastcgi_buffers 16 16k;
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port; fastcgi_buffer_size 32k;
# Trust external proxy proto, or default to https for signed URL validation # Set server-side, NOT taken from the client's header: honouring a
set $fwd_proto $http_x_forwarded_proto; # client-supplied X-Forwarded-Proto would let an attacker declare the
if ($fwd_proto = "") { # connection plaintext and skew signed-URL validation and secure-cookie
set $fwd_proto "https"; # 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;
} }
fastcgi_param HTTP_X_FORWARDED_PROTO $fwd_proto;
# 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).* { location ~ /\.(?!well-known).* {
deny all; deny all;
} }
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" 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; 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;
} }

View File

@@ -1,10 +1,65 @@
FROM php:8.3-fpm # syntax=docker/dockerfile:1
# System dependencies # =============================================================================
RUN apt-get update && apt-get install -y \ # Stage 1 — PHP dependencies (no dev, production autoloader)
git \ # =============================================================================
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 \ curl \
netcat-openbsd \ netcat-openbsd \
procps \
nginx \
supervisor \
poppler-utils \
libpng-dev \ libpng-dev \
libjpeg62-turbo-dev \ libjpeg62-turbo-dev \
libfreetype6-dev \ libfreetype6-dev \
@@ -12,12 +67,8 @@ RUN apt-get update && apt-get install -y \
libxml2-dev \ libxml2-dev \
libzip-dev \ libzip-dev \
libicu-dev \ libicu-dev \
zip \
unzip \
supervisor \
poppler-utils \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \ && docker-php-ext-install -j"$(nproc)" \
pdo_mysql \ pdo_mysql \
mbstring \ mbstring \
exif \ exif \
@@ -32,52 +83,47 @@ RUN apt-get update && apt-get install -y \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && 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 COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install Node.js 20 LTS COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf
&& apt-get install -y nodejs \ COPY docker/nginx/default.conf /etc/nginx/sites-available/default
&& apt-get clean \ COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
&& rm -rf /var/lib/apt/lists/*
# 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 WORKDIR /var/www/html
# Copy PHP configuration # Everything the app needs is baked into the image. Runtime containers do NOT
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini # 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 # Writable paths. storage/ is replaced by a named volume at runtime; this is
COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf # 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 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"] ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,246 +1,205 @@
#!/bin/bash #!/bin/bash
set -Eeuo pipefail set -Eeuo pipefail
echo "=========================================" # =============================================================================
echo " TerManager2 - Entrypoint" # TerManager2 — application container entrypoint
echo "=========================================" #
# 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() { log() { echo "[*] $*"; }
echo "[!] $*" ok() { echo "[✓] $*"; }
} warn() { echo "[!] $*" >&2; }
die() { echo "[✗] $*" >&2; exit 1; }
echo "========================================="
echo " TerManager2 — starting"
echo "========================================="
retry() { retry() {
local attempts="$1" local attempts="$1" delay="$2"; shift 2
local delay="$2"
shift 2
local n=1 local n=1
until "$@"; do until "$@"; do
if [ "$n" -ge "$attempts" ]; then if [ "$n" -ge "$attempts" ]; then
return 1 return 1
fi fi
warn "Command failed (attempt ${n}/${attempts}). Retrying in ${delay}s..." warn "Attempt ${n}/${attempts} failed. Retrying in ${delay}s..."
sleep "$delay" sleep "$delay"
n=$((n + 1)) n=$((n + 1))
done done
} }
upsert_env() { # -----------------------------------------------------------------------------
local key="$1" # 0. Required configuration
local value="$2" # APP_URL anchors every generated link (assignment PDFs, e-mails) and is
local env_file="$3" # 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 case "$APP_URL" in
sed -i "s|^${key}=.*|${key}=${value}|" "$env_file" http://*|https://*) ;;
else *) die "APP_URL must include the scheme (https://...), got: ${APP_URL}" ;;
echo "${key}=${value}" >> "$env_file" esac
fi
}
read_env_value() { # -----------------------------------------------------------------------------
local key="$1" # 1. Writable directories
local env_file="$2" # 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 if [ "$(id -u)" = "0" ]; then
chown -R www-data:www-data storage bootstrap/cache chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
fi fi
ok "Storage directories ready."
# ----------------------------------------------- # -----------------------------------------------------------------------------
# 1. .env file (must exist before composer/artisan) # 2. APP_KEY
# ----------------------------------------------- # Personal data of proclamatori is encrypted at column level with this key.
if [ ! -f .env ]; then # Losing it means losing that data, so a generated key is persisted into the
echo "[*] Creating .env from .env.example..." # storage volume and reused across redeploys.
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)
# -----------------------------------------------
KEY_FILE="/var/www/html/storage/app/.app_key" KEY_FILE="/var/www/html/storage/app/.app_key"
if [ -f "$KEY_FILE" ]; then if [ -n "${APP_KEY:-}" ]; then
# Restore key from persistent volume ok "APP_KEY provided via environment."
STORED_KEY=$(cat "$KEY_FILE") printf '%s' "$APP_KEY" > "$KEY_FILE"
upsert_env "APP_KEY" "$STORED_KEY" .env elif [ -s "$KEY_FILE" ]; then
echo "[✓] Application key restored from storage." APP_KEY="$(cat "$KEY_FILE")"
elif grep -q "^APP_KEY=$" .env; then export APP_KEY
# No stored key and .env has empty key: generate new one ok "APP_KEY restored from persistent storage."
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."
else else
# .env already has a key (from env var override or .env.example), persist it APP_KEY="base64:$(head -c 32 /dev/urandom | base64)"
grep '^APP_KEY=' .env | cut -d= -f2- > "$KEY_FILE" export APP_KEY
echo "[✓] Application key already set, saved to persistent storage." 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 fi
# ----------------------------------------------- chmod 600 "$KEY_FILE" 2>/dev/null || true
# 4. NPM install & build assets if [ "$(id -u)" = "0" ]; then
# ----------------------------------------------- chown www-data:www-data "$KEY_FILE" 2>/dev/null || true
if [ ! -d node_modules ]; then fi
echo "[*] Installing NPM dependencies..."
if [ -f package-lock.json ]; then # -----------------------------------------------------------------------------
retry 3 5 npm ci --no-audit --no-fund # 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 <<EOF
map \$host \$app_forwarded_proto {
default ${FORWARDED_PROTO};
}
EOF
ok "Forwarded scheme set to ${FORWARDED_PROTO}."
# -----------------------------------------------------------------------------
# 3. Storage symlink (public/storage -> 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 else
retry 3 5 npm install --no-audit --no-fund warn "Seed failed. The app will start, but roles/permissions may be missing."
fi fi
else else
echo "[✓] NPM dependencies already installed." ok "Seed already applied (or disabled)."
fi fi
if [ ! -f public/build/manifest.json ]; then # -----------------------------------------------------------------------------
echo "[*] Building frontend assets..." # 7. Initial administrator
npm run build # -----------------------------------------------------------------------------
else if [ "${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}" = "true" ]; then
echo "[✓] Frontend assets already built." if [ -n "${INITIAL_ADMIN_EMAIL:-}" ] && [ -n "${INITIAL_ADMIN_PASSWORD:-}" ]; then
fi log "Ensuring initial administrator..."
php artisan app:create-initial-admin \
# ----------------------------------------------- --name="${INITIAL_ADMIN_NAME:-Amministratore}" \
# 5. Storage link --email="${INITIAL_ADMIN_EMAIL}" \
# ----------------------------------------------- --password="${INITIAL_ADMIN_PASSWORD}" \
if [ ! -L public/storage ]; then --no-interaction \
echo "[*] Creating storage symlink..." || warn "Initial admin creation failed (it may already exist)."
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."
else 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 fi
else
echo "[i] RUN_DB_SEED_ON_FIRST_START=false, skipping automatic seed."
fi fi
# ----------------------------------------------- # -----------------------------------------------------------------------------
# 7c. Ensure initial admin exists (first startup) # 8. Warm caches. Done last, so the config cache captures the final env.
# ----------------------------------------------- # -----------------------------------------------------------------------------
ENSURE_INITIAL_ADMIN_ON_EMPTY_DB="${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}" log "Warming caches..."
php artisan config:cache --quiet || warn "config:cache failed; continuing."
INITIAL_ADMIN_NAME_VALUE="${INITIAL_ADMIN_NAME:-$(read_env_value INITIAL_ADMIN_NAME .env)}" php artisan route:cache --quiet || warn "route:cache failed; continuing."
INITIAL_ADMIN_EMAIL_VALUE="${INITIAL_ADMIN_EMAIL:-$(read_env_value INITIAL_ADMIN_EMAIL .env)}" php artisan view:cache --quiet || warn "view:cache failed; continuing."
INITIAL_ADMIN_PASSWORD_VALUE="${INITIAL_ADMIN_PASSWORD:-$(read_env_value INITIAL_ADMIN_PASSWORD .env)}" ok "Caches warmed."
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."
echo "=========================================" echo "========================================="
echo " TerManager2 - Ready!" echo " TerManager2 — ready"
echo "=========================================" echo "========================================="
# -----------------------------------------------
# Execute CMD (default: php-fpm)
# -----------------------------------------------
exec "$@" exec "$@"

View File

@@ -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 "$@"

View File

@@ -1,7 +1,10 @@
[www] [www]
user = www-data user = www-data
group = 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.owner = www-data
listen.group = www-data listen.group = www-data
@@ -11,3 +14,15 @@ pm.start_servers = 4
pm.min_spare_servers = 2 pm.min_spare_servers = 2
pm.max_spare_servers = 6 pm.max_spare_servers = 6
pm.max_requests = 500 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

View File

@@ -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