Compare commits

...

2 Commits

Author SHA1 Message Date
0502fad996 Correggi bug di sicurezza e integrita' dei dati
I due piu' gravi potevano causare perdita di dati silenziosa.

AppServiceProvider chiamava key:generate --force a ogni richiesta quando
APP_KEY risultava vuota. Poiche' nome e cognome dei proclamatori sono
cifrati con quella chiave, ogni riga scritta sotto una chiave poi
sostituita diventava irrecuperabile. La chiave e' ora risolta una sola
volta all'avvio del container e il provider solleva un'eccezione se manca,
invece di rigenerarla.

importTerritoryPdfFolder() caricava i PDF, costruiva l'elenco dei file e
poi lo scartava senza mai accodare il job: nessun errore, nessun territorio
aggiornato, e i file restavano su disco a crescita illimitata perche' la
pulizia viveva solo nel finally del job mai eseguito.

Sicurezza:
- i permessi non venivano riapplicati alle richieste /livewire/update, per
  cui un permesso revocato restava attivo fino al reload della pagina
- host header injection: nginx passava l'Host del client come
  X-Forwarded-Host e Laravel si fidava di tutti i proxy, abbastanza per far
  generare a route() link verso domini arbitrari, inclusi i PDF pubblici
  che contengono un codice di accesso valido
- lo schema forwarded e' ora deciso dal server e non dall'header del client
- campagna_id in Rientra e' #[Locked]: era derivato lato server ma
  manipolabile dal client, permettendo di conteggiare un rientro su una
  campagna arbitraria
- sortField del registro passava non validato a orderBy(), con 500 su
  colonna inesistente
- SESSION_SECURE_COOKIE ora vale true fuori da local
- limiti su dimensione e numero di voci negli import ZIP

Integrita' e prestazioni:
- il default di assignment_link_ttl_hours era rimasto 24 dopo la
  conversione da ore a mesi: i link PDF pubblici duravano due anni invece
  di uno mese
- il seeder usava syncPermissions(), che a un secondo giro azzerava i
  permessi personalizzati dei ruoli; ora e' additivo
- pdftoppm senza timeout poteva bloccare il worker indefinitamente
- nomi file deterministici nell'import: "1 A" e "1-A" scrivevano sullo
  stesso PDF, sovrascrivendosi
- Setting::flushCache() dopo le cancellazioni massive, che non emettono
  eventi e lasciavano la cache puntata a una riga inesistente
- riga settings creata dal seeder, per evitare due singleton concorrenti
- rimosse due migrazioni ridondanti che rompevano migrate:rollback
- indici su assigned_at, (attivo, deleted_at) e deleted_at
- risolti gli N+1 negli elenchi territori e proclamatori

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:49:42 +02:00
cf78550516 Ristruttura il deploy: immagine autosufficiente per Dokploy
Il Dockerfile compilava l'applicazione in /app-src mentre il runtime girava
da /var/www/html, popolato solo dal bind mount del sorgente: l'immagine
costruita non veniva mai usata. L'entrypoint rieseguiva composer install e
npm run build a ogni avvio, dentro il container. Su Dokploy, senza sorgente
sull'host, questo significava build a runtime a ogni deploy.

Ora vendor/ e public/build sono compilati in stage separati e copiati
nell'immagine finale. I container di produzione non montano il sorgente e
non eseguono build all'avvio: un redeploy e' build, migrazioni, online.

- Dockerfile multi-stage (composer, node, runtime)
- nginx e php-fpm nello stesso container via supervisor: con container
  separati nginx avrebbe bisogno di una copia di public/ e del volume
  storage solo per servire file statici
- clear_env = no in www.conf, senza cui php-fpm nasconde a Laravel tutte
  le variabili d'ambiente
- configurazione da ambiente di processo invece di .env riscritto con sed;
  il compose fissa solo i valori dettati dalla topologia
- fail-fast su APP_URL, DB_PASSWORD, DB_ROOT_PASSWORD, REDIS_PASSWORD
- servizio scheduler dedicato: schedule:work non veniva mai eseguito,
  quindi la pulizia dell'audit log non e' mai partita
- solo il container app esegue le migrazioni, per evitare che due
  container in gara lascino uno schema applicato a meta'
- healthcheck separati per worker e scheduler, che non espongono HTTP
- docker-compose.dev.yml per lo sviluppo locale
- documentazione unificata in README.md e HANDOFF.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:49:10 +02:00
33 changed files with 2004 additions and 1791 deletions

View File

