Compare commits
2 Commits
abd6e5c21b
...
0502fad996
| Author | SHA1 | Date | |
|---|---|---|---|
| 0502fad996 | |||
| cf78550516 |
@@ -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
|
||||
|
||||
88
.env.example
88
.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
|
||||
|
||||
249
HANDOFF.md
Normal file
249
HANDOFF.md
Normal 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
|
||||
```
|
||||
@@ -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”.
|
||||
@@ -22,7 +22,11 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
public int $timeout = 0;
|
||||
|
||||
// A bounded timeout, not 0. With no limit a single PDF that hangs the
|
||||
// renderer blocks the worker forever and the import state never leaves
|
||||
// "running", so the UI polls a job that will never finish.
|
||||
public int $timeout = 900;
|
||||
|
||||
public function __construct(
|
||||
public string $importId,
|
||||
@@ -115,7 +119,14 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
$thumbnailService->delete($territorio->thumbnail_path);
|
||||
}
|
||||
|
||||
$storedFilename = Str::slug('territorio-' . $territorio->numero) . '.pdf';
|
||||
// The territory id is part of the filename because the slug
|
||||
// alone is not unique: "1 A" and "1-A" are distinct
|
||||
// territory numbers that both slug to "territorio-1-a".
|
||||
// Without it the second import overwrites the first's PDF
|
||||
// and both rows point at one file — then removing the PDF
|
||||
// from either territory breaks the other.
|
||||
$storedFilename = Str::slug('territorio-' . $territorio->numero)
|
||||
. '-' . $territorio->id . '.pdf';
|
||||
$publicPath = 'territori-pdf/' . $storedFilename;
|
||||
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
|
||||
$thumbnailPath = $thumbnailService->generate($publicPath);
|
||||
@@ -166,6 +177,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the job exceeds $timeout or dies outside handle()'s try block.
|
||||
*
|
||||
* Without this the import state is stranded in "running" — the UI polls it
|
||||
* until the 24h cache entry expires — and the uploaded temp directory is
|
||||
* never reclaimed, since handle()'s finally never executes.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$stateService = app(TerritorioPdfImportState::class);
|
||||
|
||||
$stateService->appendLog(
|
||||
$this->importId,
|
||||
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||
);
|
||||
$stateService->markFailed($this->importId);
|
||||
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
|
||||
}
|
||||
|
||||
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
|
||||
{
|
||||
if ($territoriMap === []) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
@@ -11,7 +12,14 @@ class Rientra extends Component
|
||||
public Assegnazione $assegnazione;
|
||||
public string $returned_at = '';
|
||||
public bool $showCampaignPrompt = false;
|
||||
|
||||
// Derived server-side by checkCampaign() and never bound in the view.
|
||||
// Without #[Locked] the client can inject an arbitrary campaign id into
|
||||
// the update payload and have the assignment counted toward a campaign it
|
||||
// has no date overlap with (or hit an FK violation with a bogus id).
|
||||
#[Locked]
|
||||
public ?int $campagna_id = null;
|
||||
|
||||
public bool $counted_in_campaign = false;
|
||||
|
||||
public function mount(Assegnazione $assegnazione)
|
||||
@@ -62,6 +70,12 @@ class Rientra extends Component
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
// mount() rejects an already-returned assignment, but the component
|
||||
// state persists across requests — two open tabs would both submit and
|
||||
// the second would overwrite returned_at/returned_by and log a
|
||||
// duplicate activity entry. Re-check against the current row.
|
||||
abort_if($this->assegnazione->fresh()?->returned_at !== null, 409, 'Assegnazione già rientrata.');
|
||||
|
||||
$returnDate = \Carbon\Carbon::parse($this->returned_at);
|
||||
|
||||
$this->assegnazione->update([
|
||||
|
||||
@@ -68,7 +68,12 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = Proclamatore::query();
|
||||
// withCount rather than letting the view call
|
||||
// ->assegnazioni()->aperte()->count() per row, which issued one
|
||||
// COUNT(*) per displayed proclamatore on every render.
|
||||
$query = Proclamatore::query()->withCount([
|
||||
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
|
||||
]);
|
||||
|
||||
if ($this->search !== '') {
|
||||
// In-memory filter because nome/cognome are encrypted
|
||||
|
||||
@@ -54,8 +54,26 @@ class Registro extends Component
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns that may be sorted on.
|
||||
*
|
||||
* $sortField reaches orderBy() directly, and it is a public Livewire
|
||||
* property, so the client controls it. Laravel's grammar prevents SQL
|
||||
* injection, but an unknown column still raises "Column not found" and
|
||||
* returns a 500 — trivially triggerable from the browser console.
|
||||
*/
|
||||
private const SORTABLE_FIELDS = [
|
||||
'territorio_numero',
|
||||
'assigned_at',
|
||||
'returned_at',
|
||||
];
|
||||
|
||||
public function sortBy(string $field)
|
||||
{
|
||||
if (! in_array($field, self::SORTABLE_FIELDS, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sortField === $field) {
|
||||
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
@@ -179,13 +197,19 @@ class Registro extends Component
|
||||
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia));
|
||||
}
|
||||
|
||||
if ($this->sortField === 'territorio_numero') {
|
||||
// Re-check here too: sortBy() guards the action, but the property can
|
||||
// also be set directly in a Livewire update payload.
|
||||
$sortField = in_array($this->sortField, self::SORTABLE_FIELDS, true)
|
||||
? $this->sortField
|
||||
: 'territorio_numero';
|
||||
$dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
if ($sortField === 'territorio_numero') {
|
||||
$query->orderByRaw(
|
||||
"CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir"
|
||||
);
|
||||
} else {
|
||||
$query->orderBy($this->sortField, $this->sortDirection);
|
||||
$query->orderBy($sortField, $dir);
|
||||
}
|
||||
|
||||
// In-memory search for encrypted proclamatore fields / territorio numero
|
||||
|
||||
@@ -72,6 +72,11 @@ class XmlExchange extends Component
|
||||
|
||||
$this->pdfFolder = [];
|
||||
|
||||
$this->dispatchPdfImport(
|
||||
$importId,
|
||||
$storedFiles,
|
||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
|
||||
);
|
||||
}
|
||||
|
||||
public function refreshPdfImportStatus(): void
|
||||
@@ -197,6 +202,12 @@ class XmlExchange extends Component
|
||||
AnnoTeocratico::query()->delete();
|
||||
Setting::query()->delete();
|
||||
|
||||
// The mass delete above fires no model events, so Setting's
|
||||
// memoized instance still points at the deleted row. Without this
|
||||
// the update() below would target a nonexistent id, match zero
|
||||
// rows, and silently leave the app with no settings at all.
|
||||
Setting::flushCache();
|
||||
|
||||
$settingsNode = $xml->settings;
|
||||
if ($settingsNode) {
|
||||
Setting::instance()->update([
|
||||
|
||||
@@ -123,7 +123,16 @@ class TerritorioIndex extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']);
|
||||
// ultimaAssegnazione is eager-loaded because getGiorniGiacenzaAttribute()
|
||||
// and is_prioritario read it for every row — and this query fetches the
|
||||
// whole table (pagination happens in memory below), so lazy-loading it
|
||||
// meant one extra query per territory on every render.
|
||||
$query = Territorio::with([
|
||||
'zona',
|
||||
'tipologia',
|
||||
'assegnazioneCorrente.proclamatore',
|
||||
'ultimaAssegnazione',
|
||||
]);
|
||||
|
||||
if ($this->search) {
|
||||
$search = $this->search;
|
||||
|
||||
@@ -47,6 +47,19 @@ class Setting extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the memoized instance.
|
||||
*
|
||||
* Mass operations (Setting::query()->delete(), truncate, raw SQL) do not
|
||||
* fire model events, so the `saved`/`deleted` hooks above can't catch
|
||||
* them and the cache would keep serving a row that no longer exists.
|
||||
* Call this explicitly after any such operation.
|
||||
*/
|
||||
public static function flushCache(): void
|
||||
{
|
||||
static::$cachedInstance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton settings instance (first row).
|
||||
*/
|
||||
|
||||
@@ -4,8 +4,9 @@ namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
use RuntimeException;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -16,16 +17,57 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Force HTTPS when behind a reverse proxy
|
||||
if (str_starts_with(config('app.url'), 'https://')) {
|
||||
// Pin generated URLs to APP_URL.
|
||||
//
|
||||
// Behind a trusted proxy, Symfony derives the port from
|
||||
// X-Forwarded-Port and falls back to the scheme's default when that
|
||||
// header is absent — which silently drops a non-standard port from
|
||||
// every generated link. Anchoring the root to APP_URL makes the
|
||||
// public address explicit instead of reconstructed per request, and
|
||||
// keeps PDF/e-mail links correct regardless of proxy behaviour.
|
||||
$appUrl = (string) config('app.url');
|
||||
|
||||
if ($appUrl !== '') {
|
||||
URL::forceRootUrl($appUrl);
|
||||
|
||||
if (str_starts_with($appUrl, 'https://')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Auto-generate APP_KEY if missing
|
||||
if (empty(config('app.key'))) {
|
||||
Artisan::call('key:generate', ['--force' => true]);
|
||||
}
|
||||
|
||||
// NOTE: APP_KEY is deliberately NOT auto-generated here.
|
||||
//
|
||||
// This provider boots on every request. Generating a key at runtime
|
||||
// meant that if the key ever failed to persist, each request could
|
||||
// mint a *different* one — and since proclamatori names are encrypted
|
||||
// at column level with APP_KEY, every row written under a superseded
|
||||
// key becomes permanently unreadable.
|
||||
//
|
||||
// The key is now resolved once at container start (docker/php/
|
||||
// entrypoint.sh) and persisted to the storage volume. Fail loudly
|
||||
// instead of silently rotating.
|
||||
// Only enforced when serving HTTP. Build-time tooling (package:discover,
|
||||
// config:cache) legitimately runs before the key exists, and failing
|
||||
// there would break the image build rather than surface a real problem.
|
||||
if (empty(config('app.key')) && ! $this->app->runningInConsole()) {
|
||||
throw new RuntimeException(
|
||||
'APP_KEY is not set. Set it in the environment before starting the application. '
|
||||
.'Generate one with: php artisan key:generate --show'
|
||||
);
|
||||
}
|
||||
|
||||
// Livewire only re-applies a fixed allow-list of middleware to
|
||||
// /livewire/update requests. Spatie's permission middleware is not on
|
||||
// that list, so a component whose route is guarded by
|
||||
// `permission:territori.manage` would keep accepting mutating actions
|
||||
// from a user whose permission was revoked after page load, until they
|
||||
// reload. Registering them as persistent closes that window.
|
||||
Livewire::addPersistentMiddleware([
|
||||
\Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
\Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
\Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||
]);
|
||||
|
||||
Gate::before(function ($user, $ability) {
|
||||
return $user->hasRole('amministratore') ? true : null;
|
||||
});
|
||||
|
||||
@@ -4,12 +4,19 @@ namespace App\Services;
|
||||
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
class TerritorioPdfImportDispatcher
|
||||
{
|
||||
/** Per-entry uncompressed ceiling, matching the 10 MB Livewire upload rule. */
|
||||
private const MAX_ENTRY_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Caps the job payload — every entry is serialized into one queue message. */
|
||||
private const MAX_ENTRIES = 2000;
|
||||
|
||||
public function __construct(
|
||||
protected TerritorioPdfImportState $stateService,
|
||||
) {
|
||||
@@ -56,6 +63,25 @@ class TerritorioPdfImportDispatcher
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entryIndex >= self::MAX_ENTRIES) {
|
||||
throw new RuntimeException(
|
||||
'Lo ZIP contiene più di ' . self::MAX_ENTRIES . ' PDF. Suddividilo in archivi più piccoli.'
|
||||
);
|
||||
}
|
||||
|
||||
// Check the *uncompressed* size before reading. getFromIndex()
|
||||
// inflates the entry entirely into memory, so a small zip with
|
||||
// one hugely compressible entry would otherwise exhaust PHP's
|
||||
// memory limit and abort the request mid-extraction.
|
||||
$uncompressedSize = $zip->statIndex($index)['size'] ?? 0;
|
||||
if ($uncompressedSize > self::MAX_ENTRY_BYTES) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Il file "%s" nello ZIP supera %d MB.',
|
||||
basename($entryName),
|
||||
self::MAX_ENTRY_BYTES / 1048576
|
||||
));
|
||||
}
|
||||
|
||||
$content = $zip->getFromIndex($index);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
@@ -74,6 +100,12 @@ class TerritorioPdfImportDispatcher
|
||||
|
||||
$entryIndex++;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
// Nothing was queued, so the job's own cleanup will never run —
|
||||
// drop the partially extracted files rather than orphaning them.
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class TerritorioThumbnailService
|
||||
{
|
||||
/**
|
||||
* Wall-clock limit for pdftoppm. A malformed PDF can make it spin
|
||||
* indefinitely; unbounded, that wedges the queue worker for good.
|
||||
*/
|
||||
private const RENDER_TIMEOUT_SECONDS = 60;
|
||||
|
||||
|
||||
/**
|
||||
* Generate a PNG thumbnail of the first page of a PDF stored on the public disk.
|
||||
*
|
||||
@@ -22,11 +32,26 @@ class TerritorioThumbnailService
|
||||
|
||||
$tempPrefix = sys_get_temp_dir() . '/terr_thumb_' . uniqid();
|
||||
|
||||
exec(
|
||||
'pdftoppm -r 72 -png -l 1 ' . escapeshellarg($pdfAbsPath) . ' ' . escapeshellarg($tempPrefix),
|
||||
$output,
|
||||
$exitCode
|
||||
);
|
||||
$process = new Process([
|
||||
'pdftoppm', '-r', '72', '-png', '-l', '1', $pdfAbsPath, $tempPrefix,
|
||||
]);
|
||||
$process->setTimeout(self::RENDER_TIMEOUT_SECONDS);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException) {
|
||||
Log::warning('pdftoppm timed out generating a territory thumbnail.', [
|
||||
'pdf' => $pdfStoragePath,
|
||||
'timeout' => self::RENDER_TIMEOUT_SECONDS,
|
||||
]);
|
||||
|
||||
// Clean up whatever partial pages it managed to write.
|
||||
foreach (glob($tempPrefix . '*.png') ?: [] as $partial) {
|
||||
@unlink($partial);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// pdftoppm may produce -1.png, -01.png or even -001.png depending on page count
|
||||
$generated = null;
|
||||
|
||||
@@ -12,14 +12,30 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
|
||||
// set an arbitrary Host and have route()/url() emit links pointing at
|
||||
// an attacker's domain — including the public assignment PDF links,
|
||||
// which carry a valid access code.
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
headers: Request::HEADER_X_FORWARDED_FOR
|
||||
| Request::HEADER_X_FORWARDED_HOST
|
||||
| Request::HEADER_X_FORWARDED_PORT
|
||||
| Request::HEADER_X_FORWARDED_PROTO
|
||||
);
|
||||
|
||||
// Second line of defence: only serve requests whose Host matches
|
||||
// APP_URL. Relaxed outside production so local access by IP works.
|
||||
$middleware->trustHosts(at: function () {
|
||||
$host = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
// localhost/127.0.0.1 are kept for container-internal probes: the
|
||||
// Docker health check and Dokploy's own checks address the
|
||||
// container directly, not through the public domain. They are not
|
||||
// an external hole — Traefik only forwards requests whose Host
|
||||
// matches a configured domain.
|
||||
return array_values(array_filter([$host, 'localhost', '127.0.0.1']));
|
||||
}, subdomains: false);
|
||||
|
||||
$middleware->alias([
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
|
||||
@@ -15,7 +15,10 @@ return [
|
||||
'cookie' => env('SESSION_COOKIE', 'termanager2_session'),
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
// Defaults to true outside local: the app is served over HTTPS via the
|
||||
// reverse proxy, and without the Secure flag the session cookie would be
|
||||
// transmitted in cleartext on any plain-HTTP request to the same host.
|
||||
'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV') !== 'local'),
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
@@ -7,20 +7,28 @@ use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* Guarded on both sides because two follow-up migrations used to re-add
|
||||
* this same column. They have been removed, but a database that already
|
||||
* ran them may have the column in either state, and an unguarded
|
||||
* add/drop would abort the whole migration run.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->string('thumbnail_path')->nullable()->after('pdf_path');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropColumn('thumbnail_path');
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->string('thumbnail_path')->nullable()->after('pdf_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropColumn('thumbnail_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->string('thumbnail_path')->nullable()->after('pdf_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropColumn('thumbnail_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// assignment_link_ttl_hours now holds MONTHS (renamed in spirit by
|
||||
// 2026_04_08_123500, which converted the rows but left the column
|
||||
// default at 24). A settings row created without an explicit value
|
||||
// therefore got 24 *months* — public PDF links valid for two years
|
||||
// instead of the intended one month.
|
||||
// ---------------------------------------------------------------
|
||||
DB::table('settings')
|
||||
->where('assignment_link_ttl_hours', 24)
|
||||
->update(['assignment_link_ttl_hours' => 1]);
|
||||
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->unsignedInteger('assignment_link_ttl_hours')->default(1)->change();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Indexes. Every Eloquent query on these soft-deleting models appends
|
||||
// `deleted_at is null`, and the dashboard/listing scopes filter on
|
||||
// attivo/prioritario and range-scan assigned_at — all unindexed.
|
||||
// ---------------------------------------------------------------
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->index(['attivo', 'deleted_at'], 'territori_attivo_deleted_at_index');
|
||||
$table->index('prioritario', 'territori_prioritario_index');
|
||||
});
|
||||
|
||||
Schema::table('proclamatori', function (Blueprint $table) {
|
||||
$table->index('deleted_at', 'proclamatori_deleted_at_index');
|
||||
});
|
||||
|
||||
// (territorio_id, returned_at) is already indexed by the create
|
||||
// migration; only the assigned_at range scans are missing.
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->index('assigned_at', 'assegnazioni_assigned_at_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->dropIndex('assegnazioni_assigned_at_index');
|
||||
});
|
||||
|
||||
Schema::table('proclamatori', function (Blueprint $table) {
|
||||
$table->dropIndex('proclamatori_deleted_at_index');
|
||||
});
|
||||
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropIndex('territori_prioritario_index');
|
||||
$table->dropIndex('territori_attivo_deleted_at_index');
|
||||
});
|
||||
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->unsignedInteger('assignment_link_ttl_hours')->default(24)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
@@ -11,6 +12,13 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([RolesAndPermissionsSeeder::class]);
|
||||
|
||||
// Materialize the settings singleton up front. Setting::instance()
|
||||
// falls back to firstOrCreate() on read, and read paths run on every
|
||||
// page (Territorio scopes and accessors) — so on a fresh database two
|
||||
// concurrent first requests can each insert a row, leaving two
|
||||
// "singletons" with edits landing in whichever one wins.
|
||||
Setting::instance();
|
||||
|
||||
if (App::environment('local') && env('SEED_DEV_DATA', false)) {
|
||||
$this->call([DevSeeder::class]);
|
||||
}
|
||||
|
||||
@@ -31,13 +31,23 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
Permission::firstOrCreate(['name' => $permission]);
|
||||
}
|
||||
|
||||
// givePermissionTo() rather than syncPermissions(): sync *detaches*
|
||||
// anything not in these lists. This seeder can re-run against a
|
||||
// populated database (the deploy marker lives on a volume that may be
|
||||
// recreated), and syncing would silently revoke every permission an
|
||||
// administrator had granted through the UI. Granting is additive and
|
||||
// therefore safe to repeat.
|
||||
//
|
||||
// These lists are the baseline each role starts with, not a
|
||||
// continuously enforced contract.
|
||||
|
||||
// Amministratore: all permissions
|
||||
$admin = Role::firstOrCreate(['name' => 'amministratore']);
|
||||
$admin->syncPermissions($permissions);
|
||||
$admin->givePermissionTo($permissions);
|
||||
|
||||
// Assistente: proclamatori, campagne, assign, return
|
||||
$assistente = Role::firstOrCreate(['name' => 'assistente']);
|
||||
$assistente->syncPermissions([
|
||||
$assistente->givePermissionTo([
|
||||
'proclamatori.manage',
|
||||
'campagne.manage',
|
||||
'territori.assign',
|
||||
@@ -47,7 +57,7 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
|
||||
// Operatore: assign and return only
|
||||
$operatore = Role::firstOrCreate(['name' => 'operatore']);
|
||||
$operatore->syncPermissions([
|
||||
$operatore->givePermissionTo([
|
||||
'territori.assign',
|
||||
'territori.return',
|
||||
]);
|
||||
|
||||
149
docker-compose.dev.yml
Normal file
149
docker-compose.dev.yml
Normal 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
|
||||
@@ -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:
|
||||
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
|
||||
# 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:
|
||||
- ./:/var/www/html
|
||||
# Uploaded territory PDFs, thumbnails, logs and the persisted APP_KEY.
|
||||
- storage_data:/var/www/html/storage
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
FROM nginx:1.25-alpine
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/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";
|
||||
# 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;
|
||||
}
|
||||
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).* {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,65 @@
|
||||
FROM php:8.3-fpm
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# System dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
# =============================================================================
|
||||
# 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 \
|
||||
@@ -12,12 +67,8 @@ RUN apt-get update && apt-get install -y \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
libicu-dev \
|
||||
zip \
|
||||
unzip \
|
||||
supervisor \
|
||||
poppler-utils \
|
||||
&& 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"]
|
||||
|
||||
@@ -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 <<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
|
||||
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 "$@"
|
||||
|
||||
40
docker/php/queue-entrypoint.sh
Normal file
40
docker/php/queue-entrypoint.sh
Normal 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 "$@"
|
||||
@@ -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
|
||||
|
||||
38
docker/supervisor/supervisord.conf
Normal file
38
docker/supervisor/supervisord.conf
Normal 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
|
||||
@@ -55,7 +55,7 @@
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio">
|
||||
|
||||
Reference in New Issue
Block a user