Merge develop in main: versione 1.2.2

Avvisi certificati a 30 giorni per il giocatore (DD-041), turno palloni e sollecito presenze una sola volta (DD-042, DD-045), niente promemoria evento (DD-043), niente foto tessera (DD-044). Migration m27-m30 già applicate in produzione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-01 15:41:26 +02:00
commit 008f0d784c
44 files changed
+832 -355

No files matched your search

+6 -4
View File
@@ -9,12 +9,14 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione in produzione:** 1.2.1 su `main` — notifiche su tre canali (push, in-app, email), - **Versione:** 1.2.2 su `develop` (su `main`, in produzione, resta la 1.2.1 fino al merge) — notifiche
promemoria che non si ripetono, turno palloni e solleciti presenze automatici. su tre canali, avvisi di palloni (3 ore prima) e presenze (24 ore prima) una sola volta, niente
promemoria evento, niente foto tessera, avviso certificato a 30 giorni per il giocatore.
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 37 file in `supabase/migrations/`, fino a `m26_notifiche_automatiche`, tutte - **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
applicate in produzione (verificato con `npx supabase migration list` il 30/09/2026). applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `develop`, non ancora su `main`. è su `develop`, non ancora su `main`.
+33
View File
@@ -13,6 +13,39 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
## [1.2.2] - 2026-10-01
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
promemoria degli eventi non c'è più, la foto tessera è stata tolta e l'avviso del certificato medico arriva al
giocatore un mese prima. Le migration `m27`–`m30` sono applicate in produzione dal 01/10/2026; questa versione porta il
codice dell'app che le accompagna, da rilasciare subito: la `m29` ha tolto la colonna `foto_path` che la versione
precedente ancora legge.
### Rimosso
- **Foto tessera del profilo** — il giocatore non la carica più e l'admin non la vede nella scheda; il
completamento del profilo si calcola su dati personali (34%), documento (33%) e certificato (33%). Migration
`m29_senza_foto_tessera` (elimina la colonna `foto_path`); i file già caricati sono stati cancellati dal
bucket `profili-giocatore` (DD-044). Applicata in produzione il 01/10/2026, con la cancellazione dei 4 file già caricati.
- **Promemoria evento a 24 e 3 ore** — non partono più per nessun evento (allenamenti, partite, extra-campo):
niente notifica, push né email. Migration `m28_niente_promemoria_evento` (tolti i due job `pg_cron`;
funzione, tipi e notifiche già generate restano). Applicata in produzione il 01/10/2026 (DD-043).
### Modificato
- **Sollecito presenze: un solo avviso automatico, 24 ore prima** — a chi non ha risposto o ha risposto
«forse»; prima ne partivano tre (24, 12 e 6 ore). Il pulsante «Sollecita» non cambia. Migration
`m30_sollecito_solo_24h`, applicata in produzione il 01/10/2026 (DD-045).
- **Turno palloni: un solo avviso, 3 ore prima** — a chi deve portare i palloni e a chi li deve
prendere; prima ne partivano tre (12, 6 e 3 ore). Migration `m27_palloni_solo_3h`, applicata in
produzione il 01/10/2026 (DD-042).
- **Avviso certificati: giocatore a 30 giorni** — il giocatore vede l'avviso giallo sul proprio
certificato medico da un mese prima della scadenza (prima 7 giorni). Per gli admin non cambia
nulla: 7 giorni prima in giallo, nero dopo la scadenza (DD-041).
## [1.2.1] - 2026-09-30 ## [1.2.1] - 2026-09-30
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
+13 -13
View File
@@ -66,10 +66,10 @@ Colonne di `giocatori_squadra` che meritano una nota:
## Storage ## Storage
| Bucket | Scopo | Note | | Bucket | Scopo | Note |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato e destinato a restare tale**: documenti e dati sanitari non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella; l'admin scarica tutto con signed URL a scadenza breve. | | `profili-giocatore` | Documento d'identità e certificato medico, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato e destinato a restare tale**: documenti e dati sanitari non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella; l'admin scarica tutto con signed URL a scadenza breve. |
| `avatar-giocatori` | Foto profilo (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali. Qualsiasi autenticato può caricare, sostituire o eliminare un file (nessun controllo per proprietario: la maggior parte dei giocatori non ha ancora `auth_user_id`, DD-018). Letto da `src/lib/avatar-store.ts`. | | `avatar-giocatori` | Foto profilo (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali. Qualsiasi autenticato può caricare, sostituire o eliminare un file (nessun controllo per proprietario: la maggior parte dei giocatori non ha ancora `auth_user_id`, DD-018). Letto da `src/lib/avatar-store.ts`. |
## Eventi e presenze ## Eventi e presenze
@@ -151,15 +151,15 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040. [modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` | | Funzione (migration) | Cosa fa | Job `pg_cron` |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | | `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26), con `genera_avvisi_palloni_fascia()` | Avvisi del turno palloni a 12, 6 e 3 ore per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) | | `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | | `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26), con `..._fascia()` | Solleciti a 24, 12 e 6 ore ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) | | `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — | | `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | | `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — | | `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Badge ## Badge
+1 -1
View File
@@ -19,7 +19,7 @@ Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ot
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
letto. letto.
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno 6. **Push solo per eventi importanti**: convocazioni, turno
palloni, esito finale. palloni, esito finale.
7. **Niente funzionalità pesanti**: foto, video, chat. 7. **Niente funzionalità pesanti**: foto, video, chat.
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration. 8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
+1 -1
View File
@@ -26,4 +26,4 @@ il campionato. Dove serve il dettaglio c'è il rimando al modulo.
| **Worker** | Il container Docker in `mailer/` che invia email e push dei promemoria con la service role ([WORKER_EMAIL.md](WORKER_EMAIL.md)). | | **Worker** | Il container Docker in `mailer/` che invia email e push dei promemoria con la service role ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| **Service role** | Chiave Supabase che bypassa la RLS. Solo server e worker, mai il client ([OPERATIONS.md](OPERATIONS.md)). | | **Service role** | Chiave Supabase che bypassa la RLS. Solo server e worker, mai il client ([OPERATIONS.md](OPERATIONS.md)). |
| **DD-XXX** | Decisione registrata in [decisions/](decisions/README.md). | | **DD-XXX** | Decisione registrata in [decisions/](decisions/README.md). |
| **M1…M26** | Numero d'ordine delle migration (`m26_notifiche_automatiche`…) in `supabase/migrations/`. | | **M1…M27** | Numero d'ordine delle migration (`m27_palloni_solo_3h`…) in `supabase/migrations/`. |
+3 -3
View File
@@ -27,7 +27,7 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Dashboard amministratore - [x] Dashboard amministratore
- [x] Download CSV dati - [x] Download CSV dati
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico - [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati (caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
resta un'estensione futura resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già - [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera) tesserato (numero e data di tessera)
@@ -44,8 +44,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati, - [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036) ([specifica](modules/notifiche-email.md), DD-036)
- [x] Notifiche automatiche — promemoria con «oggi/domani», turno palloni 12, 6 e 3 ore prima, sollecito - [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
presenze a 24, 12 e 6 ore (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040) presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
## Prossimo ## Prossimo
+1 -1
View File
@@ -1,7 +1,7 @@
# Worker email: installazione e gestione # Worker email: installazione e gestione
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
per email e le push delle notifiche che nascono nel database: promemoria a 24 e 3 ore, turno palloni a 12, 6 e 3 ore con revoca, solleciti presenze a 24, 12 e 6 ore (DD-040). Cosa fa il canale email sta in per email e le push delle notifiche che nascono nel database: turno palloni a 3 ore con revoca, sollecito presenze a 24 ore (DD-040). Cosa fa il canale email sta in
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in [modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in [modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
[DD-036](decisions/DD-036.md) [DD-036](decisions/DD-036.md)
+1 -1
View File
@@ -16,7 +16,7 @@ Regole vincolanti:
1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati. 1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali. 2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**. 3. I file (documento identità, certificato; la foto tessera è stata tolta da DD-044) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin. 4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012). 5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo. 6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo.
+1 -1
View File
@@ -15,7 +15,7 @@ Dalla dashboard amministratore, un admin può:
Restano fuori, e non cambiano: Restano fuori, e non cambiano:
- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile; - i **file** (documento, certificato; la foto tessera è stata tolta da DD-044): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
- il **giocatore**, che continua a non poter toccare i propri dati squadra. - il **giocatore**, che continua a non poter toccare i propri dati squadra.
**Alternative scartate** **Alternative scartate**
+2 -2
View File
@@ -50,5 +50,5 @@ torna utile, e con lui il segreto: DD-024 descrive già come farlo.
--- ---
**Aggiornamento (30 settembre 2026)** — DD-040 affianca al pulsante un invio automatico a 12, 6 e 3 ore **Aggiornamento (30 settembre 2026)** — DD-040 affianca al pulsante un invio automatico (poi ridotto a uno solo, 3 ore
prima dell'evento, per gli eventi con un incaricato confermato; il pulsante e la route restano. prima, da DD-042), per gli eventi con un incaricato confermato; il pulsante e la route restano.
+2 -2
View File
@@ -1,7 +1,7 @@
# DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin # DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
**Data:** 24 settembre 2026 **Data:** 24 settembre 2026
**Stato:** Accettata (implementata, versione 1.1.0) **Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7.
**Contesto** **Contesto**
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
@@ -49,7 +49,7 @@ certificato viene rinnovato. Specifica completa in
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna - Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
da solo. da solo.
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home. - Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome. - ~~Un admin che è anche giocatore vede solo l'avviso dello staff~~ → superato da DD-041: vede anche il proprio avviso personale.
**Riesame** **Riesame**
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati), Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
+1 -2
View File
@@ -1,8 +1,7 @@
# DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici # DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici
**Data:** 30 settembre 2026 **Data:** 30 settembre 2026
**Stato:** Accettata (implementata e verificata in locale: migration `m26`, job, route e test; la **Stato:** Accettata (implementata: migration `m26`, applicata in produzione il 30 settembre 2026, job, route e test). Il sollecito presenze è poi passato a un solo avviso a 24 ore da [DD-045](DD-045.md). Il turno palloni è poi passato a un solo avviso a 3 ore da [DD-042](DD-042.md).
migration non è ancora applicata in produzione)
**Contesto** **Contesto**
Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno
+39
View File
@@ -0,0 +1,39 @@
# DD-041 — Avviso certificati: il giocatore è avvisato un mese prima dello staff
**Data:** 1 ottobre 2026
**Stato:** Accettata (modifica la soglia di [DD-035](DD-035.md) per il solo giocatore)
**Contesto**
Con DD-035 giocatore e admin vedono l'avviso negli stessi 7 giorni prima della scadenza. Per
il giocatore sono pochi: prenotare una visita di idoneità richiede più tempo (era il rischio
indicato nel Riesame di DD-035). All'admin invece un avviso così anticipato riempirebbe la
Home di nomi che non può ancora sollecitare con urgenza.
**Decisione**
1. Il **giocatore** vede l'avviso giallo da **30 giorni** prima della scadenza
(`GIORNI_AVVISO_CERTIFICATO_GIOCATORE`).
2. L'**admin** resta a **7 giorni** prima (`GIORNI_AVVISO_CERTIFICATO`), invariato.
3. Dopo la scadenza l'avviso è **nero** per entrambi, invariato.
4. L'**admin che è anche giocatore** vede il proprio avviso **personale** (30 giorni,
cliccabile) oltre a quello dello staff, dal quale il suo nome è tolto per non duplicarlo.
5. Nessun altro aspetto di DD-035 cambia: calcolo al volo, niente push, stessi destinatari.
Esempio: scadenza il 5 ottobre 2026 → dal 5 settembre l'avviso giallo compare solo al
giocatore; dal 28 settembre compare anche agli admin; dal 6 ottobre è nero per entrambi.
**Alternative scartate**
- «Un mese» come mese di calendario → la soglia varierebbe tra 28 e 31 giorni; 30 giorni fissi
sono prevedibili e coincidono con l'esempio della richiesta.
- Soglia unica a 30 giorni anche per l'admin → elenco dello staff pieno con settimane di
anticipo, senza nulla da fare per l'admin che non può caricare il file (DD-017).
**Conseguenze**
- Il giocatore vede il conto alla rovescia «scade tra N giorni» con N fino a 30.
- `avvisiCertificati()` riceve la soglia come parametro (default 7, cioè lo staff).
**Riesame**
Se 30 giorni risultano ancora pochi o troppi, cambiare la costante; se serve una soglia
regolabile dall'admin, vedi il Riesame di DD-035.
+36
View File
@@ -0,0 +1,36 @@
# DD-042 — Turno palloni: un solo avviso, 3 ore prima
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m27`, applicata in produzione il 1 ottobre 2026; modifica la parte «turno palloni» di [DD-040](DD-040.md))
**Contesto**
DD-040 manda l'avviso del turno palloni a 12, 6 e 3 ore prima dell'evento: tre messaggi identici per
chi deve portare i palloni e per chi deve prenderli. Sono troppi per un'informazione che non cambia.
**Decisione**
1. L'avviso automatico parte **una sola volta, 3 ore prima** dell'inizio (finestra «meno di 3 ore»), a
chi deve portare i palloni («incarico assegnato») e a chi li deve prendere («riconsegna», se è
un'altra persona). Testi, destinatari, push senza email e condizione dell'incaricato confermato
restano quelli di DD-040.
2. La revoca resta: chi è stato avvisato e poi non è più destinatario riceve una sola volta «incarico
revocato». Un evento creato o assegnato a meno di 3 ore dall'inizio riceve l'avviso al giro
successivo del job.
3. **Migration `m27`:** `genera_avvisi_palloni()` chiama solo la fascia `turno_palloni_3h`. I tipi
`turno_palloni_12h` e `_6h` **restano nei `CHECK`** e nella revoca, perché notifiche e righe di
registro già generate li usano; non ne nascono di nuovi.
4. Il pulsante «Avvisa chi è di turno» non cambia. I solleciti presenze non cambiano qui (poi ridotti a uno, DD-045).
**Alternative scartate**
- Togliere i tipi `12h` e `6h` dai `CHECK` → le righe già generate in produzione non sarebbero più
valide e la revoca non vedrebbe più chi ha ricevuto quegli avvisi.
- Tenere anche un avviso a 12 ore → scartata su indicazione esplicita: uno solo.
**Conseguenze**
- Chi non apre l'app nelle 3 ore prima può non vedere l'avviso; la push è il canale principale.
- Per le notifiche palloni già generate con DD-040 non cambia nulla.
**Riesame**
Se 3 ore risultano poche per procurarsi i palloni, alzare la finestra (una sola, mai più avvisi).
+35
View File
@@ -0,0 +1,35 @@
# DD-043 — Niente promemoria automatico a 24 e 3 ore prima degli eventi
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m28`, applicata in produzione il 1 ottobre 2026; sostituisce il promemoria di
[DD-030](DD-030.md), [DD-037](DD-037.md) e [DD-040](DD-040.md))
**Contesto**
Il promemoria a 24 e 3 ore prima di ogni evento non serve: la squadra ha già il calendario, il
sollecito presenze (per chi non ha risposto) e l'avviso del turno palloni a 3 ore.
**Decisione**
1. Il database **non genera più** `evento_promemoria_24h` ed `evento_promemoria_3h`, per nessun tipo di
evento (allenamento, partita, extra-campo).
2. La migration `m28` toglie solo i due job `pg_cron` (`promemoria-eventi-24h` e `-3h`).
`genera_promemoria_eventi()`, i tipi nei `CHECK`, il registro `promemoria_eventi_generati` e la coda
push restano: le notifiche già create continuano a esistere e rimettere il promemoria richiede solo
un `cron.schedule`.
3. Non cambiano: sollecito presenze, turno palloni (DD-042), messaggio dello staff, sondaggio.
**Alternative scartate**
- Togliere il promemoria solo per gli allenamenti → scartata su indicazione esplicita: via per tutti.
- Cancellare funzione, tipi e registro → scartata: vanno tenute le notifiche già generate e il lavoro per
rimetterlo sarebbe molto più grande di un `cron.schedule`.
**Conseguenze**
- Meno notifiche per la squadra. Chi voleva il promemoria di una partita non lo riceve più.
- Funzione, test e registro restano come codice non pianificato: sono ancora coperti da
`promemoria-eventi.test.ts` e dagli altri test sulla funzione.
**Riesame**
Se la squadra sente la mancanza del promemoria (per esempio solo per le partite), rimettere i job o
reintrodurlo per tipo di evento.
+38
View File
@@ -0,0 +1,38 @@
# DD-044 — Via la foto tessera dal profilo
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m29`, applicata in produzione il 1 ottobre 2026)
**Contesto**
Il profilo amministrativo chiedeva al giocatore una foto formato tessera (DD-016), per il
tesseramento CSI. Non serve più: non viene richiesta nel tesseramento e resta un dato personale
da custodire.
**Decisione**
1. Il profilo **non ha più la foto tessera**: sparisce il campo di caricamento dal Profilo e la
voce «Foto» dalla scheda dell'admin.
2. **Completamento del profilo** su tre sezioni: dati personali 34%, documento 33%, certificato 33%
(prima 30/30/30/10). Il totale resta 100 e il widget «Completa il tuo profilo» sparisce con le
tre sezioni complete.
3. **Dati:** la migration `m29` elimina la colonna `profili_giocatore.foto_path`. I file
`<giocatore_id>/foto.*` del bucket privato `profili-giocatore` vanno cancellati con l'API
Storage (da SQL Storage lo vieta) prima della migration, perché dopo i path non sono più
leggibili. **Non toccati:** l'avatar (bucket `avatar-giocatori`), il documento e il certificato.
4. Ordine in produzione: **prima il rilascio del codice** (la versione precedente seleziona
`foto_path` e darebbe errore), poi cancellazione dei file, poi `m29`.
**Alternative scartate**
- Lasciare la colonna e nascondere solo l'interfaccia → scartata: resterebbe un dato personale
senza uso e senza un campo da cui aggiornarlo.
- Tenere i pesi 30/30/30 senza rinormalizzare → scartata: nessun profilo arriverebbe a 100%.
**Conseguenze**
- Cancellazione definitiva: le foto già caricate non si recuperano.
- Il 1 ottobre 2026 sono state cancellate in produzione le 4 foto caricate, poi la colonna.
- Le percentuali di completamento cambiano per tutti (chi aveva il 90% senza foto passa al 100%).
**Riesame**
Se il comitato CSI dovesse richiedere di nuovo la foto, reintrodurla con una nuova decisione.
+34
View File
@@ -0,0 +1,34 @@
# DD-045 — Sollecito presenze: un solo avviso automatico, 24 ore prima
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m30`, applicata in produzione il 1 ottobre 2026; modifica la parte «sollecito presenze»
di [DD-040](DD-040.md))
**Contesto**
DD-040 manda il sollecito presenze a 24, 12 e 6 ore prima dell'evento: fino a tre messaggi per chi non
risponde o resta su «forse». Ne basta uno.
**Decisione**
1. Il sollecito automatico parte **una sola volta, 24 ore prima** (finestra «meno di 24 ore»), a chi in
quel momento non ha risposto o ha risposto «forse» tra i destinatari dell'evento (convocati, o tutta
la rosa attiva; mai l'allenatore). Un evento creato a meno di 24 ore dall'inizio lo riceve comunque,
al giro successivo del job. Testo e canali (push, centro notifiche, email) restano quelli di DD-040.
2. Il **pulsante «Sollecita»** e la route `sollecita-presenze` non cambiano: l'admin o l'allenatore possono
sempre rimandarlo a mano, anche dopo l'automatico.
3. **Migration `m30`:** `genera_solleciti_presenze()` chiama solo la fascia `sollecita_presenze_24h`. I tipi
`sollecita_presenze_12h` e `_6h` restano nei `CHECK` per le notifiche già generate; non ne nascono di nuovi.
**Alternative scartate**
- Mandarlo a un orario più vicino (12 o 6 ore) → scartata su indicazione esplicita: 24 ore.
- Togliere i tipi `12h` e `6h` dai `CHECK` → le righe già generate non sarebbero più valide.
**Conseguenze**
- Chi risponde «forse» o non risponde dopo il sollecito non riceve altri avvisi automatici: tocca al
pulsante manuale.
- Il massimo di mail automatiche per giocatore ed evento scende a una (il sollecito).
**Riesame**
Se il tasso di risposta cala, valutare un secondo sollecito più vicino all'evento, solo per chi non ha mai risposto.
+45 -40
View File
@@ -46,46 +46,51 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
**Accettate** **Accettate**
| ID | Titolo | | ID | Titolo |
| ------------------- | ------------------------------------- | | ------------------- | ------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable | | [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first | | [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive | | [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first | | [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile | | [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB | | [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli | | [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API | | [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati | | [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo | | [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID | | [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack | | [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB | | [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore | | [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore | | [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email | | [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente | | [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi | | [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion | | [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara | | [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo | | [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate | | [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale | | [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato | | [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati | | [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP | | [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato | | [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app | | [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata | | [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare | | [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario | | [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore | | [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home | | [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail | | [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni | | [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica | | [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
**In valutazione** **In valutazione**
+12 -12
View File
@@ -55,8 +55,8 @@ Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
| Telefono | `profili_giocatore.telefono` | | | Telefono | `profili_giocatore.telefono` | |
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori | | Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto **Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico,
tessera, tesseramento CSI. tesseramento CSI.
### Completamento profilo ### Completamento profilo
@@ -114,7 +114,7 @@ a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il `sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono solleciti automatici a 24, 12 e 6 ore prima dell'evento, ma il pulsante resta. sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono un sollecito automatico 24 ore prima dell'evento, ma il pulsante resta.
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI, **Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
@@ -125,14 +125,14 @@ risposte presenze altrui, gestione dei ruoli.
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
[notifiche.md](notifiche.md#catalogo-delle-notifiche)): [notifiche.md](notifiche.md#catalogo-delle-notifiche)):
| Notifica | L'allenatore la riceve? | Note | | Notifica | L'allenatore la riceve? | Note |
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). | | Promemoria a 24 e 3 ore prima di un evento (rimosso, DD-043) | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. | | Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . | | Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
| Turno palloni | no | I turni riguardano solo i giocatori. | | Turno palloni | no | I turni riguardano solo i giocatori. |
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. | | Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. | | Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
@@ -164,7 +164,7 @@ giocatore).
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- | | ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
| Modificare nome e cognome | no | sì, i propri | tutti | | Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) | | Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato, foto tessera | i propri | — | lettura/download | | Documento, certificato | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa | | Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì | | Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì | | Sollecitare le presenze | no | sì | sì |
+1 -2
View File
@@ -49,8 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi ## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a **Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
3 ore, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il `m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
+46 -45
View File
@@ -34,25 +34,25 @@ in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne ([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
quelle del turno palloni**, che restano solo push e in-app: DD-040). quelle del turno palloni**, che restano solo push e in-app: DD-040).
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email | | # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- | | --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
| 1 | **Promemoria a 24 ore** — «Promemoria evento di _domani_ (o _oggi_): _titolo_», con data, ora e luogo | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì | | 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì | | 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì | | 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **12, 6 e 3 ore prima** dell'evento (DD-040): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipi `turno_palloni_12h`, `_6h` e `_3h`, push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) | | 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, in tre tempi (DD-040): **24, 12 e 6 ore** prima dell'evento, dallo stesso job; tipi `sollecita_presenze_24h`, `_12h`, `_6h`, push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì | | 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | | 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | | 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
automatiche (1, 2, 4 e 5) nascono nel database e la manda il worker `mailer/` (vedi «Push dei automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
lo stesso evento (vedi «Centro notifiche in-app»). lo stesso evento (vedi «Centro notifiche in-app»).
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e > **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
> verificate in locale; la migration va ancora applicata in produzione. Finché non lo è, l'app si comporta > applicate in produzione (`m26` il 30/09/2026; `m27` il 01/10/2026, che porta il turno palloni a un solo
> come in 1.2.0: promemoria con il testo breve, turno palloni e sollecito presenze solo da pulsante. > avviso 3 ore prima, DD-042).
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche). Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
@@ -63,17 +63,17 @@ volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato d
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di [notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`. disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
| # | Notifica | Titolo | Corpo | | # | Notifica | Titolo | Corpo |
| --- | ----------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1-2 | Promemoria 24 e 3 ore | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) | | 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. | | 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca | | 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` | | 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta | | 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore | | 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | | 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
Le righe 1-2, 4a-4c e 5 nascono nelle funzioni SQL della migration `m26` Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`, (`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts` `genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano (`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
@@ -96,8 +96,8 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
### Cosa non genera nessuna notifica ### Cosa non genera nessuna notifica
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima - Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
dell'inizio (DD-030). promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
- L'arrivo di un risultato, di una pagella o di un voto MVP. - L'arrivo di un risultato, di una pagella o di un voto MVP.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi. - Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
- Assenze e infortuni. - Assenze e infortuni.
@@ -106,7 +106,9 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
### Comportamenti da conoscere ### Comportamenti da conoscere
Sono l'effetto attuale della logica, non scelte documentate altrove: Sono l'effetto attuale della logica, non scelte documentate altrove. I punti sui **promemoria 1 e 2**
descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessun job la chiama
(DD-043); valgono solo se i job vengono ripianificati.
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed - **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
@@ -134,28 +136,28 @@ Sono l'effetto attuale della logica, non scelte documentate altrove:
proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo
un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta
«Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso. «Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso.
- **I solleciti cadono in finestre che non si sovrappongono** (DD-040): 24 ore tra 24 e 12 ore - **Il sollecito automatico è uno solo, a 24 ore** (DD-045): parte per gli eventi che iniziano entro 24
prima dell'inizio, 12 ore tra 12 e 6, 6 ore sotto le 6. Un evento creato a meno di 12 ore ore, a chi in quel momento non ha risposto o ha risposto «forse». Un evento creato a meno di 24
dall'inizio riceve solo quello della sua finestra. Chi risponde presente, assente o in ritardo ore dall'inizio lo riceve comunque, una volta. Chi risponde dopo non ne riceve altri; chi resta su
smette di riceverli; chi resta su «forse» li riceve tutti e tre. «forse» non lo riceve una seconda volta. Altri solleciti solo col pulsante manuale.
- **Per ogni evento un giocatore riceve al massimo cinque mail automatiche** (DD-040): due - **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il
promemoria e tre solleciti, più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app. (avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche») - **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere. e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
### Test che verificano il catalogo ### Test che verificano il catalogo
| Cosa | Test | | Cosa | Test |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` | | Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` | | Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` | | Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` | | Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` | | Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Promemoria con «oggi/domani», turno palloni a 12·6·3 ore e revoca, solleciti a 24·12·6 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`) | `integration/notifiche-automatiche.test.ts` (nuovo), `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` | | Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` | | Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) | | L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
@@ -282,12 +284,11 @@ non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico per
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database). stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
job `pg_cron` come la 2, che scrive `turno_palloni_12h`/`_6h`/`_3h`/`_revocato` e `sollecita_presenze_24h`/ job `pg_cron` come la 2, che scrive `turno_palloni_3h`/`_revocato` (`_12h`/`_6h` solo fino a `m26`) e `sollecita_presenze_24h` (`_12h`/`_6h` solo fino a `m29`); i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
`_12h`/`_6h`; i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario 1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso). in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15 2. **Promemoria evento (rimosso dalla `m28`, DD-043: i job non esistono più)** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro (o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)` `promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
@@ -325,7 +326,7 @@ ritrova per sempre nell'elenco, solo segnata come letta.
## Push dei promemoria (M24) ## Push dei promemoria (M24)
I promemoria a 24 e 3 ore nascono nel database (job `pg_cron`), che non può firmare né cifrare una I promemoria a 24 e 3 ore (rimossi con la `m28`, DD-043; la coda resta valida per i tipi già esistenti) nascevano nel database (job `pg_cron`), che non può firmare né cifrare una
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md), push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
DD-038). DD-038).
+2 -2
View File
@@ -11,7 +11,7 @@
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
notifiche e senza email a 12, 6 e 3 ore dall'evento (DD-040). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i notifiche e senza email 3 ore prima dell'evento (DD-040, DD-042). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
compleanni non c'entrano: non richiedono palloni. compleanni non c'entrano: non richiedono palloni.
--- ---
@@ -56,7 +56,7 @@ oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un al
## Route API pubblica `/api/public/promemoria-palloni` ## Route API pubblica `/api/public/promemoria-palloni`
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job > **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
> `pg_cron` ogni 15 minuti manda l'avviso **12, 6 e 3 ore prima** dell'evento, solo se per l'evento c'è un > `pg_cron` ogni 15 minuti manda l'avviso **una sola volta, 3 ore prima** dell'evento (DD-042, migration `m27`), solo se per l'evento c'è un
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo > incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» / > `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel > «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
+2 -2
View File
@@ -79,7 +79,7 @@ src/routes/api/public/sollecita-presenze.ts
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route > **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno > **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **24, 12 e 6 ore prima** dell'evento a chi in > risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **una sola volta, 24 ore prima** dell'evento (DD-045, migration `m30`) a chi in
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o > quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche). > tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento. > Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
@@ -94,7 +94,7 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal - Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
server, la UI risponde subito. server, la UI risponde subito.
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron` - Il sollecito ha due vie (DD-040): un sollecito **automatico** (24 ore prima, job `pg_cron`
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034). il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla - Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
+45 -40
View File
@@ -44,7 +44,7 @@ Può:
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id agganciati al suo id
Non può caricare o sostituire i file altrui: documento, certificato e foto restano Non può caricare o sostituire i file altrui: documento e certificato restano
responsabilità del giocatore che li fornisce. responsabilità del giocatore che li fornisce.
## Flusso utente ## Flusso utente
@@ -73,7 +73,6 @@ Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_),
- Dati personali - Dati personali
- Documento di identità - Documento di identità
- Certificato medico - Certificato medico
- Foto tessera
Quando tutte le sezioni sono complete il widget scompare automaticamente. Quando tutte le sezioni sono complete il widget scompare automaticamente.
@@ -82,7 +81,7 @@ sulla tab Stagione.
### Avviso certificati ### Avviso certificati
**Stato:** implementato (DD-035). **Stato:** implementato (DD-035, soglia del giocatore DD-041).
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
@@ -90,13 +89,13 @@ già salvata: niente tabella, niente migration, niente push.
#### Chi lo vede #### Chi lo vede
| Utente | Cosa vede | Al tocco | | Utente | Cosa vede | Al tocco |
| --------------------------- | --------------------------------------------------------------- | ----------------------------- | | --------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------- |
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile | | Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` | | Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente | | Admin che è anche giocatore | **entrambi**: lo staff (senza il suo nome) e il proprio personale, a 30 giorni | personale: apre `/profilo?tab=documenti` |
| Altri giocatori | niente | — | | Altri giocatori | niente | — |
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — | | Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
@@ -109,15 +108,24 @@ solo proprio a un giocatore.
#### Quando compare #### Quando compare
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia `giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale). La
`GIORNI_AVVISO_CERTIFICATO = 7`. soglia del giallo dipende da chi guarda (DD-041): il giocatore è avvisato un mese prima dello
staff.
| Condizione | Stato | Avviso | | Chi guarda | Soglia |
| -------------------- | ----------- | ------------------ | | ---------- | ------------------------------------------ |
| `giorni > 7` | valido | nessuno | | Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) | | Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
| `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | nessuno | | Condizione | Stato | Avviso |
| --------------------- | ----------- | ------------------ |
| `giorni > soglia` | valido | nessuno |
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
| `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | nessuno |
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il - Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo. giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
@@ -136,7 +144,7 @@ solo proprio a un giocatore.
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» | | Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» | | Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non Il numero di giorni scende da solo ogni giorno (30… 7, 6, 5… domani, oggi): l'avviso non
conserva niente, rilegge la data a ogni apertura della Home. conserva niente, rilegge la data a ogni apertura della Home.
#### Più giocatori #### Più giocatori
@@ -182,14 +190,16 @@ la Home non deve rompersi per un dato accessorio.
#### Implementazione #### Implementazione
| Pezzo | Ruolo | | Pezzo | Ruolo |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 | | `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto | | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` | | `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» | | `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card | | `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` | | `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da - "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima. `new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
@@ -197,7 +207,7 @@ la Home non deve rompersi per un dato accessorio.
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi `/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge [EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
i profili. i profili.
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto), - Test in `test/unit/profili-core.test.ts`: avvisi per utente (admin giocatore, admin puro, giocatore semplice), soglia dello staff (8 giorni no, 7 sì, 0 sì, −1 scaduto) e del giocatore (31 no, 30 sì),
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi, certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
di anno nel calcolo dei giorni. di anno nel calcolo dei giorni.
@@ -254,17 +264,16 @@ Upload.
Il giocatore può aggiornare liberamente sia la data sia il file. Il giocatore può aggiornare liberamente sia la data sia il file.
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore Quando mancano 30 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
riceve un avviso nella propria Home e compare in quello degli admin (vedi riceve un avviso nella propria Home; compare in quello degli admin da 7 giorni prima (vedi
[Avviso certificati](#avviso-certificati)). [Avviso certificati](#avviso-certificati)).
Lo storico non viene mantenuto nella prima versione. Lo storico non viene mantenuto nella prima versione.
### Foto tessera ### Foto tessera
Upload di una fotografia formato tessera. **Rimossa** (DD-044, migration `m29`): il profilo non chiede più la foto tessera e la colonna
`foto_path` non esiste più. I file già caricati sono stati cancellati dal bucket.
Utilizzata dagli amministratori per il tesseramento CSI.
### Statistiche ### Statistiche
@@ -317,7 +326,6 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo - Stato del profilo
- Certificato medico - Certificato medico
- Documento di identità - Documento di identità
- Foto tessera
- Stato tesseramento CSI (tesserato / da tesserare) - Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili. Azioni disponibili.
@@ -325,7 +333,6 @@ Azioni disponibili.
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata) - Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
- Scarica certificato - Scarica certificato
- Scarica documento - Scarica documento
- Scarica foto tessera
- Modifica dati squadra e dati personali del giocatore (DD-017) - Modifica dati squadra e dati personali del giocatore (DD-017)
- Registra numero e data della tessera CSI, una volta arrivata dal comitato - Registra numero e data della tessera CSI, una volta arrivata dal comitato
- Scollega account, per liberare uno slot assegnato per errore - Scollega account, per liberare uno slot assegnato per errore
@@ -366,10 +373,9 @@ Ogni sezione contribuisce alla percentuale di completamento.
| Sezione | Peso | | Sezione | Peso |
| --------------------- | ---- | | --------------------- | ---- |
| Dati personali | 30% | | Dati personali | 34% |
| Documento di identità | 30% | | Documento di identità | 33% |
| Certificato medico | 30% | | Certificato medico | 33% |
| Foto tessera | 10% |
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%. Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
@@ -391,7 +397,6 @@ nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
- Gestione dati personali - Gestione dati personali
- Documento di identità - Documento di identità
- Certificato medico - Certificato medico
- Foto tessera
- Dashboard amministratore - Dashboard amministratore
- Esportazione CSV CSI - Esportazione CSV CSI
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.2.1", "version": "1.2.2",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+46 -45
View File
@@ -2,7 +2,7 @@ import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useProfili } from "@/lib/profili"; import { useProfili } from "@/lib/profili";
import { import {
avvisiCertificati, avvisiCertificatiUtente,
formatDataBreve, formatDataBreve,
testoScadenza, testoScadenza,
type AvvisoCertificato, type AvvisoCertificato,
@@ -49,7 +49,10 @@ function Card({
); );
} }
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */ /**
* Avviso certificati in scadenza/scaduti (DD-035, DD-041): calcolato al volo, niente push.
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
*/
export function AvvisoCertificati() { export function AvvisoCertificati() {
const admin = useIsAdmin(); const admin = useIsAdmin();
const ruoliPronti = useRuoliPronti(); const ruoliPronti = useRuoliPronti();
@@ -61,50 +64,48 @@ export function AvvisoCertificati() {
const oggi = oggiISO(); const oggi = oggiISO();
if (admin) { // Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041).
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi); const { personale, staff } = avvisiCertificatiUtente(rosa, profili, oggi, { admin, base });
if (scaduti.length === 0 && inScadenza.length === 0) return null; const cardPersonale = personale ? (
return ( personale.giorni < 0 ? (
<> <Card
{scaduti.length > 0 ? ( colore="primary"
<Card titolo="Certificato scaduto"
colore="primary" cliccabile
titolo="Certificati scaduti" righe={[
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))} `Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
/> ]}
) : null} />
{inScadenza.length > 0 ? ( ) : (
<Card <Card
colore="warning" colore="warning"
titolo="Certificati in scadenza" titolo="Certificato in scadenza"
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))} cliccabile
/> righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
) : null} />
</> )
); ) : null;
}
if (!base) return null; if (!staff) return cardPersonale;
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi); const { scaduti, inScadenza } = staff;
const mio = scaduti[0] ?? inScadenza[0]; return (
if (!mio) return null; <>
{cardPersonale}
return mio.giorni < 0 ? ( {scaduti.length > 0 ? (
<Card <Card
colore="primary" colore="primary"
titolo="Certificato scaduto" titolo="Certificati scaduti"
cliccabile righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
righe={[ />
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`, ) : null}
]} {inScadenza.length > 0 ? (
/> <Card
) : ( colore="warning"
<Card titolo="Certificati in scadenza"
colore="warning" righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
titolo="Certificato in scadenza" />
cliccabile ) : null}
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]} </>
/>
); );
} }
+2 -17
View File
@@ -120,14 +120,12 @@ export function CampiProfilo({
sezioni, sezioni,
fileDocumento, fileDocumento,
fileCertificato, fileCertificato,
fileFoto,
}: { }: {
corrente: Profilo; corrente: Profilo;
aggiorna: (patch: Partial<Profilo>) => void; aggiorna: (patch: Partial<Profilo>) => void;
sezioni: Record<Sezione, boolean>; sezioni: Record<Sezione, boolean>;
fileDocumento?: React.ReactNode; fileDocumento?: React.ReactNode;
fileCertificato?: React.ReactNode; fileCertificato?: React.ReactNode;
fileFoto?: React.ReactNode;
}) { }) {
return ( return (
<> <>
@@ -241,14 +239,13 @@ export function CampiProfilo({
/> />
</Campo> </Campo>
{fileCertificato} {fileCertificato}
{fileFoto}
</> </>
); );
} }
/** /**
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno * I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin. * indirizzo, documento e certificato. Condivisi tra Profilo e dashboard admin.
*/ */
export function CampiAllenatore({ export function CampiAllenatore({
corrente, corrente,
@@ -469,18 +466,6 @@ export function ProfiloAmministrativo({
onCaricato={caricato("certificatoPath")} onCaricato={caricato("certificatoPath")}
/> />
} }
fileFoto={
<>
<Intestazione titolo="Foto tessera" completa={sezioni.foto} />
<CampoFile
label="Foto tessera"
path={corrente.fotoPath}
sezione="foto"
giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")}
/>
</>
}
/> />
<button <button
@@ -550,7 +535,7 @@ export function CompletaProfilo({
<p className="mt-2 text-[13px] text-muted-foreground"> <p className="mt-2 text-[13px] text-muted-foreground">
{allenatore {allenatore
? "Data e luogo di nascita, telefono ed email." ? "Data e luogo di nascita, telefono ed email."
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."} : "Documento e certificato medico servono per il tesseramento CSI."}
</p> </p>
</Link> </Link>
</Reveal> </Reveal>
-3
View File
@@ -375,7 +375,6 @@ export type Database = {
documento_scadenza: string | null; documento_scadenza: string | null;
documento_tipo: string | null; documento_tipo: string | null;
email: string | null; email: string | null;
foto_path: string | null;
giocatore_id: string; giocatore_id: string;
indirizzo: string | null; indirizzo: string | null;
luogo_nascita: string | null; luogo_nascita: string | null;
@@ -395,7 +394,6 @@ export type Database = {
documento_scadenza?: string | null; documento_scadenza?: string | null;
documento_tipo?: string | null; documento_tipo?: string | null;
email?: string | null; email?: string | null;
foto_path?: string | null;
giocatore_id: string; giocatore_id: string;
indirizzo?: string | null; indirizzo?: string | null;
luogo_nascita?: string | null; luogo_nascita?: string | null;
@@ -415,7 +413,6 @@ export type Database = {
documento_scadenza?: string | null; documento_scadenza?: string | null;
documento_tipo?: string | null; documento_tipo?: string | null;
email?: string | null; email?: string | null;
foto_path?: string | null;
giocatore_id?: string; giocatore_id?: string;
indirizzo?: string | null; indirizzo?: string | null;
luogo_nascita?: string | null; luogo_nascita?: string | null;
+1 -1
View File
@@ -5,7 +5,7 @@ import { useGiocatoreId } from "./user-store";
/** /**
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non * Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
* lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un * lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il * messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h, non più generato dalla M28), il
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in * turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
* parallelo alla push esistente). Qui si legge e si segna come letto, con * parallelo alla push esistente). Qui si legge e si segna come letto, con
* `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi * `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi
+40 -12
View File
@@ -21,7 +21,6 @@ export type Profilo = {
documentoRetroPath: string | null; documentoRetroPath: string | null;
certificatoScadenza: string | null; certificatoScadenza: string | null;
certificatoPath: string | null; certificatoPath: string | null;
fotoPath: string | null;
}; };
export type RigaProfilo = { export type RigaProfilo = {
@@ -40,11 +39,10 @@ export type RigaProfilo = {
documento_retro_path: string | null; documento_retro_path: string | null;
certificato_scadenza: string | null; certificato_scadenza: string | null;
certificato_path: string | null; certificato_path: string | null;
foto_path: string | null;
}; };
export const COLONNE_PROFILO = export const COLONNE_PROFILO =
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path"; "giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path";
export function profiloVuoto(giocatoreId: string): Profilo { export function profiloVuoto(giocatoreId: string): Profilo {
return { return {
@@ -63,7 +61,6 @@ export function profiloVuoto(giocatoreId: string): Profilo {
documentoRetroPath: null, documentoRetroPath: null,
certificatoScadenza: null, certificatoScadenza: null,
certificatoPath: null, certificatoPath: null,
fotoPath: null,
}; };
} }
@@ -84,7 +81,6 @@ export function daRigaProfilo(r: RigaProfilo): Profilo {
documentoRetroPath: r.documento_retro_path, documentoRetroPath: r.documento_retro_path,
certificatoScadenza: r.certificato_scadenza, certificatoScadenza: r.certificato_scadenza,
certificatoPath: r.certificato_path, certificatoPath: r.certificato_path,
fotoPath: r.foto_path,
}; };
} }
@@ -111,12 +107,11 @@ export function aRigaProfilo(p: Profilo): RigaProfilo {
documento_retro_path: oNull(p.documentoRetroPath), documento_retro_path: oNull(p.documentoRetroPath),
certificato_scadenza: oNull(p.certificatoScadenza), certificato_scadenza: oNull(p.certificatoScadenza),
certificato_path: oNull(p.certificatoPath), certificato_path: oNull(p.certificatoPath),
foto_path: oNull(p.fotoPath),
}; };
} }
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */ /** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const; export const PESI = { dati: 34, documento: 33, certificato: 33 } as const;
export type Sezione = keyof typeof PESI; export type Sezione = keyof typeof PESI;
@@ -131,7 +126,6 @@ export function sezioniComplete(p: Profilo | null | undefined): Record<Sezione,
p.documentoRetroPath p.documentoRetroPath
), ),
certificato: !!(p?.certificatoScadenza && p.certificatoPath), certificato: !!(p?.certificatoScadenza && p.certificatoPath),
foto: !!p?.fotoPath,
}; };
} }
@@ -145,8 +139,8 @@ export function completamento(p: Profilo | null | undefined): number {
} }
/** /**
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento, * L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento
* certificato né foto tessera, che servono al tesseramento dei giocatori. * né certificato, che servono al tesseramento dei giocatori.
*/ */
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const; export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
@@ -216,9 +210,12 @@ export function etichettaGiocatore(g: GiocatoreSquadra): string {
return `#${g.numero} ${nomeCompleto(g)}`; return `#${g.numero} ${nomeCompleto(g)}`;
} }
/** Soglia dell'avviso certificati in Home (DD-035). */ /** Soglia dell'avviso certificati dello staff in Home (DD-035). */
export const GIORNI_AVVISO_CERTIFICATO = 7; export const GIORNI_AVVISO_CERTIFICATO = 7;
/** Soglia dell'avviso personale: il giocatore è avvisato un mese prima dello staff (DD-041). */
export const GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30;
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */ /** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
export function giorniAllaScadenza(scadenza: string, oggi: string): number { export function giorniAllaScadenza(scadenza: string, oggi: string): number {
const a = new Date(`${scadenza}T00:00:00`); const a = new Date(`${scadenza}T00:00:00`);
@@ -258,11 +255,13 @@ function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da * Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha * `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza). * ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
* `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041).
*/ */
export function avvisiCertificati( export function avvisiCertificati(
rosa: GiocatoreSquadra[], rosa: GiocatoreSquadra[],
profili: Record<string, Profilo>, profili: Record<string, Profilo>,
oggi: string, oggi: string,
soglia: number = GIORNI_AVVISO_CERTIFICATO,
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } { ): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
const scaduti: AvvisoCertificato[] = []; const scaduti: AvvisoCertificato[] = [];
const inScadenza: AvvisoCertificato[] = []; const inScadenza: AvvisoCertificato[] = [];
@@ -271,7 +270,7 @@ export function avvisiCertificati(
const p = profili[g.id]; const p = profili[g.id];
if (!p?.certificatoScadenza || !p.certificatoPath) continue; if (!p?.certificatoScadenza || !p.certificatoPath) continue;
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi); const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue; if (giorni > soglia) continue;
const avviso: AvvisoCertificato = { const avviso: AvvisoCertificato = {
giocatoreId: g.id, giocatoreId: g.id,
nome: g.nome, nome: g.nome,
@@ -285,3 +284,32 @@ export function avvisiCertificati(
inScadenza.sort(confrontaAvvisi); inScadenza.sort(confrontaAvvisi);
return { scaduti, inScadenza }; return { scaduti, inScadenza };
} }
/**
* Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio
* certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa; `staff` sono gli
* altri della rosa (soglia 7 giorni), solo per l'admin, senza il suo nome perché già nel
* personale.
*/
export function avvisiCertificatiUtente(
rosa: GiocatoreSquadra[],
profili: Record<string, Profilo>,
oggi: string,
utente: { admin: boolean; base: GiocatoreSquadra | null },
): {
personale: AvvisoCertificato | undefined;
staff: { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } | null;
} {
const mio = utente.base
? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE)
: null;
const personale = mio ? (mio.scaduti[0] ?? mio.inScadenza[0]) : undefined;
const staff = utente.admin
? avvisiCertificati(
rosa.filter((g) => g.id !== utente.base?.id),
profili,
oggi,
)
: null;
return { personale, staff };
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function useSalvaProfilo() {
}); });
} }
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto"; export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato";
const MAX_BYTE = 8 * 1024 * 1024; const MAX_BYTE = 8 * 1024 * 1024;
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"]; const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
-7
View File
@@ -8,7 +8,6 @@ import {
Download, Download,
FileText, FileText,
IdCard, IdCard,
Image,
Loader2, Loader2,
Lock, Lock,
Send, Send,
@@ -487,12 +486,6 @@ function SchedaGiocatore({
stato={certificato} stato={certificato}
path={profilo?.certificatoPath ?? null} path={profilo?.certificatoPath ?? null}
/> />
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
/>
<span <span
className={cn( className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase", "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
+1 -1
View File
@@ -31,7 +31,7 @@ export const Route = createFileRoute("/calendario")({
{ property: "og:title", content: "Calendario squadra — CrAPP" }, { property: "og:title", content: "Calendario squadra — CrAPP" },
{ {
property: "og:description", property: "og:description",
content: "Vista mensile e prossimi eventi con promemoria per la squadra.", content: "Vista mensile e prossimi eventi per la squadra.",
}, },
], ],
}), }),
@@ -0,0 +1,22 @@
-- M27 — Turno palloni: un solo avviso, 3 ore prima dell'evento (DD-042)
--
-- Con M26 il job mandava l'avviso a 12, 6 e 3 ore. Ne basta uno, 3 ore prima, a chi deve portare
-- i palloni e a chi li deve prendere. Cambia solo il job: i tipi `turno_palloni_12h` e `_6h`
-- restano ammessi dai CHECK perché esistono notifiche e righe di registro già generate (anche la
-- revoca le guarda), ma non ne nascono di nuove. Le funzioni `genera_avvisi_palloni_fascia` e
-- `genera_revoche_palloni` non cambiano.
CREATE OR REPLACE FUNCTION public.genera_avvisi_palloni()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_3h', interval '0', interval '3 hours');
PERFORM public.genera_revoche_palloni();
END;
$$;
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni() TO service_role;
@@ -0,0 +1,14 @@
-- M28 — Niente più promemoria automatico a 24 e 3 ore prima degli eventi (DD-043)
--
-- Si tolgono solo i due job `pg_cron` di M17: da ora il database non genera più notifiche
-- `evento_promemoria_24h` e `evento_promemoria_3h`. La funzione `genera_promemoria_eventi()`, i tipi
-- ammessi dai CHECK e il registro `promemoria_eventi_generati` restano: servono per le notifiche
-- già generate e, volendo, per rimettere in funzione il promemoria con un solo `cron.schedule`.
DO $$
BEGIN
PERFORM cron.unschedule(jobname)
FROM cron.job
WHERE jobname IN ('promemoria-eventi-24h', 'promemoria-eventi-3h');
END;
$$;
@@ -0,0 +1,9 @@
-- M29 — Via la foto tessera dal profilo (DD-044)
--
-- Elimina la colonna `profili_giocatore.foto_path`. I file `<giocatore_id>/foto.*` nel bucket
-- privato `profili-giocatore` NON si cancellano da SQL (Storage lo vieta con un trigger): vanno
-- rimossi con l'API Storage, prima di questa migration, mentre i path sono ancora leggibili.
-- Da applicare DOPO il rilascio del codice che non legge più `foto_path`: l'app precedente
-- la seleziona esplicitamente e darebbe errore.
ALTER TABLE public.profili_giocatore DROP COLUMN IF EXISTS foto_path;
@@ -0,0 +1,20 @@
-- M30 — Sollecito presenze: un solo avviso automatico, 24 ore prima (DD-045)
--
-- Con M26 il job mandava il sollecito a 24, 12 e 6 ore. Ne basta uno, 24 ore prima: parte per gli
-- eventi che iniziano entro 24 ore (quindi anche per uno creato più tardi), a chi in quel momento
-- non ha risposto o ha risposto «forse». I tipi `sollecita_presenze_12h` e `_6h` restano ammessi dai
-- CHECK per le notifiche già generate, ma non ne nascono di nuove. Il pulsante manuale non cambia.
CREATE OR REPLACE FUNCTION public.genera_solleciti_presenze()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_24h', interval '0', interval '24 hours');
END;
$$;
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze() TO service_role;
+5 -5
View File
@@ -15,24 +15,24 @@ INSERT INTO public.profili_giocatore
(giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, (giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email,
documento_tipo, documento_numero, documento_rilasciato_da, documento_tipo, documento_numero, documento_rilasciato_da,
documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path,
certificato_scadenza, certificato_path, foto_path) certificato_scadenza, certificato_path)
VALUES VALUES
-- Profilo completo al 100%. -- Profilo completo al 100%.
('g1', '1997-08-30', 'Bologna', 'Via Roma 1', '3330000001', 'g1@example.test', ('g1', '1997-08-30', 'Bologna', 'Via Roma 1', '3330000001', 'g1@example.test',
'Carta d''identità', 'CA1000001', 'Comune di Bologna', 'Carta d''identità', 'CA1000001', 'Comune di Bologna',
'2021-03-01', '2031-03-01', 'g1/documento-fronte.jpg', 'g1/documento-retro.jpg', '2021-03-01', '2031-03-01', 'g1/documento-fronte.jpg', 'g1/documento-retro.jpg',
'2027-06-30', 'g1/certificato.pdf', 'g1/foto.jpg'), '2027-06-30', 'g1/certificato.pdf'),
-- Certificato scaduto: in dashboard deve comparire rosso. -- Certificato scaduto: in dashboard deve comparire rosso.
('g4', '1995-05-01', 'Bologna', 'Via Verdi 2', '3330000004', 'g4@example.test', ('g4', '1995-05-01', 'Bologna', 'Via Verdi 2', '3330000004', 'g4@example.test',
'Carta d''identità', 'CA1000004', 'Comune di Bologna', 'Carta d''identità', 'CA1000004', 'Comune di Bologna',
'2019-05-01', '2029-05-01', 'g4/documento-fronte.jpg', 'g4/documento-retro.jpg', '2019-05-01', '2029-05-01', 'g4/documento-fronte.jpg', 'g4/documento-retro.jpg',
'2025-01-01', 'g4/certificato.pdf', 'g4/foto.jpg'), '2025-01-01', 'g4/certificato.pdf'),
-- Profilo a metà: dati personali sì, documento no, certificato sì, foto no. -- Profilo a metà: dati personali sì, documento no, certificato sì.
('g2', '1996-12-07', 'Modena', 'Via Bianchi 3', '3330000002', 'g2@example.test', ('g2', '1996-12-07', 'Modena', 'Via Bianchi 3', '3330000002', 'g2@example.test',
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
'2027-09-15', 'g2/certificato.pdf', NULL) '2027-09-15', 'g2/certificato.pdf')
ON CONFLICT (giocatore_id) DO NOTHING; ON CONFLICT (giocatore_id) DO NOTHING;
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update; ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | Cartella | Cosa verifica | Serve rete? |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No | | `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26, DD-040) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | | `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Server di sviluppo con Node vecchio ## Server di sviluppo con Node vecchio
@@ -0,0 +1,37 @@
/**
* Via la foto tessera dal profilo (M29, DD-044): `bun test/integration/foto-tessera-rimossa.test.ts`.
*
* Gira solo sullo stack locale (`npx supabase start`): verifica che la colonna `foto_path` non ci
* sia più e che le colonne lette dall'app (`COLONNE_PROFILO`) esistano tutte. Non guarda il
* database di `.env`, che può essere ancora quello di produzione prima di applicare la M29.
*/
import assert from "node:assert/strict";
import { COLONNE_PROFILO } from "@/lib/profili-core";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("foto tessera rimossa", "stack locale non attivo (npx supabase start)");
riepilogo("foto-tessera-rimossa");
} else {
const leggi = (colonne: string) =>
fetch(`${locale.url}/rest/v1/profili_giocatore?select=${encodeURIComponent(colonne)}&limit=1`, {
headers: { apikey: locale.servizio, Authorization: `Bearer ${locale.servizio}` },
});
await prova("la colonna foto_path non esiste più", async () => {
const res = await leggi("foto_path");
assert.equal(res.status, 400, "PostgREST rifiuta una colonna che non c'è");
assert.match(await res.text(), /foto_path/);
});
await prova("le colonne lette dall'app esistono tutte", async () => {
assert.ok(!COLONNE_PROFILO.includes("foto"), "COLONNE_PROFILO non nomina la foto");
const res = await leggi(COLONNE_PROFILO);
assert.equal(res.status, 200, await res.text());
});
riepilogo("foto-tessera-rimossa");
}
+111 -74
View File
@@ -5,7 +5,8 @@
* Esegue i job veri del database (`genera_avvisi_palloni`, `genera_solleciti_presenze`) su eventi * Esegue i job veri del database (`genera_avvisi_palloni`, `genera_solleciti_presenze`) su eventi
* creati con orari relativi a «adesso» e verifica: * creati con orari relativi a «adesso» e verifica:
* *
* - le finestre contigue e senza sovrapposizione (palloni 12·6·3 ore, solleciti 24·12·6 ore); * - che il promemoria a 24 e 3 ore non sia più pianificato (M28, DD-043);
* - le finestre (palloni: un solo avviso nelle 3 ore prima; solleciti: un solo avviso nelle 24 ore prima, DD-045);
* - i destinatari (incaricato e turno precedente; convocati senza risposta o con «forse»; mai * - i destinatari (incaricato e turno precedente; convocati senza risposta o con «forse»; mai
* l'allenatore né chi non è più attivo); * l'allenatore né chi non è più attivo);
* - una sola generazione per giocatore, evento e tipo, anche se la notifica viene eliminata; * - una sola generazione per giocatore, evento e tipo, anche se la notifica viene eliminata;
@@ -46,12 +47,14 @@ if (!locale) {
const INATTIVO = "g9"; const INATTIVO = "g9";
const ALLENATORE = "g10"; const ALLENATORE = "g10";
// Palloni: in ordine cronologico PASSATO < PREC < MAIN < EV6 < EV12 < LONTANO. // Palloni: in ordine cronologico PASSATO < PREC < MAIN < SECONDO < TERZO < FUORI4 < FUORI9 < LONTANO.
const PASSATO = `${PREFISSO}-passato`; const PASSATO = `${PREFISSO}-passato`;
const PREC = `${PREFISSO}-prec`; const PREC = `${PREFISSO}-prec`;
const MAIN = `${PREFISSO}-main`; const MAIN = `${PREFISSO}-main`;
const EV6 = `${PREFISSO}-ev6`; const SECONDO = `${PREFISSO}-secondo`;
const EV12 = `${PREFISSO}-ev12`; const TERZO = `${PREFISSO}-terzo`;
const FUORI4 = `${PREFISSO}-fuori4`;
const FUORI9 = `${PREFISSO}-fuori9`;
const LONTANO = `${PREFISSO}-lontano`; const LONTANO = `${PREFISSO}-lontano`;
const GENERICO = `${PREFISSO}-generico`; const GENERICO = `${PREFISSO}-generico`;
const COPPIA_A = `${PREFISSO}-coppia-a`; const COPPIA_A = `${PREFISSO}-coppia-a`;
@@ -68,8 +71,10 @@ if (!locale) {
PASSATO, PASSATO,
PREC, PREC,
MAIN, MAIN,
EV6, SECONDO,
EV12, TERZO,
FUORI4,
FUORI9,
LONTANO, LONTANO,
GENERICO, GENERICO,
COPPIA_A, COPPIA_A,
@@ -266,36 +271,51 @@ if (!locale) {
); );
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin); await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin);
// Palloni. Le ore scelte stanno a più di mezz'ora dai confini delle finestre. // Palloni (DD-042): un solo avviso, nelle 3 ore prima. MAIN, SECONDO e TERZO sono dentro la
// finestra; FUORI4 e FUORI9 hanno un incaricato ma sono ancora fuori (tra 4,5 e 9 ore).
// Le ore scelte stanno a più di mezz'ora dai confini della finestra.
await creaEvento(PASSATO, "partita", tra(-60)); await creaEvento(PASSATO, "partita", tra(-60));
await creaEvento(PREC, "allenamento", tra(-30)); await creaEvento(PREC, "allenamento", tra(-30));
await creaEvento(MAIN, "allenamento", tra(2.5)); await creaEvento(MAIN, "allenamento", tra(0.8));
await creaEvento(EV6, "partita", tra(4.5)); await creaEvento(SECONDO, "partita", tra(1.5));
await creaEvento(EV12, "allenamento", tra(9)); await creaEvento(TERZO, "allenamento", tra(2.2));
await creaEvento(FUORI4, "allenamento", tra(4.5));
await creaEvento(FUORI9, "allenamento", tra(9));
await creaEvento(LONTANO, "allenamento", tra(40)); await creaEvento(LONTANO, "allenamento", tra(40));
await creaEvento(GENERICO, "evento", tra(2.2)); await creaEvento(GENERICO, "evento", tra(1.9));
await assegna(PREC, G5); await assegna(PREC, G5);
await assegna(MAIN, G4); await assegna(MAIN, G4);
await assegna(EV6, G7); await assegna(SECONDO, G7);
await assegna(EV12, G8); await assegna(TERZO, G8);
await assegna(FUORI4, G7);
await assegna(FUORI9, G8);
await assegna(GENERICO, G4); // extra-campo: il turno non conta await assegna(GENERICO, G4); // extra-campo: il turno non conta
// --- Turno palloni: finestre e destinatari --------------------------------------------- // --- Turno palloni: finestre e destinatari ---------------------------------------------
await prova("ogni evento riceve l'avviso della sua fascia e di nessun'altra", async () => { await prova(
await esegui("genera_avvisi_palloni"); "un solo avviso, 3 ore prima, a chi porta i palloni e a chi li prende",
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4, G5]); async () => {
assert.deepEqual(await destinatari(EV6, "turno_palloni_6h"), [G4, G7]); await esegui("genera_avvisi_palloni");
assert.deepEqual(await destinatari(EV12, "turno_palloni_12h"), [G7, G8]); assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4, G5]);
for (const [evento, altri] of [ assert.deepEqual(await destinatari(SECONDO, "turno_palloni_3h"), [G4, G7]);
[MAIN, ["turno_palloni_6h", "turno_palloni_12h"]], assert.deepEqual(await destinatari(TERZO, "turno_palloni_3h"), [G7, G8]);
[EV6, ["turno_palloni_3h", "turno_palloni_12h"]], for (const evento of [MAIN, SECONDO, TERZO]) {
[EV12, ["turno_palloni_3h", "turno_palloni_6h"]], for (const tipo of ["turno_palloni_12h", "turno_palloni_6h"]) {
] as const) { assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
for (const tipo of altri) { }
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
} }
} },
}); );
await prova(
"a 4,5 e 9 ore dall'inizio non parte nessun avviso, anche con l'incaricato",
async () => {
assert.equal((await notifiche(FUORI4)).length, 0, "tra 4,5 ore: prima era la fascia 6 ore");
assert.equal((await notifiche(FUORI9)).length, 0, "tra 9 ore: prima era la fascia 12 ore");
assert.equal((await registro(FUORI4)).length, 0);
assert.equal((await registro(FUORI9)).length, 0);
},
);
await prova( await prova(
"nessun avviso per eventi lontani, passati, extra-campo o senza incaricato", "nessun avviso per eventi lontani, passati, extra-campo o senza incaricato",
@@ -314,8 +334,8 @@ if (!locale) {
const turni = await turniDelDatabase(); const turni = await turniDelDatabase();
for (const [evento, tipo] of [ for (const [evento, tipo] of [
[MAIN, "turno_palloni_3h"], [MAIN, "turno_palloni_3h"],
[EV6, "turno_palloni_6h"], [SECONDO, "turno_palloni_3h"],
[EV12, "turno_palloni_12h"], [TERZO, "turno_palloni_3h"],
] as const) { ] as const) {
const attesi = avvisiPalloniEvento(turni, eventi, evento); const attesi = avvisiPalloniEvento(turni, eventi, evento);
assert.equal(attesi.length, 2, evento); assert.equal(attesi.length, 2, evento);
@@ -330,7 +350,7 @@ if (!locale) {
const [incarico] = (await notifiche(MAIN, "turno_palloni_3h")).filter( const [incarico] = (await notifiche(MAIN, "turno_palloni_3h")).filter(
(r) => r.giocatore_id === G4, (r) => r.giocatore_id === G4,
); );
const eventiEv6 = eventi.find((e) => e.id === EV6)!; const eventoSecondo = eventi.find((e) => e.id === SECONDO)!;
const main = eventi.find((e) => e.id === MAIN)!; const main = eventi.find((e) => e.id === MAIN)!;
assert.equal( assert.equal(
incarico?.corpo, incarico?.corpo,
@@ -338,7 +358,7 @@ if (!locale) {
`Evento: ${PREFISSO} main`, `Evento: ${PREFISSO} main`,
`Data: ${gg(main.data)}, ore ${main.ora}`, `Data: ${gg(main.data)}, ore ${main.ora}`,
"Incarico: custodia dei palloni al termine dell'evento", "Incarico: custodia dei palloni al termine dell'evento",
`Riconsegna: ${gg(eventiEv6.data)}`, `Riconsegna: ${gg(eventoSecondo.data)}`,
].join("\n"), ].join("\n"),
"la riconsegna è la data dell'evento successivo", "la riconsegna è la data dell'evento successivo",
); );
@@ -349,8 +369,8 @@ if (!locale) {
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2); assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2);
const righe = [ const righe = [
...(await notifiche(MAIN, "turno_palloni_3h")), ...(await notifiche(MAIN, "turno_palloni_3h")),
...(await notifiche(EV6, "turno_palloni_6h")), ...(await notifiche(SECONDO, "turno_palloni_3h")),
...(await notifiche(EV12, "turno_palloni_12h")), ...(await notifiche(TERZO, "turno_palloni_3h")),
]; ];
const ids = righe.map((r) => r.id); const ids = righe.map((r) => r.id);
assert.equal(ids.length, 6); assert.equal(ids.length, 6);
@@ -385,13 +405,13 @@ if (!locale) {
"ora non valida: escluso, senza errori", "ora non valida: escluso, senza errori",
); );
await scrivi(`turni_palloni?evento_id=eq.${EV12}`, "PATCH", { giocatore_id: INATTIVO }); await scrivi(`turni_palloni?evento_id=eq.${TERZO}`, "PATCH", { giocatore_id: INATTIVO });
await esegui("genera_avvisi_palloni"); await esegui("genera_avvisi_palloni");
assert.ok( assert.ok(
!(await destinatari(EV12, "turno_palloni_12h")).includes(INATTIVO), !(await destinatari(TERZO, "turno_palloni_3h")).includes(INATTIVO),
"chi non è più attivo non riceve nulla", "chi non è più attivo non riceve nulla",
); );
await cambiaTurno(EV12, G8); await cambiaTurno(TERZO, G8);
}, },
); );
@@ -452,18 +472,19 @@ if (!locale) {
"turno tolto: chi era stato avvisato, incaricato o riconsegna, viene revocato", "turno tolto: chi era stato avvisato, incaricato o riconsegna, viene revocato",
async () => { async () => {
// La notifica dell'avviso può essere stata eliminata: conta il registro. // La notifica dell'avviso può essere stata eliminata: conta il registro.
const [avvisoG7] = (await notifiche(EV6, "turno_palloni_6h")).filter( const [avvisoG7] = (await notifiche(SECONDO, "turno_palloni_3h")).filter(
(r) => r.giocatore_id === G7, (r) => r.giocatore_id === G7,
); );
await scrivi(`notifiche_utente?id=eq.${avvisoG7!.id}`, "DELETE"); await scrivi(`notifiche_utente?id=eq.${avvisoG7!.id}`, "DELETE");
await scrivi(`turni_palloni?evento_id=eq.${EV6}`, "DELETE"); await scrivi(`turni_palloni?evento_id=eq.${SECONDO}`, "DELETE");
await esegui("genera_avvisi_palloni"); await esegui("genera_avvisi_palloni");
const revocati = await destinatari(EV6, "turno_palloni_revocato"); const revocati = await destinatari(SECONDO, "turno_palloni_revocato");
assert.ok(revocati.includes(G7), "g7 aveva eliminato l'avviso: conta il registro"); assert.ok(revocati.includes(G7), "g7 aveva eliminato l'avviso: conta il registro");
assert.ok(!revocati.includes(G8), "g8 non c'entra con questo evento"); assert.ok(!revocati.includes(G8), "g8 non c'entra con questo evento");
// Senza incaricato non parte nulla: nemmeno un nuovo avviso di fascia per nessuno. // Senza incaricato non parte nulla di nuovo: restano g4 e g6, avvisati prima come
assert.equal((await notifiche(EV6, "turno_palloni_3h")).length, 0); // «riconsegna» quando il turno di MAIN è passato a g6 e poi tornato a g4.
await assegna(EV6, G7); assert.deepEqual(await destinatari(SECONDO, "turno_palloni_3h"), [G4, G6]);
await assegna(SECONDO, G7);
}, },
); );
@@ -511,12 +532,12 @@ if (!locale) {
// Solo i due eventi della coppia cadono nella fascia: 8 giorni ± un giorno. // Solo i due eventi della coppia cadono nella fascia: 8 giorni ± un giorno.
await esegui("genera_avvisi_palloni_fascia", { await esegui("genera_avvisi_palloni_fascia", {
p_tipo: "turno_palloni_12h", p_tipo: "turno_palloni_3h",
p_da: `${24 * 7} hours`, p_da: `${24 * 7} hours`,
p_a: `${24 * 9} hours`, p_a: `${24 * 9} hours`,
}); });
for (const evento of [COPPIA_A, COPPIA_B]) { for (const evento of [COPPIA_A, COPPIA_B]) {
const righe = await notifiche(evento, "turno_palloni_12h"); const righe = await notifiche(evento, "turno_palloni_3h");
const attesi = avvisiPalloniEvento(turni, eventi, evento); const attesi = avvisiPalloniEvento(turni, eventi, evento);
assert.equal(righe.length, attesi.length, evento); assert.equal(righe.length, attesi.length, evento);
for (const a of attesi) { for (const a of attesi) {
@@ -545,7 +566,7 @@ if (!locale) {
.sort(); .sort();
await prova( await prova(
"ogni evento riceve il sollecito della sua fascia, a chi deve rispondere", "un solo sollecito, entro 24 ore dall'inizio, a chi deve rispondere (DD-045)",
async () => { async () => {
await esegui("genera_solleciti_presenze"); await esegui("genera_solleciti_presenze");
assert.deepEqual( assert.deepEqual(
@@ -555,21 +576,18 @@ if (!locale) {
); );
const senzaRisposta = (await tuttiGiocatoriAttivi()).filter((g) => g !== G4); const senzaRisposta = (await tuttiGiocatoriAttivi()).filter((g) => g !== G4);
assert.deepEqual( assert.deepEqual(
await destinatari(S12, "sollecita_presenze_12h"), await destinatari(S12, "sollecita_presenze_24h"),
senzaRisposta, senzaRisposta,
"convocati vuoto: tutta la rosa attiva, meno chi ha risposto; g5 («forse») resta", "convocati vuoto: tutta la rosa attiva, meno chi ha risposto; g5 («forse») resta",
); );
assert.deepEqual( assert.deepEqual(
await destinatari(S6, "sollecita_presenze_6h"), await destinatari(S6, "sollecita_presenze_24h"),
(await tuttiGiocatoriAttivi()).filter((g) => g !== G4), (await tuttiGiocatoriAttivi()).filter((g) => g !== G4),
"anche a 4 ore dall'inizio: il sollecito è uno solo, non uno per fascia",
); );
assert.equal((await notifiche(S_FUORI)).length, 0, "tra 30 ore: fuori da ogni fascia"); assert.equal((await notifiche(S_FUORI)).length, 0, "tra 30 ore: non ancora");
for (const [evento, altri] of [ for (const evento of [S24, S12, S6]) {
[S24, ["sollecita_presenze_12h", "sollecita_presenze_6h"]], for (const tipo of ["sollecita_presenze_12h", "sollecita_presenze_6h"]) {
[S12, ["sollecita_presenze_24h", "sollecita_presenze_6h"]],
[S6, ["sollecita_presenze_24h", "sollecita_presenze_12h"]],
] as const) {
for (const tipo of altri) {
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`); assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
} }
} }
@@ -593,8 +611,8 @@ if (!locale) {
}>("giocatori_squadra?select=id,attivo,tipo"); }>("giocatori_squadra?select=id,attivo,tipo");
for (const [id, tipo] of [ for (const [id, tipo] of [
[S24, "sollecita_presenze_24h"], [S24, "sollecita_presenze_24h"],
[S12, "sollecita_presenze_12h"], [S12, "sollecita_presenze_24h"],
[S6, "sollecita_presenze_6h"], [S6, "sollecita_presenze_24h"],
] as const) { ] as const) {
const evento = eventi.find((e) => e.id === id)!; const evento = eventi.find((e) => e.id === id)!;
const risposte = await leggi<{ giocatore_id: string; stato: string }>( const risposte = await leggi<{ giocatore_id: string; stato: string }>(
@@ -634,41 +652,33 @@ if (!locale) {
}); });
await prova( await prova(
"chi risponde smette di riceverli, chi resta su «forse» li riceve tutti e tre", "chi resta su «forse» riceve il sollecito una sola volta, non a ogni fascia",
async () => { async () => {
await creaEvento(S_FORSE, "evento", tra(18), { convocati: [G4, G5, G8] }); await creaEvento(S_FORSE, "evento", tra(18), { convocati: [G4, G5, G8] });
await rispondi(S_FORSE, G5, "forse"); await rispondi(S_FORSE, G5, "forse");
await esegui("genera_solleciti_presenze"); await esegui("genera_solleciti_presenze");
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_24h"), [G4, G5, G8]); assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_24h"), [G4, G5, G8]);
await rispondi(S_FORSE, G4, "presente"); // risponde dopo il primo sollecito await rispondi(S_FORSE, G4, "presente");
await spostaEvento(S_FORSE, 9); // entra nella fascia delle 12 ore await spostaEvento(S_FORSE, 9);
await esegui("genera_solleciti_presenze"); await esegui("genera_solleciti_presenze");
assert.deepEqual(
await destinatari(S_FORSE, "sollecita_presenze_12h"),
[G5, G8],
"g4 ha risposto: niente secondo sollecito; «forse» e nessuna risposta sì",
);
await rispondi(S_FORSE, G8, "assente");
await spostaEvento(S_FORSE, 4); await spostaEvento(S_FORSE, 4);
await esegui("genera_solleciti_presenze"); await esegui("genera_solleciti_presenze");
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_6h"), [G5]); assert.deepEqual(
assert.equal( (await notifiche(S_FORSE)).map((r) => r.tipo),
(await notifiche(S_FORSE)).filter((r) => r.giocatore_id === G5).length, ["sollecita_presenze_24h", "sollecita_presenze_24h", "sollecita_presenze_24h"],
3, "nessun secondo sollecito avvicinandosi all'evento, nemmeno a g5 («forse»)",
"g5, sempre «forse»: 24, 12 e 6 ore",
); );
}, },
); );
await prova("un evento creato tardi riceve solo il sollecito della sua fascia", async () => { await prova("un evento creato tardi riceve comunque un solo sollecito", async () => {
await creaEvento(S_TARDI, "evento", tra(5), { convocati: [G4] }); await creaEvento(S_TARDI, "evento", tra(5), { convocati: [G4] });
await esegui("genera_solleciti_presenze"); await esegui("genera_solleciti_presenze");
await esegui("genera_solleciti_presenze");
assert.deepEqual( assert.deepEqual(
(await notifiche(S_TARDI)).map((r) => r.tipo), (await notifiche(S_TARDI)).map((r) => r.tipo),
["sollecita_presenze_6h"], ["sollecita_presenze_24h"],
"non tre solleciti insieme",
); );
}); });
@@ -774,6 +784,33 @@ if (!locale) {
); );
}); });
await prova("il promemoria a 24 e 3 ore non è più pianificato (M28, DD-043)", async () => {
const contenitore = spawnSync(
"docker",
["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"],
{ encoding: "utf8" },
)
.stdout.trim()
.split("\n")[0];
assert.ok(contenitore, "container del database locale non trovato");
const esito = spawnSync(
"docker",
[
"exec",
contenitore,
"psql",
"-U",
"postgres",
"-At",
"-c",
"select count(*) from cron.job where jobname like 'promemoria-eventi-%'",
],
{ encoding: "utf8" },
);
assert.equal(esito.status, 0, esito.stderr);
assert.equal(esito.stdout.trim(), "0", "nessun job di promemoria evento");
});
await prova("cancellare l'evento toglie registro e notifiche collegate", async () => { await prova("cancellare l'evento toglie registro e notifiche collegate", async () => {
assert.ok((await registro(MAIN)).length > 0); assert.ok((await registro(MAIN)).length > 0);
await scrivi(`eventi_app?id=eq.${MAIN}`, "DELETE"); await scrivi(`eventi_app?id=eq.${MAIN}`, "DELETE");
+86 -6
View File
@@ -3,9 +3,12 @@ import assert from "node:assert/strict";
import { import {
aRigaProfilo, aRigaProfilo,
avvisiCertificati, avvisiCertificati,
avvisiCertificatiUtente,
GIORNI_AVVISO_CERTIFICATO_GIOCATORE,
completamento, completamento,
completamentoAllenatore, completamentoAllenatore,
csvTesseramento, csvTesseramento,
PESI,
daRigaProfilo, daRigaProfilo,
formatDataBreve, formatDataBreve,
giorniAllaScadenza, giorniAllaScadenza,
@@ -39,7 +42,6 @@ const vuoto: Profilo = {
documentoRetroPath: null, documentoRetroPath: null,
certificatoScadenza: null, certificatoScadenza: null,
certificatoPath: null, certificatoPath: null,
fotoPath: null,
}; };
const completo: Profilo = { const completo: Profilo = {
@@ -58,18 +60,22 @@ const completo: Profilo = {
documentoRetroPath: "g1/documento-retro.jpg", documentoRetroPath: "g1/documento-retro.jpg",
certificatoScadenza: "2027-06-30", certificatoScadenza: "2027-06-30",
certificatoPath: "g1/certificato.pdf", certificatoPath: "g1/certificato.pdf",
fotoPath: "g1/foto.jpg",
}; };
// --- completamento ----------------------------------------------------------- // --- completamento -----------------------------------------------------------
assert.equal(completamento(null), 0, "profilo inesistente = 0%"); assert.equal(completamento(null), 0, "profilo inesistente = 0%");
assert.equal(completamento(vuoto), 0); assert.equal(completamento(vuoto), 0);
assert.equal(completamento(completo), 100, "tutte le sezioni piene = 100%"); assert.equal(completamento(completo), 100, "tutte le sezioni piene = 100%");
assert.equal(completamento({ ...completo, fotoPath: null }), 90, "la foto pesa 10"); assert.equal(
assert.equal(completamento({ ...completo, certificatoPath: null }), 70, "il certificato pesa 30"); Object.values(PESI).reduce((somma, peso) => somma + peso, 0),
100,
"senza foto tessera (DD-044) i pesi di dati, documento e certificato fanno 100",
);
assert.deepEqual(Object.keys(sezioniComplete(completo)), ["dati", "documento", "certificato"]);
assert.equal(completamento({ ...completo, certificatoPath: null }), 67, "il certificato pesa 33");
assert.equal( assert.equal(
completamento({ ...completo, email: null }), completamento({ ...completo, email: null }),
70, 66,
"i dati personali sono completi solo tutti insieme", "i dati personali sono completi solo tutti insieme",
); );
@@ -214,7 +220,7 @@ assert.equal(
email: "a@b.it", email: "a@b.it",
}), }),
100, 100,
"bastano i quattro dati personali: niente indirizzo, documento, certificato, foto", "bastano i quattro dati personali: niente indirizzo, documento, certificato",
); );
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano"); assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
@@ -333,4 +339,78 @@ assert.deepEqual(
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)", "il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
); );
// soglia del giocatore (DD-041): 30 giorni di preavviso invece di 7
const rosaMese: GiocatoreSquadra[] = [
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
];
const profiliMese: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-10-21", certificatoPath: "p1" }, // 31 giorni: fuori
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-10-20", certificatoPath: "p2" }, // 30 giorni: dentro
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-10", certificatoPath: "p3" }, // scaduto
};
assert.equal(GIORNI_AVVISO_CERTIFICATO_GIOCATORE, 30);
const mese = avvisiCertificati(
rosaMese,
profiliMese,
oggiTest,
GIORNI_AVVISO_CERTIFICATO_GIOCATORE,
);
assert.deepEqual(
mese.inScadenza.map((a) => a.giocatoreId),
["g2"],
"31 giorni resta valido, 30 genera l'avviso del giocatore",
);
assert.deepEqual(
mese.scaduti.map((a) => a.giocatoreId),
["g3"],
);
const staffMese = avvisiCertificati(rosaMese, profiliMese, oggiTest);
assert.equal(staffMese.inScadenza.length, 0, "senza soglia esplicita resta 7 giorni (staff)");
assert.deepEqual(
staffMese.scaduti.map((a) => a.giocatoreId),
["g3"],
);
// avvisi per utente (DD-041): admin giocatore, admin puro, giocatore semplice
const rosaUtente: GiocatoreSquadra[] = [
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
];
const profiliUtente: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-10-10", certificatoPath: "p1" }, // 20 giorni
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" }, // 5 giorni
};
const adminGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTest, {
admin: true,
base: rosaUtente[0]!,
});
assert.equal(
adminGiocatore.personale?.giocatoreId,
"g1",
"admin giocatore: avviso personale a 30 giorni",
);
assert.deepEqual(
adminGiocatore.staff?.inScadenza.map((a) => a.giocatoreId),
["g2"],
"lo staff non ripete il suo nome",
);
const adminPuro = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTest, {
admin: true,
base: null,
});
assert.equal(adminPuro.personale, undefined);
assert.deepEqual(
adminPuro.staff?.inScadenza.map((a) => a.giocatoreId),
["g2"],
"admin non giocatore: solo staff a 7 giorni (g1 a 20 giorni escluso)",
);
const soloGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTest, {
admin: false,
base: rosaUtente[0]!,
});
assert.equal(soloGiocatore.personale?.giocatoreId, "g1");
assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff");
console.log("profili-core: ok"); console.log("profili-core: ok");
+2 -2
View File
@@ -14,11 +14,11 @@ await assert.rejects(
); );
// --- file troppo grande: rifiutato anche con un formato valido --------------- // --- file troppo grande: rifiutato anche con un formato valido ---------------
const troppoGrande = new File([new Uint8Array(8 * 1024 * 1024 + 1)], "foto.jpg", { const troppoGrande = new File([new Uint8Array(8 * 1024 * 1024 + 1)], "certificato.jpg", {
type: "image/jpeg", type: "image/jpeg",
}); });
await assert.rejects( await assert.rejects(
() => caricaFile("g1", "foto", troppoGrande), () => caricaFile("g1", "certificato", troppoGrande),
/File troppo grande: massimo 8 MB\./, /File troppo grande: massimo 8 MB\./,
); );
+28
View File
@@ -0,0 +1,28 @@
/** Check dei campi del profilo: `bun test/unit/profilo-campi.test.ts` (DD-044: niente foto tessera). */
import assert from "node:assert/strict";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { profiloVuoto, sezioniComplete } from "@/lib/profili-core";
const corrente = profiloVuoto("g1");
const html = renderToStaticMarkup(
createElement(CampiProfilo, {
corrente,
aggiorna: () => {},
sezioni: sezioniComplete(corrente),
fileDocumento: createElement("p", null, "slot-documento"),
fileCertificato: createElement("p", null, "slot-certificato"),
}),
);
// --- le sezioni rimaste ci sono ----------------------------------------------
assert.ok(html.includes("Dati personali"));
assert.ok(html.includes("slot-documento"), "lo slot del documento è mostrato");
assert.ok(html.includes("slot-certificato"), "lo slot del certificato è mostrato");
// --- la foto tessera non c'è più, né come titolo né come campo ----------------
assert.ok(!/foto tessera/i.test(html), "nessun riferimento alla foto tessera");
assert.ok(!html.includes("fotoPath"));
console.log("profilo-campi: ok");