@@ -1,22 +1,46 @@
# Keep the build context small and, more importantly, keep host state out of
# the image. vendor/ and public/build are produced by dedicated build stages —
# copying a host-built copy in would shadow them with whatever happened to be
# on the developer's machine.
.git .git
.gitignore .gitignore
.github
# Never bake secrets into an image layer.
.env .env
.env.*
!.env.example
vendor vendor
node_modules node_modules
storage/logs/* storage/logs/*
storage/framework/cache/* storage/framework/cache/*
storage/framework/sessions/* storage/framework/sessions/*
storage/framework/views/* storage/framework/views/*
storage/app/public/territori-pdf/*
storage/app/public/territori-thumbnails/*
storage/app/livewire-tmp/*
storage/app/.app_key
storage/app/.db_seeded
bootstrap/cache/* bootstrap/cache/*
public/build public/build
public/storage public/storage
public/hot
db_data db_data
redis_data redis_data
docker-compose.yml docker-compose.yml
docker-compose.dev.yml
docker-compose.override.yml docker-compose.override.yml
README.md
TerManager2_v2.md *.md
.idea *.sql
.vscode
*.swp *.swp
*.swo *.swo
.idea
.vscode
.phpunit.result.cache

View File

@@ -1,42 +1,88 @@
APP_NAME=TerManager2 # =============================================================================
APP_ENV=local # TerManager2 — environment template
APP_KEY= #
APP_DEBUG=true # LOCAL DEVELOPMENT: copy to .env and adjust.
APP_TIMEZONE=Europe/Rome # cp .env.example .env && docker compose -f docker-compose.dev.yml up -d
APP_URL=https://demo-termanager.pyconetwork.it #
ASSET_URL=https://demo-termanager.pyconetwork.it # PRODUCTION (Dokploy): do NOT ship a .env file. Set these as environment
# variables in the Dokploy UI instead — the container reads them directly.
# Variables marked [REQUIRED] have no safe default and the stack will refuse
# to start without them.
# =============================================================================
APP_PORT=8080 APP_NAME=TerManager2
SEED_DEV_DATA=false APP_ENV=production
RUN_DB_SEED_ON_FIRST_START=true APP_DEBUG=false
ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true APP_TIMEZONE=Europe/Rome
INITIAL_ADMIN_NAME=
# [REQUIRED] Generate with: php artisan key:generate --show
# Proclamatori personal data is encrypted with this key. If it changes or is
# lost, that data becomes permanently unreadable. Back it up.
APP_KEY=
# [REQUIRED] Public URL, including scheme. Used for links in PDFs and emails,
# and to validate the incoming Host header.
APP_URL=https://territori.example.org
LOG_CHANNEL=stderr
LOG_LEVEL=warning
# -----------------------------------------------------------------------------
# First-run bootstrap
# -----------------------------------------------------------------------------
# The admin account created on first deploy. Change the password after login.
INITIAL_ADMIN_NAME=Amministratore
INITIAL_ADMIN_EMAIL= INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD= INITIAL_ADMIN_PASSWORD=
# Leave these at their defaults; they exist as escape hatches.
RUN_MIGRATIONS=true
RUN_DB_SEED_ON_FIRST_START=true
ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true
# -----------------------------------------------------------------------------
# Database — hostnames are fixed by docker-compose.yml
# -----------------------------------------------------------------------------
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mariadb DB_HOST=mariadb
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=termanager2 DB_DATABASE=termanager2
DB_USERNAME=termanager2 DB_USERNAME=termanager2
DB_PASSWORD=secret
DB_ROOT_PASSWORD=rootsecret
# [REQUIRED] No defaults on purpose — generate with: openssl rand -base64 24
DB_PASSWORD=
DB_ROOT_PASSWORD=
# -----------------------------------------------------------------------------
# Redis (cache, sessions, queue)
# -----------------------------------------------------------------------------
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PASSWORD=redissecret
REDIS_PORT=6379 REDIS_PORT=6379
# [REQUIRED] openssl rand -base64 24
REDIS_PASSWORD=
CACHE_STORE=redis CACHE_STORE=redis
SESSION_DRIVER=redis SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
# -----------------------------------------------------------------------------
# Mail — external SMTP. Use MAIL_MAILER=log to disable delivery entirely.
# -----------------------------------------------------------------------------
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=
MAIL_PORT=1025 MAIL_PORT=587
MAIL_USERNAME=null MAIL_USERNAME=
MAIL_PASSWORD=null MAIL_PASSWORD=
MAIL_ENCRYPTION=null MAIL_SCHEME=tls
MAIL_FROM_ADDRESS="noreply@termanager2.local" MAIL_FROM_ADDRESS=noreply@example.org
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
# -----------------------------------------------------------------------------
# Local development only — ignored in production
# -----------------------------------------------------------------------------
# Host port for docker-compose.dev.yml. In production Traefik handles routing
# and no port is published.
APP_PORT=8080
MAILPIT_UI_PORT=8025 MAILPIT_UI_PORT=8025
SEED_DEV_DATA=false

249
HANDOFF.md Normal file
View File

@@ -0,0 +1,249 @@
# Handoff — TerManager2
Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **18 luglio 2026**.
---
## Stato attuale
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
redeploy, login, tutte le pagine, code e scheduler.
| Aspetto | Stato |
|-----------------------------|-------|
| Deploy Dokploy (Compose) | ✅ verificato |
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
| Redeploy senza interventi | ✅ ~25 secondi |
| Sviluppo locale | ✅ `docker-compose.dev.yml` |
| Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
---
## Da dove veniamo
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato
solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.**
L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro
il container. Su Dokploy, senza sorgente sull'host, significava build a runtime
a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
Da qui la ristrutturazione descritta sotto.
---
## Decisioni architetturali
Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il
contesto per ridiscuterla è qui.
### L'immagine è autosufficiente
`vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e
copiati nell'immagine finale. I container di produzione **non montano il
sorgente** e non eseguono build all'avvio.
Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend,
serve un rebuild dell'immagine, non un semplice restart.
### nginx e php-fpm nello stesso container
Con container separati, nginx avrebbe bisogno di una copia di `public/` e del
volume storage solo per servire file statici. Per questa scala è complessità
senza beneficio. Sono coordinati da supervisor
(`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due
muore definitivamente — altrimenti resterebbe "up" senza servire nulla.
### La configurazione arriva dall'ambiente, non da `.env` riscritto
Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel
legge direttamente l'ambiente di processo, e `docker-compose.yml` usa
`env_file` senza ridichiarare le variabili.
Fissati nel compose solo i valori che sono una proprietà *della topologia*
(`DB_HOST: mariadb`, `CACHE_STORE: redis`, `LOG_CHANNEL: stderr`, …). Questi
**vincono** su quanto scritto nel `.env`, così un file copiato da una macchina
di sviluppo non rompe il container.
Nota: perché questo funzioni, `www.conf` ha `clear_env = no`. Senza, php-fpm
sanifica l'ambiente e Laravel non vedrebbe nessuna variabile.
### Fail-fast sulle variabili mancanti
Lo stack si rifiuta di partire senza `APP_URL`, `DB_PASSWORD`,
`DB_ROOT_PASSWORD`, `REDIS_PASSWORD`. Le password sono validate
dall'interpolazione compose (`${VAR:?}`), `APP_URL` dall'entrypoint (dove il
messaggio d'errore è più chiaro).
Deliberato: un errore immediato è meglio di un database in produzione con
password `secret`.
### Un solo container esegue le migrazioni
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
attende e non tocca lo schema. Due container in gara su `artisan migrate` è
il modo tipico di ritrovarsi con uno schema applicato a metà.
### `APP_KEY` risolta una volta sola all'avvio
Prima veniva rigenerata da `AppServiceProvider::boot()` a ogni richiesta se
mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
provider **solleva un'eccezione** se manca, invece di rigenerarla.
---
## Bug corretti — i più significativi
Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| Bug | Effetto | Dove |
|-----|---------|------|
| `key:generate` a runtime | Rotazione silenziosa della chiave → **perdita irreversibile** dei dati cifrati | `AppServiceProvider` |
| Import PDF da cartella mai accodato | Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata | `Livewire/Settings/XmlExchange` |
| Permessi non riapplicati su Livewire | Un permesso revocato restava attivo fino al reload della pagina | `AppServiceProvider` (`addPersistentMiddleware`) |
| Host header injection | Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari | `bootstrap/app.php`, `nginx/default.conf` |
| Default TTL link mai convertito | Link PDF validi 2 anni invece di 1 mese | migrazione `2026_07_18_000001` |
| `syncPermissions()` nel seeder | Un re-seed azzerava i permessi personalizzati dei ruoli | `RolesAndPermissionsSeeder` |
| `campagna_id` manipolabile | Rientro conteggiato su una campagna arbitraria | `Livewire/Assegnazioni/Rientra` (`#[Locked]`) |
| `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` |
| Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` |
| Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` |
| Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti |
---
## Cosa resta aperto
In ordine di priorità.
### 1. Nessun test automatico
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste**. Ogni
verifica è manuale. È il debito più rilevante: le regole di business (anno
teocratico, stato territorio, priorità automatica, percentuale campagne) sono
esattamente il tipo di logica che va protetta da test.
Nota: `config/database.php` ridefinisce `connections` e lascia solo `mysql`.
Per usare SQLite in memoria nei test va riaggiunta la connessione `sqlite`.
### 2. Race su doppia assegnazione
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma
nulla a livello di database impedisce due assegnazioni aperte sullo stesso
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`,
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi.
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna
generata che imponga una sola assegnazione aperta per territorio.
### 3. Scrittura durante il rendering
`Assegnazione::shortPdfUrl()` genera e salva il codice d'accesso al primo
utilizzo, quindi una GET del registro può produrre decine di INSERT. Meglio
generare il codice alla creazione dell'assegnazione (observer) e far restituire
`null` al metodo quando manca.
### 4. Ricerca in memoria sui campi cifrati
Nome e cognome dei proclamatori sono cifrati, quindi la ricerca carica l'intera
tabella e filtra in PHP (`ProclamatoreIndex`, `Registro`). Accettabile sotto
qualche centinaio di record, non oltre. Se dovesse servire scalare: hash
deterministico ricercabile affiancato al campo cifrato.
### 5. Endpoint PDF pubblici senza throttle
`/p/{code}` e le rotte del visualizzatore sono senza autenticazione né rate
limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a
~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non
verrebbe né rallentato né registrato. Un `throttle:30,1` costerebbe una riga.
---
## Trappole da conoscere
**`APP_KEY` è il singolo punto di perdita dati.** I nomi dei proclamatori sono
cifrati con questa chiave. Il backup del database senza la chiave è inutile per
quei campi. Se non la imposti, l'entrypoint ne genera una e la salva nel volume
`storage_data`, stampandola nei log: se quel volume viene perso, i dati sono
irrecuperabili.
**`APP_URL` deve combaciare col dominio servito.** L'applicazione rifiuta
richieste con `Host` diverso (protezione host header injection) e restituisce
400 senza spiegazioni. È la causa più comune di "il deploy è andato ma il sito
non si apre". `localhost` e `127.0.0.1` sono ammessi perché li usano gli health
check interni.
**Il volume `storage_data` non è solo cache.** Contiene i PDF dei territori, le
miniature e la chiave di fallback. Non va trattato come rigenerabile.
**Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente
riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine.
---
## Come verificare che tutto funzioni
Dopo una modifica sostanziale, questo giro copre i percorsi critici:
Si usa il compose di **sviluppo**: quello di produzione richiede la rete
esterna `dokploy-network` e non pubblica porte sull'host, quindi non è
verificabile in locale così com'è.
```bash
cp .env.example .env # se non c'è già
# 1. L'immagine builda
docker build -f docker/php/Dockerfile -t termanager2-check .
# 2. Stack pulito da zero
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.dev.yml up -d --build
docker compose -f docker-compose.dev.yml ps # tutti i servizi healthy
# 3. L'applicazione risponde
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up # 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/login # 200
# 4. Il redeploy è idempotente
docker compose -f docker-compose.dev.yml up -d --force-recreate app
docker compose -f docker-compose.dev.yml logs app | grep -E "Migrations|Seed"
# atteso: "Migrations applied" + "Seed already applied"
# 5. La cifratura funziona
docker compose -f docker-compose.dev.yml exec app php artisan tinker --execute="
\$p = App\Models\Proclamatore::create(['nome'=>'Test','cognome'=>'Verifica','attivo'=>true]);
\$raw = DB::table('proclamatori')->where('id',\$p->id)->value('nome');
echo \$raw === 'Test' ? 'ERRORE: dati in chiaro' : 'OK: cifrato';
"
```
Per provare davvero il compose di produzione serve creare la rete
(`docker network create dokploy-network`) e aggiungere un override che pubblichi
la porta 80 del servizio `app`.
---
## Documentazione
| Documento | Contenuto |
|------------------------------------|--------------------------------------------|
| [README.md](README.md) | Deploy, configurazione, uso, riferimento |
| `HANDOFF.md` (questo file) | Stato, decisioni, debito tecnico |
La specifica funzionale originale (`TerManager2_v2.md`) è stata rimossa dal
working tree: descriveva funzionalità poi cambiate — in particolare un wizard
di primo avvio successivamente eliminato in favore della pagina Impostazioni —
e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide
(modello dati, regole di business, RBAC) sono confluite nel README.
Resta consultabile nella storia del repository:
```bash
git show 701f479:TerManager2_v2.md
```

1237
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,640 +0,0 @@
# TerManager2 — Specifiche funzionali + istruzioni per container (Laravel)
> Documento operativo per realizzare **TerManager2**, applicazione Laravel per la gestione dell’assegnazione/rientro di cartoline territorio, statistiche di percorrenza e campagne speciali.
>
> **Obiettivo del documento**: chiarire requisiti, proporre un’architettura “container-ready”, definire modello dati e regole di calcolo, e fornire una **scomposizione in sottotask** con checklist di controllo a fine attività.
---
## 1) Visione d’insieme
**TerManager2** gestisce:
- **Territori** (cartoline) con PDF, stato (in reparto / assegnato / da rientrare / prioritario / inattivo) e proprietà (Zona, Tipologia, ecc.).
- **Proclamatori** (assegnatari) con dati **illeggibili in DB** (cifratura applicativa a livello di colonna) e gestione **GDPR**.
- **Assegnazioni** con data assegnazione/rientro, teocratic year, opzionale “conteggio campagna”.
- **Campagne** (periodi speciali) con percentuali di percorrenza e dashboard.
- **Statistiche** (media mensile di percorrenza nell’anno teocratico, indicatori di giacenza e “smarriti”).
**Ruoli (RBAC)**:
- **Amministratore**: tutto (impostazioni, territori, proclamatori, campagne, registro, proprietà territorio, assegnazione/rientro).
- **Assistente**: proclamatori, campagne, assegnazione/rientro.
- **Operatore**: assegnazione/rientro.
---
## 2) Requisiti non funzionali (sicurezza, GDPR, UX)
### 2.1 Sicurezza e dati illeggibili in DB
Requisito: i dati dei proclamatori devono risultare **illeggibili** se si accede direttamente al database.
**Scelta consigliata (Laravel)**:
- Password utente: **hash** (bcrypt/argon2id) (standard Laravel).
- Dati sensibili (nome, cognome, eventuali note): **cifratura applicativa per colonna**.
- Usare **Laravel Eloquent encrypted casts** (es. `protected $casts = ['nome' => 'encrypted', ...];`) oppure un **Custom Cast** che cifri/decifri con AES-256-GCM.
- La chiave di cifratura è `APP_KEY` (gestita come secret in ambiente). **Mai** versionarla.
**Nota importante**: la cifratura applicativa protegge “a riposo” nel DB. Per un livello ulteriore:
- cifrare anche **backup**;
- considerare cifratura volume/disco a livello host.
### 2.2 GDPR — criteri base da rispettare
Implementare almeno:
- **Minimizzazione**: salvare solo ciò che serve (nome/cognome e stato attivo).
- **Ruoli e autorizzazioni**: accesso ai dati solo per chi ha permessi.
- **Audit trail**: log di operazioni critiche (creazione/modifica/eliminazione, assegnazione/rientro, cambio stato territorio).
- **Retention**: definire policy su conservazione storici (es. registro assegnazioni conservato per X anni — parametro impostazioni).
- **Diritto all’oblio**: possibilità di **disattivare/anonimizzare** un proclamatore (senza rompere lo storico).
- **Esportazione**: esportazione dati (CSV/PDF) su richiesta (opzionale ma consigliato).
- **Consenso e informativa**: pagina “Privacy / Informativa” e log di accettazione (se necessario al contesto).
### 2.3 UI/UX
- Responsive “mobile-first” (smartphone/tablet), grafica minimale e moderna.
- Layout con:
- header con logo + congregazione;
- menu laterale (collassabile su mobile);
- contenuti centrali con card e tabelle filtrabili.
Tecnologie UI consigliate:
- **TailwindCSS** + **Livewire** (rapido, server-driven) **oppure** Inertia + Vue/React.
- Componenti: ricerca veloce, paginazione, badge di stato, drawer per dettagli.
---
## 3) Architettura applicativa (Laravel)
### 3.1 Componenti
- Laravel (>=10) con:
- Auth (Breeze/Jetstream o Fortify) + **RBAC** (Spatie Laravel Permission consigliato).
- DB: MySQL/MariaDB o PostgreSQL.
- Storage: `storage/app` per PDF (in dev) + volume Docker; in produzione valutare S3/MinIO.
- Queue: Redis (opzionale ma utile per report/esportazioni).
- Scheduler: `artisan schedule:work` per job (es. calcoli, pulizie, report).
### 3.2 Concetti chiave di dominio
- **Anno teocratico**: da **Settembre** (anno precedente) ad **Agosto** (anno corrente).
- **Percorrenza**: giorni tra assegnazione e rientro (nell’anno teocratico corrente) aggregati e convertiti in media mensile.
- **Campagna**: periodo con start/end; a ogni rientro si chiede se quel rientro conta per la campagna (anche retroattivo se assegnato durante il periodo ma rientrato dopo).
---
## 4) Modello dati (proposta)
> I nomi campi sono indicativi; adeguare naming (snake_case) e indici.
### 4.1 Tabelle
#### `users`
- auth utenti app (admin/assistente/operatore)
- campi standard Laravel (`email`, `password`, `name`, ecc.).
#### `roles`, `permissions`, `model_has_roles`, ...
- se si usa Spatie Permission.
#### `settings`
- configurazioni globali:
- `congregazione_nome`
- soglia `giorni_giacenza_da_assegnare`
- soglia `giorni_giacenza_prioritari`
- soglia `giorni_per_smarrito`
- `home_limit_list` (default 10)
- altre.
#### `proclamatori`
- `id`
- `nome` **encrypted**
- `cognome` **encrypted**
- `attivo` boolean
- timestamps
> (Opzionale) `display_name_hash` per ricerche rapide senza decifrare tutto (hash normalizzato di nome+cognome). Attenzione: l’hash può comunque essere dato personale; trattarlo come tale.
#### `territori`
- `id`
- `numero` (string/int) **univoco**
- `zona_id`
- `tipologia_id`
- `note` (text)
- `confini` (text)
- `pdf_path` (string)
- `attivo` boolean
- `prioritario` boolean
- timestamps
#### `zone` e `tipologie`
- `id`, `nome`, `attivo`.
#### `anni_teocratici`
- `id`
- `label` (es. `2025-2026`)
- `start_date` (1 settembre)
- `end_date` (31 agosto)
- univoco per `label`.
#### `campagne`
- `id`
- `start_date`
- `end_date`
- `descrizione`
- `attiva` (derivabile da date, ma può essere utile una view/flag)
- timestamps
#### `assegnazioni`
- `id`
- `territorio_id`
- `proclamatore_id`
- `anno_teocratico_id`
- `assigned_at`
- `returned_at` (nullable)
- `counted_in_campaign` boolean nullable (null = non chiesto/non applicabile)
- `campaign_id` nullable (se conteggiata)
- `note` nullable
- `created_by` (user_id)
- `returned_by` (user_id) nullable
- timestamps
#### `audit_logs` (o `activity_log`) — per pagina Log attività
- tabella dedicata a tracciare azioni utenti (vedi §6.7)
- se si usa un pacchetto, mantenere naming/colonne del pacchetto; altrimenti creare tabella custom.
### 4.2 Vincoli e indici (essenziali)
- `territori.numero` unique
- `assegnazioni`:
- indice su (`territorio_id`, `returned_at`)
- indice su (`proclamatore_id`, `returned_at`)
- indice su (`anno_teocratico_id`)
- integrità referenziale con `ON DELETE CASCADE` dove sensato.
- **Attenzione**: requisito: eliminazione territorio => eliminazione su tutto il registro assegnazioni. Quindi `assegnazioni.territorio_id` con cascade.
---
## 5) Regole di business e calcoli
### 5.1 Anno teocratico (creazione automatica)
- All’avvio Home o in fase di assegnazione/rientro:
1. determinare data odierna;
2. se mese >= 9 (settembre) -> anno teocratico = `anno_corrente-anno_corrente+1`;
3. se mese <= 8 -> anno teocratico = `anno_corrente-1-anno_corrente`;
4. se non esiste in DB, crearlo con start_date=1/09 e end_date=31/08.
### 5.2 Stato territorio
- **In reparto**: nessuna assegnazione aperta (ultima assegnazione `returned_at` non null) e attivo.
- **Assegnato a X**: esiste assegnazione aperta (`returned_at` null).
- **Smarrito / da rientrare**: assegnazione aperta da più giorni rispetto a `settings.giorni_per_smarrito`.
- **Prioritario**: flag `prioritario=true` oppure giacenza in reparto > soglia `giorni_giacenza_prioritari`.
- **Inattivo**: `attivo=false` (non assegnabile).
### 5.3 “Territori da assegnare” (Home)
- Territori **in reparto** con giacenza > `giorni_giacenza_da_assegnare`.
- Mostrare primi N (`home_limit_list`).
- Azione: assegnazione rapida a proclamatore.
### 5.4 “Territori prioritari” (Home)
- Territori in reparto con giacenza > `giorni_giacenza_prioritari` **oppure** `prioritario=true`.
- Azione: assegnazione rapida.
### 5.5 “Territori da rientrare” (Home)
- Territori con assegnazione aperta da più giorni di `giorni_per_smarrito`.
- Azione: rientro rapido (sull’attuale assegnatario).
### 5.6 Media mensile di percorrenza (Home)
Definizione richiesta: media mensile di percorrenza di tutti i territori nell’anno teocratico corrente.
Interpretazione operativa (coerente con testo):
- Per ogni **assegnazione chiusa** (returned_at valorizzato) nell’anno teocratico corrente:
- `giorni = returned_at - assigned_at` (in giorni, >=0)
- Sommare tutti i `giorni` e normalizzare su base mensile.
Proposta formula (trasparente e verificabile):
- `giorni_totali = Σ giorni` per tutte le assegnazioni chiuse dell’anno teocratico corrente
- `mesi_trascorsi = numero mesi dall’inizio anno teocratico a oggi (min 1)`
- `media_mensile = giorni_totali / mesi_trascorsi`
> Alternativa: media per territorio o per assegnazione; scegliere una e documentarla in impostazioni. L’importante è che sia consistente e testata.
### 5.7 Campagne (Home + Tab Campagne)
- Campagna attiva se `today` tra `start_date` e `end_date`.
- Percentuale percorrenza campagna:
- `territori_percorsi_in_campagna / territori_totali_pertinenti`.
Chiarimento necessario per “totali pertinenti”: suggerito:
- denominatore = numero di territori rientrati **conteggiati campagna** durante il periodo **+** territori assegnati nel periodo ma non ancora rientrati? (da definire).
Per evitare ambiguità e mantenere semplice:
- **Proposta**: percentuale = (numero assegnazioni chiuse conteggiate campagna) / (numero assegnazioni chiuse totali che hanno `assigned_at` nel range campagna) * 100.
#### Avviso rientro “con o senza campagna” (retroattivo)
Alla chiusura di un’assegnazione:
- se esiste campagna tale che `assigned_at` è compreso nel range campagna **(anche se oggi è dopo end_date)**, mostrare prompt:
- “Questo rientro conta per la campagna X?”
- se sì: `counted_in_campaign=true` e `campaign_id`.
- se no: `counted_in_campaign=false`.
- se non applicabile: lasciare null.
#### Vista “territori percorsi e in quanto tempo”
Per campagna selezionata:
- lista assegnazioni conteggiate in campagna con:
- territorio, proclamatore (mostrato in chiaro solo in UI), assigned_at, returned_at, giorni.
---
## 6) Pagine e funzionalità (corrette/riordinate)
### 6.1 Wizard iniziale (primo avvio)
Se mancano impostazioni base (`settings` vuoto):
- Step 1: Nome congregazione + logo (upload).
- Step 2: soglie (giacenza, prioritari, smarrito) + limite liste Home.
- Step 3: conferma e creazione admin (se necessario).
### 6.2 Home
Header:
- sinistra: logo + nome congregazione
- destra: nome utente + ruolo + logout
Sidebar:
- voci variabili in base ai permessi.
Contenuto centrale:
1) **Anno teocratico in corso** (auto-creazione se non esiste)
2) **Media mensile di percorrenza** (anno teocratico corrente)
3) **Campagna in corso** (se attiva): descrizione + percentuale + pulsante “Dettagli in tempo reale”
4) **Assegnazione rapida** (3 colonne/zone):
- Territori da assegnare
- Territori prioritari
- Territori da rientrare
Ogni lista:
- mostra top N
- ricerca veloce
- pulsante “Vedi tutti” (pagina completa con filtri)
### 6.3 Gestione Territori
Lista filtrabile con colonne:
- Numero
- Zona / Tipologia
- Stato (badge)
- Ultimo rientro
- Azioni: Visualizza / Modifica / Elimina / Attiva-Disattiva
Dettaglio territorio (“Visualizza”):
- dati completi + PDF viewer
- storico assegnazioni raggruppato per anno teocratico
Regole:
- Eliminazione territorio => rimuove anche tutte le assegnazioni (cascade).
- Territorio inattivo non è assegnabile e non appare nelle liste “da assegnare/prioritari” (a meno di filtro admin).
### 6.4 Gestione Proclamatori
Lista filtrabile:
- Proclamatore (nome/cognome decifrati in UI)
- Attivo
- Territori attualmente in assegnazione (conteggio + link)
- Azioni: Visualizza / Modifica / Elimina / Disattiva
Regole consigliate:
- Eliminazione fisica solo se nessuno storico (oppure prevedere “anonimizza”).
### 6.5 Gestione Campagne
- Form creazione campagna (start, end, descrizione)
- Evidenza campagna attiva
- Elenco campagne completate (indipendente dall’anno teocratico)
- Dettaglio: elenco assegnazioni conteggiate, tempi, export.
### 6.6 Registro assegnazioni
Lista filtrabile per:
- anno teocratico
- territorio
- zona
- tipologia
- proclamatore
Output:
- tabella con assegnazioni (assegnato, rientrato, giorni, campagna sì/no)
- export CSV/PDF (opzionale)
### 6.7 Log attività (Audit trail) — **REQUISITO OBBLIGATORIO**
> Deve essere disponibile una **pagina di log** che riporti **tutte le azioni degli utenti** (Amministratore, Assistente, Operatore) e in particolare le operazioni di:
> - **Assegnazione** territorio
> - **Rientro** territorio (con/senza campagna)
> - **Creazione / modifica / cancellazione** di: Territori, Proclamatori, Campagne, Zone, Tipologie, Impostazioni
> - **Attiva/Disattiva** territorio o proclamatore
> - **Upload / sostituzione / eliminazione** PDF territorio
> - (Consigliato) **Login/Logout**, tentativi falliti, export dati
#### 6.7.1 Scopo
- Tracciabilità e accountability (chi ha fatto cosa e quando).
- Supporto a sicurezza e requisiti GDPR (audit delle operazioni sui dati).
#### 6.7.2 Permessi di accesso alla pagina log
- **Visualizzazione log**: `audit.view` (almeno Amministratore).
- Opzionale: consentire anche ad Assistente una vista “limitata” (solo eventi di assegnazione/rientro e senza IP/user-agent).
- **Esportazione log**: `audit.export` (solo Amministratore).
#### 6.7.3 Dati minimi da registrare per ogni evento
Registrare per ogni azione:
- `id`
- `created_at` (data/ora evento)
- `causer_user_id` (utente che ha eseguito)
- `causer_role_snapshot` (ruolo al momento dell’azione)
- `event` (es. `created`, `updated`, `deleted`, `assigned`, `returned`, `activated`, `deactivated`, `uploaded_pdf`)
- `subject_type` + `subject_id` (entità coinvolta: Territorio/Proclamatore/Campagna/Assegnazione/Setting...)
- `description` (testo leggibile)
- `properties` (JSON) con dettagli evento:
- **prima/dopo** per update (diff campi)
- motivazioni/flag (es. `counted_in_campaign=true`)
- valori di soglia usati in calcoli/decisioni (se rilevanti)
- (Consigliato) `ip_address` e `user_agent` (dato personale → retention e minimizzazione)
> Nota GDPR: retention configurabile (es. 365/730 giorni). Evitare di salvare nei log dati sensibili in chiaro non necessari: per Proclamatori salvare `proclamatore_id` e non nome/cognome.
#### 6.7.4 Implementazione consigliata (senza vincoli)
- È possibile implementare il log con tabella custom (`audit_logs`) oppure usare un pacchetto dedicato.
- Scelta spesso adottata in Laravel: **spatie/laravel-activitylog** per logging automatico su modelli + logging manuale per eventi “di flusso” (assegnazione/rientro).
#### 6.7.5 UI pagina Log
Funzioni minime:
- Tabella: Data/Ora, Utente, Ruolo, Azione, Oggetto, Esito, Dettagli.
- Filtri: date, utente, ruolo, tipo evento, entità.
- Dettaglio evento (drawer/modal): diff before/after + metadati.
- Paginazione + ricerca veloce.
---
## 7) Containerizzazione (Docker) — sviluppo e produzione
### 7.1 Struttura consigliata repository
```
termanager2/
docker/
nginx/
default.conf
php/
Dockerfile
php.ini
.env.example
docker-compose.yml
composer.json
package.json
...
```
> In dev si può usare `npm run dev` in un container separato o sul host.
### 7.6 Produzione (note rapide)
- Impostare `APP_ENV=production`, `APP_DEBUG=false`.
- Usare immagini “buildate” senza montare volume codice.
- Gestire segreti con Docker secrets o secret manager.
- Abilitare HTTPS con reverse proxy (Traefik/Nginx) e HSTS.
- Backup cifrati e rotazione.
---
## 8) RBAC: permessi e gating UI
Definire permessi granulari (anche se i ruoli sono solo 3):
- `settings.manage`
- `proclamatori.manage`
- `territori.manage`
- `campagne.manage`
- `registro.view`
- `territori.assign`
- `territori.return`
- `audit.view`
- `audit.export` (opzionale)
Mappatura ruoli:
- Admin: tutti (incluso `audit.view` e opzionale `audit.export`)
- Assistente: proclamatori.manage, campagne.manage, territori.assign, territori.return
- Operatore: territori.assign, territori.return
Applicare:
- Middleware route (`can:` / `role:` / `permission:`)
- Condizionali menu sidebar
## 8-bis) API routes / schermate (URL, metodi, middleware)
> Nota: di seguito sono indicate **route Web** (UI) e (opzionalmente) **route API**. Anche se l’app nasce “web”, formalizzare le route aiuta a:
> - applicare correttamente `auth` + permessi,
> - mantenere coerenza tra schermate e controller,
> - facilitare test e manutenzione.
### 8-bis.1 Convenzioni middleware
- `auth` su tutte le rotte applicative.
- `permission:<permesso>` (o `can:<permesso>`) per schermate/azioni.
- `throttle` (consigliato) su login e su endpoint di export.
### 8-bis.2 Mappa schermate principali
- **Home/Dashboard**: `/` (GET)
- **Wizard iniziale**: `/setup/*` (GET/POST)
- **Territori**: `/territori` (lista), `/territori/create`, `/territori/{id}` (dettaglio), `/territori/{id}/edit`
- **Proclamatori**: `/proclamatori` + CRUD
- **Campagne**: `/campagne` + CRUD + dettaglio
- **Registro**: `/registro` (filtri)
- **Audit log**: `/audit` (lista) + `/audit/{id}` (dettaglio)
### 8-bis.3 Route Web (UI) — dettaglio
#### Autenticazione
- `GET /login` → form login | middleware: `guest`
- `POST /login` → login | middleware: `guest`, `throttle:login`
- `POST /logout` → logout | middleware: `auth`
#### Wizard iniziale (primo avvio)
- `GET /setup` → step 1 | middleware: `setup.required`
- `POST /setup/step-1` → salva congregazione | middleware: `setup.required`
- `POST /setup/step-2` → salva soglie | middleware: `setup.required`
- `POST /setup/finish` → completa setup | middleware: `setup.required`
> `setup.required` è un middleware custom che intercetta l’assenza di settings e forza il wizard.
#### Home
- `GET /` → dashboard | middleware: `auth`
#### Territori
- `GET /territori` → lista | middleware: `auth`, `permission:territori.manage`
- `GET /territori/create` → form create | middleware: `auth`, `permission:territori.manage`
- `POST /territori` → store | middleware: `auth`, `permission:territori.manage`
- `GET /territori/{territorio}` → dettaglio | middleware: `auth`, `permission:territori.manage`
- `GET /territori/{territorio}/edit` → form edit | middleware: `auth`, `permission:territori.manage`
- `PUT /territori/{territorio}` → update | middleware: `auth`, `permission:territori.manage`
- `DELETE /territori/{territorio}` → delete (cascade assegnazioni) | middleware: `auth`, `permission:territori.manage`
- `PATCH /territori/{territorio}/toggle-active` → attiva/disattiva | middleware: `auth`, `permission:territori.manage`
- `PATCH /territori/{territorio}/toggle-priority` → prioritario sì/no | middleware: `auth`, `permission:territori.manage`
- `POST /territori/{territorio}/pdf` → upload/sostituzione PDF | middleware: `auth`, `permission:territori.manage`
- `DELETE /territori/{territorio}/pdf` → rimuovi PDF | middleware: `auth`, `permission:territori.manage`
#### Proclamatori
- `GET /proclamatori` → lista | middleware: `auth`, `permission:proclamatori.manage`
- `GET /proclamatori/create` → form create | middleware: `auth`, `permission:proclamatori.manage`
- `POST /proclamatori` → store | middleware: `auth`, `permission:proclamatori.manage`
- `GET /proclamatori/{proclamatore}` → dettaglio | middleware: `auth`, `permission:proclamatori.manage`
- `GET /proclamatori/{proclamatore}/edit` → form edit | middleware: `auth`, `permission:proclamatori.manage`
- `PUT /proclamatori/{proclamatore}` → update | middleware: `auth`, `permission:proclamatori.manage`
- `DELETE /proclamatori/{proclamatore}` → delete/anonimizza (policy) | middleware: `auth`, `permission:proclamatori.manage`
- `PATCH /proclamatori/{proclamatore}/toggle-active` → attiva/disattiva | middleware: `auth`, `permission:proclamatori.manage`
#### Campagne
- `GET /campagne` → lista + campagna attiva | middleware: `auth`, `permission:campagne.manage`
- `GET /campagne/create` → form create | middleware: `auth`, `permission:campagne.manage`
- `POST /campagne` → store | middleware: `auth`, `permission:campagne.manage`
- `GET /campagne/{campagna}` → dettaglio + tempi real-time | middleware: `auth`, `permission:campagne.manage`
- `GET /campagne/{campagna}/edit` → form edit | middleware: `auth`, `permission:campagne.manage`
- `PUT /campagne/{campagna}` → update | middleware: `auth`, `permission:campagne.manage`
- `DELETE /campagne/{campagna}` → delete | middleware: `auth`, `permission:campagne.manage`
#### Assegnazioni (azioni core)
- `POST /assegnazioni/assign` → assegna territorio a proclamatore | middleware: `auth`, `permission:territori.assign`
- `POST /assegnazioni/{assegnazione}/return` → rientro territorio | middleware: `auth`, `permission:territori.return`
> Nota: `assign` e `return` devono sempre scrivere su **audit log**.
#### Registro
- `GET /registro` → lista filtrabile | middleware: `auth`, `permission:registro.view`
- (Opzionale) `GET /registro/export` → export | middleware: `auth`, `permission:registro.export`
#### Audit log
- `GET /audit` → lista log | middleware: `auth`, `permission:audit.view`
- `GET /audit/{log}` → dettaglio log | middleware: `auth`, `permission:audit.view`
- (Opzionale) `GET /audit/export` → export | middleware: `auth`, `permission:audit.export`
### 8-bis.4 Route API (opzionali, se si usa UI “dinamica”)
> Se si adotta Livewire spesso bastano le Web route. Se si usa SPA (Inertia/Vue/React) o si vuole un frontend più interattivo, prevedere API JSON:
- `GET /api/territori?filters=...` | `auth:sanctum`, `permission:territori.manage`
- `GET /api/home/quicklists` | `auth:sanctum`
- `POST /api/assegnazioni/assign` | `auth:sanctum`, `permission:territori.assign`
- `POST /api/assegnazioni/{id}/return` | `auth:sanctum`, `permission:territori.return`
- `GET /api/campagne/{id}/realtime` | `auth:sanctum`, `permission:campagne.manage`
---
## 9) Checklist di funzionalità (con sottotask e ricontrollo)
> Regola di lavoro: **ogni funzionalità va spezzata in sottotask**, ognuno con test + verifica UI. A completamento, ricontrollare l’intera funzionalità end-to-end.
### EPIC A — Setup progetto e base security
**Sottotask**
1. Creazione progetto Laravel + repo + CI base (lint/test).
2. Setup Auth (login/logout) + password policy.
3. Setup RBAC (ruoli/permessi) + seed iniziale admin.
4. Setup cifratura colonne proclamatori + test round-trip.
5. Logging audit (azioni critiche) + retention log.
**Ricontrollo (DoD)**
- Login funziona, utenti e ruoli corretti.
- Dati proclamatori illeggibili in DB (verifica diretta tabella).
- Rotte protette da permessi.
### EPIC B — Wizard iniziale + Settings
**Sottotask**
1. Model/settings + migrazione.
2. Wizard UI 3 step + validazione.
3. Blocco app finché wizard non completato (redirect).
**Ricontrollo**
- Primo avvio porta al wizard.
- Home mostra logo/nome congregazione.
### EPIC C — Gestione Territori
**Sottotask**
1. Tabelle territori/zone/tipologie + seed.
2. CRUD territori + upload PDF.
3. Lista filtrabile + badge di stato.
4. Dettaglio con PDF viewer + storico per anno teocratico.
5. Attiva/disattiva + prioritario.
6. Delete con cascade su assegnazioni.
**Ricontrollo**
- Stati corretti e coerenti.
- PDF visibile e scaricabile.
- Eliminazione rimuove storico come richiesto.
### EPIC D — Gestione Proclamatori
**Sottotask**
1. CRUD proclamatori (encrypted fields).
2. Lista con filtro + indicazione territori assegnati.
3. Disattivazione / (opzionale) anonimizzazione.
**Ricontrollo**
- Dati illeggibili in DB.
- Non assegnabile se attivo=false.
### EPIC E — Assegnazione e rientro (core)
**Sottotask**
1. Flusso assegnazione: selezione territorio + proclamatore + conferma.
2. Prevenire doppia assegnazione (vincolo logico).
3. Flusso rientro: chiusura assegnazione + calcolo giorni.
4. Prompt campagna (retroattivo) al rientro.
5. Audit: created_by/returned_by.
**Ricontrollo**
- Un territorio non può avere due assegnazioni aperte.
- Prompt campagna compare quando dovuto.
### EPIC F — Home Dashboard
**Sottotask**
1. Calcolo anno teocratico corrente + autocreazione.
2. Calcolo media mensile percorrenza.
3. Liste rapide (da assegnare/prioritari/da rientrare) con filtri e “vedi tutti”.
4. UI responsive.
**Ricontrollo**
- Numeri coerenti con registro.
- Liste mostrano gli stessi risultati delle pagine complete.
### EPIC G — Campagne
**Sottotask**
1. CRUD campagne.
2. Indicazione campagna attiva.
3. Percentuale e dettaglio “in tempo reale”.
4. Lista campagne completate.
**Ricontrollo**
- Retroattività corretta.
- Dettaglio mostra tempi e territori.
### EPIC H — Registro e statistiche
**Sottotask**
1. Vista registro per territorio con filtri richiesti.
2. Export (opzionale).
3. Test prestazioni su query con indici.
**Ricontrollo**
- Filtri corretti.
- Dati coerenti con assegnazioni.
### EPIC I — Audit Log (pagina log e tracciamento eventi)
**Sottotask**
1. Definizione eventi da loggare (CRUD + flussi assegnazione/rientro + login/logout opzionale).
2. Implementazione persistenza log (tabella custom o activity log) + migrazioni.
3. Hook automatici su modelli (created/updated/deleted) e log manuali per `assign/return`.
4. Pagina `/audit` con filtri, paginazione, dettaglio evento (before/after).
5. RBAC: `audit.view` e (opzionale) `audit.export`.
6. Retention configurabile + job scheduler (opzionale) per pulizia log.
**Ricontrollo**
- Ogni operazione critica genera un record log.
- Il log non espone dati sensibili non necessari (es. nomi proclamatori in chiaro nei properties).
- Filtri e dettaglio mostrano correttamente chi/cosa/quando e diff.
---
## 10) Test consigliati
- **Unit test**: calcolo anno teocratico, stati territorio, logica campagne.
- **Feature test**: RBAC per pagina, assegnazione/rientro, wizard.
- **Security test**: accesso non autorizzato, CSRF, rate limit login.
- **UX test**: mobile viewport, tabelle filtrabili, azioni rapide.
- **Audit test**: ogni create/update/delete e assign/return produce log con subject/causer corretti.
---
## 11) “Done” globale (criteri di completamento)
- Tutte le funzionalità richieste presenti e testate.
- Responsività verificata su smartphone/tablet.
- Dati proclamatori cifrati e illeggibili in DB.
- Log operazioni critiche + policy di retention.
- Backup e gestione segreti definiti (per produzione).
---
### Allegato — Note di chiarezza (minime)
- La formula di “media mensile” e la definizione di denominatore percentuale campagna vanno **documentate** e rese consistenti con i test.
- La cancellazione territorio con cascade è conforme al requisito, ma valutare impatto GDPR/contabile: in alternativa soft-delete + “nascondi dallo storico”.

View File

@@ -22,7 +22,11 @@ class ImportTerritoryPdfFolder implements ShouldQueue
use SerializesModels; use SerializesModels;
public int $tries = 1; 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 function __construct(
public string $importId, public string $importId,
@@ -115,7 +119,14 @@ class ImportTerritoryPdfFolder implements ShouldQueue
$thumbnailService->delete($territorio->thumbnail_path); $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; $publicPath = 'territori-pdf/' . $storedFilename;
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath)); Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
$thumbnailPath = $thumbnailService->generate($publicPath); $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 protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
{ {
if ($territoriMap === []) { if ($territoriMap === []) {

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
@@ -11,7 +12,14 @@ class Rientra extends Component
public Assegnazione $assegnazione; public Assegnazione $assegnazione;
public string $returned_at = ''; public string $returned_at = '';
public bool $showCampaignPrompt = false; 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 ?int $campagna_id = null;
public bool $counted_in_campaign = false; public bool $counted_in_campaign = false;
public function mount(Assegnazione $assegnazione) public function mount(Assegnazione $assegnazione)
@@ -62,6 +70,12 @@ class Rientra extends Component
{ {
$this->validate(); $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); $returnDate = \Carbon\Carbon::parse($this->returned_at);
$this->assegnazione->update([ $this->assegnazione->update([

View File

@@ -68,7 +68,12 @@ class ProclamatoreIndex extends Component
public function render() 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 !== '') { if ($this->search !== '') {
// In-memory filter because nome/cognome are encrypted // In-memory filter because nome/cognome are encrypted

View File

@@ -54,8 +54,26 @@ class Registro extends Component
$this->resetPage(); $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) public function sortBy(string $field)
{ {
if (! in_array($field, self::SORTABLE_FIELDS, true)) {
return;
}
if ($this->sortField === $field) { if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else { } else {
@@ -179,13 +197,19 @@ class Registro extends Component
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia)); $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'; $dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC';
if ($sortField === 'territorio_numero') {
$query->orderByRaw( $query->orderByRaw(
"CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir" "CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir"
); );
} else { } else {
$query->orderBy($this->sortField, $this->sortDirection); $query->orderBy($sortField, $dir);
} }
// In-memory search for encrypted proclamatore fields / territorio numero // In-memory search for encrypted proclamatore fields / territorio numero

View File

@@ -72,6 +72,11 @@ class XmlExchange extends Component
$this->pdfFolder = []; $this->pdfFolder = [];
$this->dispatchPdfImport(
$importId,
$storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
);
} }
public function refreshPdfImportStatus(): void public function refreshPdfImportStatus(): void
@@ -197,6 +202,12 @@ class XmlExchange extends Component
AnnoTeocratico::query()->delete(); AnnoTeocratico::query()->delete();
Setting::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; $settingsNode = $xml->settings;
if ($settingsNode) { if ($settingsNode) {
Setting::instance()->update([ Setting::instance()->update([

View File

@@ -123,7 +123,16 @@ class TerritorioIndex extends Component
public function render() 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) { if ($this->search) {
$search = $this->search; $search = $this->search;

View File

@@ -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). * Get the singleton settings instance (first row).
*/ */

View File

@@ -4,8 +4,9 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
use RuntimeException;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -16,16 +17,57 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
// Force HTTPS when behind a reverse proxy // Pin generated URLs to APP_URL.
if (str_starts_with(config('app.url'), 'https://')) { //
// 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'); 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) { Gate::before(function ($user, $ability) {
return $user->hasRole('amministratore') ? true : null; return $user->hasRole('amministratore') ? true : null;
}); });

View File

@@ -4,12 +4,19 @@ namespace App\Services;
use App\Jobs\ImportTerritoryPdfFolder; use App\Jobs\ImportTerritoryPdfFolder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
use ZipArchive; use ZipArchive;
class TerritorioPdfImportDispatcher 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( public function __construct(
protected TerritorioPdfImportState $stateService, protected TerritorioPdfImportState $stateService,
) { ) {
@@ -56,6 +63,25 @@ class TerritorioPdfImportDispatcher
continue; 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); $content = $zip->getFromIndex($index);
if ($content === false) { if ($content === false) {
continue; continue;
@@ -74,6 +100,12 @@ class TerritorioPdfImportDispatcher
$entryIndex++; $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 { } finally {
$zip->close(); $zip->close();
} }

View File

@@ -2,10 +2,20 @@
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
class TerritorioThumbnailService 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. * 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(); $tempPrefix = sys_get_temp_dir() . '/terr_thumb_' . uniqid();
exec( $process = new Process([
'pdftoppm -r 72 -png -l 1 ' . escapeshellarg($pdfAbsPath) . ' ' . escapeshellarg($tempPrefix), 'pdftoppm', '-r', '72', '-png', '-l', '1', $pdfAbsPath, $tempPrefix,
$output, ]);
$exitCode $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 // pdftoppm may produce -1.png, -01.png or even -001.png depending on page count
$generated = null; $generated = null;

View File

@@ -12,14 +12,30 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->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( $middleware->trustProxies(
at: '*', at: '*',
headers: Request::HEADER_X_FORWARDED_FOR headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO | 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([ $middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,

View File

@@ -15,7 +15,10 @@ return [
'cookie' => env('SESSION_COOKIE', 'termanager2_session'), 'cookie' => env('SESSION_COOKIE', 'termanager2_session'),
'path' => env('SESSION_PATH', '/'), 'path' => env('SESSION_PATH', '/'),
'domain' => env('SESSION_DOMAIN'), '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), 'http_only' => env('SESSION_HTTP_ONLY', true),
'same_site' => env('SESSION_SAME_SITE', 'lax'), 'same_site' => env('SESSION_SAME_SITE', 'lax'),
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),

View File

@@ -7,20 +7,28 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration 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 public function up(): void
{ {
if (Schema::hasColumn('territori', 'thumbnail_path')) {
return;
}
Schema::table('territori', function (Blueprint $table) { Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path'); $table->string('thumbnail_path')->nullable()->after('pdf_path');
}); });
} }
/**
* Reverse the migrations.
*/
public function down(): void public function down(): void
{ {
if (! Schema::hasColumn('territori', 'thumbnail_path')) {
return;
}
Schema::table('territori', function (Blueprint $table) { Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path'); $table->dropColumn('thumbnail_path');
}); });

View File

@@ -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');
});
}
}
};

View File

@@ -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');
});
}
}
};

View File

@@ -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();
});
}
};

View File

@@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Setting;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@@ -11,6 +12,13 @@ class DatabaseSeeder extends Seeder
{ {
$this->call([RolesAndPermissionsSeeder::class]); $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)) { if (App::environment('local') && env('SEED_DEV_DATA', false)) {
$this->call([DevSeeder::class]); $this->call([DevSeeder::class]);
} }

View File

@@ -31,13 +31,23 @@ class RolesAndPermissionsSeeder extends Seeder
Permission::firstOrCreate(['name' => $permission]); 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 // Amministratore: all permissions
$admin = Role::firstOrCreate(['name' => 'amministratore']); $admin = Role::firstOrCreate(['name' => 'amministratore']);
$admin->syncPermissions($permissions); $admin->givePermissionTo($permissions);
// Assistente: proclamatori, campagne, assign, return // Assistente: proclamatori, campagne, assign, return
$assistente = Role::firstOrCreate(['name' => 'assistente']); $assistente = Role::firstOrCreate(['name' => 'assistente']);
$assistente->syncPermissions([ $assistente->givePermissionTo([
'proclamatori.manage', 'proclamatori.manage',
'campagne.manage', 'campagne.manage',
'territori.assign', 'territori.assign',
@@ -47,7 +57,7 @@ class RolesAndPermissionsSeeder extends Seeder
// Operatore: assign and return only // Operatore: assign and return only
$operatore = Role::firstOrCreate(['name' => 'operatore']); $operatore = Role::firstOrCreate(['name' => 'operatore']);
$operatore->syncPermissions([ $operatore->givePermissionTo([
'territori.assign', 'territori.assign',
'territori.return', 'territori.return',
]); ]);

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

@@ -0,0 +1,149 @@
# =============================================================================
# TerManager2 — local development
#
# cp .env.example .env
# docker compose -f docker-compose.dev.yml up -d --build
# open http://localhost:8080 (Mailpit UI on http://localhost:8025)
#
# This is a standalone file, not an overlay: it bind-mounts the source so edits
# apply without a rebuild, publishes ports to the host, and swaps the external
# Traefik network for a local one. Production uses docker-compose.yml.
# =============================================================================
x-dev-env: &dev-env
APP_NAME: ${APP_NAME:-TerManager2}
APP_ENV: local
APP_DEBUG: "true"
APP_KEY: ${APP_KEY:-}
APP_URL: http://localhost:${APP_PORT:-8080}
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Rome}
DB_CONNECTION: mysql
DB_HOST: mariadb
DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE:-termanager2}
DB_USERNAME: ${DB_USERNAME:-termanager2}
DB_PASSWORD: ${DB_PASSWORD:-secret}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-redissecret}
CACHE_STORE: redis
SESSION_DRIVER: redis
SESSION_SECURE_COOKIE: "false"
QUEUE_CONNECTION: redis
LOG_CHANNEL: stderr
LOG_LEVEL: debug
MAIL_MAILER: smtp
MAIL_HOST: mailpit
MAIL_PORT: 1025
MAIL_FROM_ADDRESS: noreply@termanager2.local
MAIL_FROM_NAME: TerManager2
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
environment:
<<: *dev-env
# Pick up source edits without restarting php-fpm.
PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1
RUN_DB_SEED_ON_FIRST_START: "true"
SEED_DEV_DATA: ${SEED_DEV_DATA:-false}
INITIAL_ADMIN_NAME: ${INITIAL_ADMIN_NAME:-Amministratore}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@termanager2.local}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-password}
volumes:
# Source is bind-mounted for live edits. vendor/ and public/build stay as
# anonymous volumes so the host (which has neither) doesn't mask what the
# image built.
- ./:/var/www/html
- /var/www/html/vendor
- /var/www/html/public/build
networks:
- termanager2
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
queue-worker:
build:
context: .
dockerfile: docker/php/Dockerfile
restart: unless-stopped
environment:
<<: *dev-env
PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1
volumes:
- ./:/var/www/html
- /var/www/html/vendor
- /var/www/html/public/build
networks:
- termanager2
depends_on:
app:
condition: service_healthy
entrypoint: ["queue-entrypoint.sh"]
command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--sleep=3", "--tries=1", "--timeout=600"]
mariadb:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_DATABASE:-termanager2}
MARIADB_USER: ${DB_USERNAME:-termanager2}
MARIADB_PASSWORD: ${DB_PASSWORD:-secret}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
ports:
# Exposed locally so you can attach a GUI client. Never in production.
- "${DB_PORT_HOST:-3306}:3306"
volumes:
- db_data_dev:/var/lib/mysql
networks:
- termanager2
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redissecret}"]
volumes:
- redis_data_dev:/data
networks:
- termanager2
healthcheck:
test: ["CMD-SHELL", "redis-cli -a '${REDIS_PASSWORD:-redissecret}' ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 10
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "${MAIL_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
networks:
- termanager2
volumes:
db_data_dev:
redis_data_dev:
networks:
termanager2:
driver: bridge

View File

@@ -1,132 +1,186 @@
services: # =============================================================================
app: # TerManager2 — production stack (Dokploy)
#
# The application image is self-contained: PHP dependencies and compiled
# frontend assets are baked in at build time, and nginx runs alongside php-fpm
# inside the app container. There are no bind mounts and no build steps at
# boot, so a redeploy is: build image, migrate, serve.
#
# Configuration comes from the .env file, which Dokploy writes from the
# variables you set in its UI. This file does not re-declare them — it only
# pins the handful of values dictated by the topology below (service names,
# which datastores exist), because those must match this file, not .env.
#
# For local development use docker-compose.dev.yml instead.
# =============================================================================
x-app: &app
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
container_name: termanager2_app
restart: unless-stopped restart: unless-stopped
# Runs as root so the entrypoint can mkdir/chown the bind-mounted storage; # required:false so `docker compose config` and a bare checkout still work;
# php-fpm workers drop to www-data via docker/php/www.conf. # a missing .env then surfaces as the explicit DB_PASSWORD error below.
working_dir: /var/www/html env_file:
- path: .env
required: false
volumes: volumes:
- ./:/var/www/html # Uploaded territory PDFs, thumbnails, logs and the persisted APP_KEY.
- storage_data:/var/www/html/storage
networks: networks:
- termanager2 - internal
# Values that are a property of THIS compose file rather than of the
# deployment, so they override whatever .env happens to say. A DB_HOST of
# 127.0.0.1 left over from a local .env would otherwise break the container.
x-app-env: &app-env
DB_CONNECTION: mysql
DB_HOST: mariadb
DB_PORT: 3306
REDIS_HOST: redis
REDIS_PORT: 6379
# The stack ships Redis and the worker below consumes the redis queue;
# pointing these elsewhere would silently break job processing.
CACHE_STORE: redis
SESSION_DRIVER: redis
QUEUE_CONNECTION: redis
# Logs go to the container's stderr, where `docker logs` and the Dokploy
# log viewer can see them, rather than into a file inside the volume.
LOG_CHANNEL: stderr
services:
app:
<<: *app
environment:
<<: *app-env
# Sources never change in a built image, so skip the stat() on every hit.
PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0
networks:
- internal
- dokploy-network
depends_on: depends_on:
mariadb: mariadb:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 9000"]
interval: 5s
timeout: 3s
retries: 20
start_period: 45s
environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
# DB/Redis creds must be passed into the container so the entrypoint can
# upsert them into .env. Defaults match the mariadb/redis services below.
- DB_HOST=${DB_HOST:-mariadb}
- DB_DATABASE=${DB_DATABASE:-termanager2}
- DB_USERNAME=${DB_USERNAME:-termanager2}
- DB_PASSWORD=${DB_PASSWORD:-secret}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PASSWORD=${REDIS_PASSWORD:-redissecret}
- SEED_DEV_DATA=${SEED_DEV_DATA:-false}
- RUN_DB_SEED_ON_FIRST_START=${RUN_DB_SEED_ON_FIRST_START:-true}
- ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}
- INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-}
nginx:
build:
context: .
dockerfile: docker/nginx/Dockerfile
container_name: termanager2_nginx
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
volumes:
- ./:/var/www/html:ro
networks:
- termanager2
depends_on:
app:
condition: service_healthy
queue-worker: queue-worker:
build: <<: *app
context: . user: www-data
dockerfile: docker/php/Dockerfile environment:
container_name: termanager2_queue <<: *app-env
restart: unless-stopped PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0
# Same UID as php-fpm workers, so storage files stay consistent.
user: "www-data"
working_dir: /var/www/html
volumes:
- ./:/var/www/html
networks:
- termanager2
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
entrypoint: ["php"] entrypoint: ["queue-entrypoint.sh"]
command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"] # --max-time recycles the worker hourly so a leaked handle or grown heap
# can't accumulate indefinitely; restarting it is Docker's job.
command:
- php
- artisan
- queue:work
- redis
- --queue=default
- --sleep=3
- --tries=3
- --timeout=600
- --max-time=3600
# Overrides the image's nginx-based HEALTHCHECK (no web server here).
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
scheduler:
<<: *app
user: www-data
environment: environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 <<: *app-env
depends_on:
app:
condition: service_healthy
entrypoint: ["queue-entrypoint.sh"]
# `schedule:work` is the container-native replacement for a host crontab —
# it wakes every minute and dispatches whatever routes/console.php defines
# (currently the audit-log cleanup).
command: ["php", "artisan", "schedule:work"]
# Same reason as above: this container runs neither nginx nor php-fpm, so
# the image's HTTP health check would mark it unhealthy forever.
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'schedule:work' > /dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
mariadb: mariadb:
image: mariadb:11 image: mariadb:11
container_name: termanager2_db
restart: unless-stopped restart: unless-stopped
# Interpolated (not env_file) because these are the *image's* own bootstrap
# variables under different names. The `:?` guards are what make a deploy
# with no configured passwords fail loudly instead of coming up with
# whatever default happened to be lying around.
environment: environment:
MARIADB_DATABASE: ${DB_DATABASE:-termanager2} MARIADB_DATABASE: ${DB_DATABASE:-termanager2}
MARIADB_USER: ${DB_USERNAME:-termanager2} MARIADB_USER: ${DB_USERNAME:-termanager2}
MARIADB_PASSWORD: ${DB_PASSWORD:-secret} MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret} MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required}
MARIADB_AUTO_UPGRADE: "1"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-buffer-pool-size=256M
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
networks: networks:
- termanager2 - internal
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 10
start_period: 30s
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: termanager2_redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret} environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
command:
- redis-server
- --appendonly
- "yes"
- --requirepass
- ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
- --maxmemory
- 256mb
# Sessions and cache live here; evict volatile keys rather than OOM.
- --maxmemory-policy
- allkeys-lru
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks:
- termanager2 - internal
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"] test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 10
start_period: 10s
mailpit:
image: axllent/mailpit:latest
container_name: termanager2_mail
restart: unless-stopped
ports:
- "${MAIL_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
networks:
- termanager2
volumes: volumes:
db_data: db_data:
driver: local
redis_data: redis_data:
driver: local storage_data:
networks: networks:
termanager2: # Datastores stay on an isolated network with no route to the outside.
internal:
driver: bridge driver: bridge
# Dokploy's shared Traefik network. Only `app` joins it, and Dokploy attaches
# the routing labels when you assign a domain to that service in the UI.
dokploy-network:
external: true

View File

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

View File

@@ -1,11 +1,21 @@
server { server {
listen 80; listen 80 default_server;
server_name localhost; server_name _;
root /var/www/html/public; root /var/www/html/public;
index index.php index.html; index index.php;
charset utf-8; charset utf-8;
client_max_body_size 64M;
# Territory PDF/ZIP imports are the largest uploads; keep this in step with
# upload_max_filesize / post_max_size in docker/php/php.ini.
client_max_body_size 256M;
# Behind Dokploy's Traefik, the real client IP arrives in X-Forwarded-For.
real_ip_header X-Forwarded-For;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_recursive on;
location / { location / {
try_files $uri $uri/ /index.php?$query_string; try_files $uri $uri/ /index.php?$query_string;
@@ -14,34 +24,70 @@ server {
location = /favicon.ico { access_log off; log_not_found off; } location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; }
# Vite emits content-hashed filenames, so these are safe to cache hard.
location /build/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public).
# Served directly by nginx rather than round-tripping through PHP.
location /storage/ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
error_page 404 /index.php; error_page 404 /index.php;
location ~ \.php$ { location ~ ^/index\.php(/|$) {
fastcgi_pass app:9000; fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params; include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_hide_header X-Powered-By; fastcgi_hide_header X-Powered-By;
# Forward proxy headers so Laravel can validate signed URLs behind reverse proxy # PDF generation and ZIP imports can outrun the default 60s.
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; fastcgi_read_timeout 300;
fastcgi_param HTTP_X_FORWARDED_HOST $http_host; fastcgi_buffers 16 16k;
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port; fastcgi_buffer_size 32k;
# Trust external proxy proto, or default to https for signed URL validation # Set server-side, NOT taken from the client's header: honouring a
set $fwd_proto $http_x_forwarded_proto; # client-supplied X-Forwarded-Proto would let an attacker declare the
if ($fwd_proto = "") { # connection plaintext and skew signed-URL validation and secure-cookie
set $fwd_proto "https"; # decisions. The value is written by the entrypoint from APP_URL's
# scheme (see /etc/nginx/conf.d/forwarded-proto.conf) — https in
# production behind Traefik, http for local plain-HTTP development.
fastcgi_param HTTP_X_FORWARDED_PROTO $app_forwarded_proto;
# X-Forwarded-Port is deliberately NOT set here. nginx already passes
# the incoming header through as HTTP_X_FORWARDED_PORT, and Traefik
# sends the real public port; overriding it with $server_port (always
# 80 inside the container) would corrupt generated URLs.
internal;
} }
fastcgi_param HTTP_X_FORWARDED_PROTO $fwd_proto;
# Any other .php file is not a valid entrypoint — Laravel routes everything
# through index.php. This blocks execution of uploaded/stray scripts.
location ~ \.php$ {
return 404;
} }
location ~ /\.(?!well-known).* { location ~ /\.(?!well-known).* {
deny all; deny all;
} }
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
} }

View File

@@ -1,10 +1,65 @@
FROM php:8.3-fpm # syntax=docker/dockerfile:1
# System dependencies # =============================================================================
RUN apt-get update && apt-get install -y \ # Stage 1 — PHP dependencies (no dev, production autoloader)
git \ # =============================================================================
FROM composer:2 AS vendor
WORKDIR /app
# --no-scripts: artisan isn't available yet, package discovery would fail.
# Autoload is optimized in the runtime stage once the full source is present.
COPY composer.json composer.lock ./
RUN composer install \
--no-interaction \
--no-dev \
--prefer-dist \
--no-progress \
--no-scripts \
--ignore-platform-reqs
# =============================================================================
# Stage 2 — Frontend assets (Vite + Tailwind)
# =============================================================================
FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
else \
npm install --no-audit --no-fund; \
fi
# Tailwind 4 scans blade/php sources for class names, so they must be present.
COPY vite.config.js ./
COPY resources ./resources
COPY app ./app
COPY config ./config
RUN npm run build
# =============================================================================
# Stage 3 — Runtime
# =============================================================================
FROM php:8.3-fpm AS runtime
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
# would need its own copy of public/ plus the storage volume just to serve
# static files, which is more moving parts than this app warrants.
# poppler-utils provides pdftoppm/pdfinfo used by TerritorioThumbnailService.
# Node is deliberately absent here: assets are built in stage 2.
# The -dev packages are kept rather than purged. Their runtime counterparts
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
# purging means pinning package names that break whenever the php base image
# moves to a new Debian. A few tens of MB is worth not having that breakage.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
netcat-openbsd \ netcat-openbsd \
procps \
nginx \
supervisor \
poppler-utils \
libpng-dev \ libpng-dev \
libjpeg62-turbo-dev \ libjpeg62-turbo-dev \
libfreetype6-dev \ libfreetype6-dev \
@@ -12,12 +67,8 @@ RUN apt-get update && apt-get install -y \
libxml2-dev \ libxml2-dev \
libzip-dev \ libzip-dev \
libicu-dev \ libicu-dev \
zip \
unzip \
supervisor \
poppler-utils \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \ && docker-php-ext-install -j"$(nproc)" \
pdo_mysql \ pdo_mysql \
mbstring \ mbstring \
exif \ exif \
@@ -32,52 +83,47 @@ RUN apt-get update && apt-get install -y \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Composer # Kept only so `composer dump-autoload` can run below; harmless at runtime.
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install Node.js 20 LTS COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf
&& apt-get install -y nodejs \ COPY docker/nginx/default.conf /etc/nginx/sites-available/default
&& apt-get clean \ COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
&& rm -rf /var/lib/apt/lists/*
# Build application in staging area
WORKDIR /app-src
# Install PHP dependencies first for better Docker layer caching
COPY composer.json composer.lock ./
RUN composer install --no-interaction --prefer-dist --no-progress --no-scripts
# Install Node dependencies first for better Docker layer caching
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
else \
npm install --no-audit --no-fund; \
fi
# Copy full source and build frontend assets
COPY . .
RUN mkdir -p bootstrap/cache storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/app
RUN if [ ! -f .env ] && [ -f .env.example ]; then cp .env.example .env; fi
RUN date -u +%Y%m%d%H%M%S > .image-build-id
RUN composer dump-autoload --optimize --no-interaction
RUN npm run build
# Runtime working directory
WORKDIR /var/www/html WORKDIR /var/www/html
# Copy PHP configuration # Everything the app needs is baked into the image. Runtime containers do NOT
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini # bind-mount the source, so there is no composer/npm work at boot.
COPY --chown=www-data:www-data . .
COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor
COPY --from=assets --chown=www-data:www-data /app/public/build ./public/build
# Copy PHP-FPM pool config # Writable paths. storage/ is replaced by a named volume at runtime; this is
COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf # the skeleton the entrypoint repopulates when that volume starts out empty.
RUN mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/public \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
RUN composer dump-autoload --optimize --no-dev --no-interaction \
&& php artisan package:discover --ansi
# Copy entrypoint
COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh COPY docker/php/queue-entrypoint.sh /usr/local/bin/queue-entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/queue-entrypoint.sh
EXPOSE 9000 EXPOSE 80
# /up is Laravel's built-in health endpoint (registered in bootstrap/app.php),
# so this exercises the full nginx -> php-fpm -> framework path, not just a port.
HEALTHCHECK --interval=10s --timeout=5s --start-period=120s --retries=12 \
CMD curl -fsS http://127.0.0.1/up >/dev/null || exit 1
ENTRYPOINT ["entrypoint.sh"] ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,246 +1,205 @@
#!/bin/bash #!/bin/bash
set -Eeuo pipefail set -Eeuo pipefail
echo "=========================================" # =============================================================================
echo " TerManager2 - Entrypoint" # TerManager2 — application container entrypoint
echo "=========================================" #
# The image is self-contained: vendor/ and public/build are baked in at build
# time. This script only does what genuinely cannot happen until runtime —
# resolve APP_KEY, wait for the database, migrate, and warm the caches.
#
# Configuration comes from real environment variables (Dokploy injects them).
# No .env file is written; Laravel reads the process environment directly.
# =============================================================================
warn() { log() { echo "[*] $*"; }
echo "[!] $*" ok() { echo "[✓] $*"; }
} warn() { echo "[!] $*" >&2; }
die() { echo "[✗] $*" >&2; exit 1; }
echo "========================================="
echo " TerManager2 — starting"
echo "========================================="
retry() { retry() {
local attempts="$1" local attempts="$1" delay="$2"; shift 2
local delay="$2"
shift 2
local n=1 local n=1
until "$@"; do until "$@"; do
if [ "$n" -ge "$attempts" ]; then if [ "$n" -ge "$attempts" ]; then
return 1 return 1
fi fi
warn "Command failed (attempt ${n}/${attempts}). Retrying in ${delay}s..." warn "Attempt ${n}/${attempts} failed. Retrying in ${delay}s..."
sleep "$delay" sleep "$delay"
n=$((n + 1)) n=$((n + 1))
done done
} }
upsert_env() { # -----------------------------------------------------------------------------
local key="$1" # 0. Required configuration
local value="$2" # APP_URL anchors every generated link (assignment PDFs, e-mails) and is
local env_file="$3" # matched against the incoming Host header, so a wrong or missing value
# surfaces as blank pages or 400s much later. Fail here instead, where the
if grep -q "^${key}=" "$env_file"; then # message is unambiguous.
sed -i "s|^${key}=.*|${key}=${value}|" "$env_file" # -----------------------------------------------------------------------------
else if [ -z "${APP_URL:-}" ]; then
echo "${key}=${value}" >> "$env_file" die "APP_URL is not set. Set it to the public URL of the application, e.g. https://territori.example.org"
fi
}
read_env_value() {
local key="$1"
local env_file="$2"
if [ ! -f "$env_file" ]; then
return 0
fi fi
local line case "$APP_URL" in
line=$(grep -E "^${key}=" "$env_file" | tail -n 1 || true) http://*|https://*) ;;
*) die "APP_URL must include the scheme (https://...), got: ${APP_URL}" ;;
esac
if [ -z "$line" ]; then # -----------------------------------------------------------------------------
return 0 # 1. Writable directories
fi # 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
local value="${line#*=}"
value="${value%\"}"
value="${value#\"}"
printf '%s' "$value"
}
# -----------------------------------------------
# 0. Application code is bind-mounted from host
# -----------------------------------------------
echo "[✓] Using bind-mounted application code from host."
# -----------------------------------------------
# 0b. Create required directories & fix permissions
# -----------------------------------------------
mkdir -p storage/framework/{cache,sessions,views}
mkdir -p storage/logs
mkdir -p storage/app
mkdir -p bootstrap/cache
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
chown -R www-data:www-data storage bootstrap/cache chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
fi fi
ok "Storage directories ready."
# ----------------------------------------------- # -----------------------------------------------------------------------------
# 1. .env file (must exist before composer/artisan) # 2. APP_KEY
# ----------------------------------------------- # Personal data of proclamatori is encrypted at column level with this key.
if [ ! -f .env ]; then # Losing it means losing that data, so a generated key is persisted into the
echo "[*] Creating .env from .env.example..." # storage volume and reused across redeploys.
cp .env.example .env # -----------------------------------------------------------------------------
fi
# Override .env values with Docker environment variables (if set)
# This ensures Dokploy env vars take precedence
if [ -n "${APP_KEY:-}" ]; then
upsert_env "APP_KEY" "${APP_KEY}" .env
fi
if [ -n "${APP_URL:-}" ]; then
upsert_env "APP_URL" "${APP_URL}" .env
fi
if [ -n "${DB_HOST:-}" ]; then
upsert_env "DB_HOST" "${DB_HOST}" .env
fi
if [ -n "${DB_DATABASE:-}" ]; then
upsert_env "DB_DATABASE" "${DB_DATABASE}" .env
fi
if [ -n "${DB_USERNAME:-}" ]; then
upsert_env "DB_USERNAME" "${DB_USERNAME}" .env
fi
if [ -n "${DB_PASSWORD:-}" ]; then
upsert_env "DB_PASSWORD" "${DB_PASSWORD}" .env
fi
if [ -n "${REDIS_HOST:-}" ]; then
upsert_env "REDIS_HOST" "${REDIS_HOST}" .env
fi
if [ -n "${REDIS_PASSWORD:-}" ]; then
upsert_env "REDIS_PASSWORD" "${REDIS_PASSWORD}" .env
fi
echo "[✓] .env file ready."
# -----------------------------------------------
# 2. Composer install (must run before artisan commands)
# -----------------------------------------------
if [ ! -f vendor/autoload.php ]; then
echo "[*] Installing Composer dependencies..."
retry 3 5 composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
else
echo "[✓] Composer dependencies already installed."
fi
# -----------------------------------------------
# 3. Application key (persisted in storage volume)
# -----------------------------------------------
KEY_FILE="/var/www/html/storage/app/.app_key" KEY_FILE="/var/www/html/storage/app/.app_key"
if [ -f "$KEY_FILE" ]; then if [ -n "${APP_KEY:-}" ]; then
# Restore key from persistent volume ok "APP_KEY provided via environment."
STORED_KEY=$(cat "$KEY_FILE") printf '%s' "$APP_KEY" > "$KEY_FILE"
upsert_env "APP_KEY" "$STORED_KEY" .env elif [ -s "$KEY_FILE" ]; then
echo "[✓] Application key restored from storage." APP_KEY="$(cat "$KEY_FILE")"
elif grep -q "^APP_KEY=$" .env; then export APP_KEY
# No stored key and .env has empty key: generate new one ok "APP_KEY restored from persistent storage."
echo "[*] Generating application key..."
php artisan key:generate --ansi
# Save to persistent volume
grep '^APP_KEY=' .env | cut -d= -f2- > "$KEY_FILE"
echo "[✓] Application key saved to persistent storage."
else else
# .env already has a key (from env var override or .env.example), persist it APP_KEY="base64:$(head -c 32 /dev/urandom | base64)"
grep '^APP_KEY=' .env | cut -d= -f2- > "$KEY_FILE" export APP_KEY
echo "[✓] Application key already set, saved to persistent storage." printf '%s' "$APP_KEY" > "$KEY_FILE"
warn "No APP_KEY was provided — generated one and persisted it to the"
warn "storage volume. Copy it into the Dokploy environment now:"
warn " APP_KEY=${APP_KEY}"
warn "If that volume is ever lost, encrypted personal data becomes"
warn "unrecoverable."
fi fi
# ----------------------------------------------- chmod 600 "$KEY_FILE" 2>/dev/null || true
# 4. NPM install & build assets if [ "$(id -u)" = "0" ]; then
# ----------------------------------------------- chown www-data:www-data "$KEY_FILE" 2>/dev/null || true
if [ ! -d node_modules ]; then fi
echo "[*] Installing NPM dependencies..."
if [ -f package-lock.json ]; then # -----------------------------------------------------------------------------
retry 3 5 npm ci --no-audit --no-fund # 2b. Forwarded scheme for nginx.
# Derived from APP_URL rather than from the client's X-Forwarded-Proto,
# which is attacker-controlled. https in production behind Traefik; http
# when developing locally over plain HTTP, so redirects don't jump scheme.
# -----------------------------------------------------------------------------
case "${APP_URL:-}" in
http://*) FORWARDED_PROTO="http" ;;
*) FORWARDED_PROTO="https" ;;
esac
cat > /etc/nginx/conf.d/forwarded-proto.conf <<EOF
map \$host \$app_forwarded_proto {
default ${FORWARDED_PROTO};
}
EOF
ok "Forwarded scheme set to ${FORWARDED_PROTO}."
# -----------------------------------------------------------------------------
# 3. Storage symlink (public/storage -> storage/app/public)
# -----------------------------------------------------------------------------
if [ ! -e public/storage ]; then
php artisan storage:link --quiet && ok "Storage symlink created."
else else
retry 3 5 npm install --no-audit --no-fund 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
warn "Seed failed. The app will start, but roles/permissions may be missing."
fi fi
else else
echo "[✓] NPM dependencies already installed." ok "Seed already applied (or disabled)."
fi fi
if [ ! -f public/build/manifest.json ]; then # -----------------------------------------------------------------------------
echo "[*] Building frontend assets..." # 7. Initial administrator
npm run build # -----------------------------------------------------------------------------
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 else
echo "[✓] Frontend assets already built." warn "INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD not set — no admin created."
warn "Set them in the Dokploy environment to get a login on first deploy."
fi
fi fi
# ----------------------------------------------- # -----------------------------------------------------------------------------
# 5. Storage link # 8. Warm caches. Done last, so the config cache captures the final env.
# ----------------------------------------------- # -----------------------------------------------------------------------------
if [ ! -L public/storage ]; then log "Warming caches..."
echo "[*] Creating storage symlink..." php artisan config:cache --quiet || warn "config:cache failed; continuing."
php artisan storage:link php artisan route:cache --quiet || warn "route:cache failed; continuing."
else php artisan view:cache --quiet || warn "view:cache failed; continuing."
echo "[✓] Storage symlink already exists." ok "Caches warmed."
fi
# -----------------------------------------------
# 6b. Clear any stale cached config from a previous deploy
# (a cached bootstrap/cache/config.php would override the .env above)
# -----------------------------------------------
echo "[*] Clearing stale cached config..."
php artisan config:clear || warn "config:clear failed; continuing startup."
# -----------------------------------------------
# 7. Run migrations
# -----------------------------------------------
echo "[*] Running database migrations..."
retry 10 3 php artisan migrate --force
# -----------------------------------------------
# 7b. Seed database on first container startup only
# -----------------------------------------------
SEED_MARKER_FILE="/var/www/html/storage/framework/.runtime_db_seeded"
RUN_DB_SEED_ON_FIRST_START="${RUN_DB_SEED_ON_FIRST_START:-true}"
if [ "$RUN_DB_SEED_ON_FIRST_START" = "true" ]; then
if [ ! -f "$SEED_MARKER_FILE" ]; then
echo "[*] First startup detected. Running database seed..."
retry 5 3 php artisan db:seed --force
touch "$SEED_MARKER_FILE"
echo "[✓] Database seed completed and startup marker saved."
else
echo "[✓] Seed already executed on a previous startup."
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."
echo "=========================================" echo "========================================="
echo " TerManager2 - Ready!" echo " TerManager2 — ready"
echo "=========================================" echo "========================================="
# -----------------------------------------------
# Execute CMD (default: php-fpm)
# -----------------------------------------------
exec "$@" exec "$@"

View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -Eeuo pipefail
# =============================================================================
# TerManager2 — queue worker entrypoint
#
# Deliberately does NOT migrate or seed: the app container owns schema changes.
# Two containers racing on `artisan migrate` is how you get a half-applied
# schema. This one only waits for its dependencies and starts working.
# =============================================================================
warn() { echo "[!] $*" >&2; }
# APP_KEY must match the app container or encrypted payloads won't decrypt.
KEY_FILE="/var/www/html/storage/app/.app_key"
if [ -z "${APP_KEY:-}" ] && [ -s "$KEY_FILE" ]; then
APP_KEY="$(cat "$KEY_FILE")"
export APP_KEY
fi
echo "[*] Queue worker waiting for database and redis..."
for _ in $(seq 1 60); do
if nc -z "${DB_HOST:-mariadb}" "${DB_PORT:-3306}" \
&& nc -z "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}"; then
echo "[✓] Dependencies reachable."
break
fi
sleep 2
done
# Wait for the app container to finish migrating before consuming jobs, so a
# job doesn't hit a table that doesn't exist yet.
for _ in $(seq 1 60); do
if php artisan migrate:status --no-interaction >/dev/null 2>&1; then
break
fi
sleep 2
done
exec "$@"

View File

@@ -1,7 +1,10 @@
[www] [www]
user = www-data user = www-data
group = www-data group = www-data
listen = 0.0.0.0:9000
; nginx now runs in the same container, so php-fpm no longer needs to be
; reachable from the network. Binding to loopback removes that exposure.
listen = 127.0.0.1:9000
listen.owner = www-data listen.owner = www-data
listen.group = www-data listen.group = www-data
@@ -11,3 +14,15 @@ pm.start_servers = 4
pm.min_spare_servers = 2 pm.min_spare_servers = 2
pm.max_spare_servers = 6 pm.max_spare_servers = 6
pm.max_requests = 500 pm.max_requests = 500
; Surface worker output in `docker logs` instead of swallowing it.
catch_workers_output = yes
decorate_workers_output = no
; php-fpm sanitizes the environment by default, which would hide every Dokploy
; env var from Laravel. clear_env=no is what makes config-from-env work.
clear_env = no
; Log slow requests (PDF import/generation) to help diagnose timeouts.
slowlog = /proc/self/fd/2
request_slowlog_timeout = 30s

View File

@@ -0,0 +1,38 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid
# Both programs log to the container's stdout/stderr so `docker logs` and the
# Dokploy log viewer show everything without shelling into the container.
[program:php-fpm]
command=php-fpm --nodaemonize --force-stderr
autostart=true
autorestart=true
startretries=3
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
startretries=3
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
# If either process dies for good, take the whole container down so Docker's
# restart policy (and Dokploy's health check) can react. Without this the
# container stays "up" while serving nothing.
[eventlistener:process_exit]
command=bash -c "printf 'READY\\n'; while read -r line; do echo \"Process exited: $line\" >&2; kill -SIGTERM $PPID; done < /dev/stdin"
events=PROCESS_STATE_FATAL

View File

@@ -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> <span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
@endif @endif
</td> </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"> <td class="px-4 py-3 text-sm text-right">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio"> <a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio">