6 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 c12011d330 Porta la versione a 1.2.1: turno palloni e solleciti presenze automatici
Changelog della 1.2.1, versione in package.json e stato del progetto: la
migration m26 è applicata in produzione e tutte le 37 migration sono allineate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:09:41 +02:00
davideandClaude Sonnet 5.5 7143c3d2eb Corregge la documentazione di palloni e presenze ancora ferma al solo invio manuale
Il sollecito e l'avviso palloni non sono più descritti come solo da pulsante
(DD-040); corretto il controllo di ruolo della route del sollecito; traccia di
DD-040 in DD-025.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:09:41 +02:00
davideandClaude Sonnet 5.5 71b2d97fae Documenta l'implementazione delle notifiche automatiche (DD-040)
DD-040 passa a implementata in locale; catalogo e testi aggiornati; nuova
tabella delle funzioni SQL e dei job in DATABASE.md; note su palloni, presenze,
allenatore e worker; CHANGELOG, ROADMAP, PROJECT_STATE e README dei test.
Corretto lo stato delle migration in produzione (applicate fino a m25).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:03:50 +02:00
davideandClaude Sonnet 5.5 9b4e3e4b45 Allinea l'app ai nuovi testi e ai destinatari dei pulsanti (DD-040)
I pulsanti «Avvisa chi è di turno» e «Sollecita» usano gli stessi testi dei job
del database. Il sollecito manuale segue i convocati e dice la risposta attuale
e chi lo ha chiesto. L'ordine degli eventi dei palloni diventa data, ora, id.
Le righe del corpo di una notifica vanno a capo nel centro notifiche.

Test unitari dei testi, dei destinatari e delle date; il test delle route
verifica testi, convocati e assenza di email per i palloni.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:03:50 +02:00
davideandClaude Sonnet 5.5 563142a30a Aggiunge la migration m26: notifiche automatiche di palloni e solleciti (DD-040)
Promemoria con «oggi/domani», data, ora e luogo. Turno palloni a 12, 6 e 3 ore
per gli eventi con un incaricato confermato, con revoca una tantum se
l'incaricato cambia. Sollecito presenze a 24, 12 e 6 ore ai destinatari
dell'evento senza risposta o con «forse». Due job pg_cron ogni 15 minuti; push
dei tipi automatici dalla coda; nessuna email per il turno palloni.

Nuovo test di integrazione sui job veri del database; aggiornati i test di
promemoria, code push ed email.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:03:50 +02:00
davideandClaude Sonnet 5.5 07624f6b9f Documenta le notifiche automatiche di palloni e presenze e i testi formali (DD-040)
Specifica, senza ancora toccare il codice: promemoria con «oggi/domani», turno
palloni a 12, 6 e 3 ore con revoca dell'incarico e senza email, solleciti
presenze a 24, 12 e 6 ore con gli stessi destinatari del pulsante. I pulsanti
manuali restano. Aggiunge la sezione «Testi delle notifiche» al modulo notifiche.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:06:01 +02:00
30 changed files with 2102 additions and 135 deletions

No files matched your search

+7 -8
View File
@@ -11,8 +11,9 @@ amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzi
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0, ha
portato il ruolo allenatore. La versione corrente è la 1.2.0: notifiche su tre canali (push, in-app
ed email) e promemoria che non si ripetono (vedi `docs/CHANGELOG.md`).
portato il ruolo allenatore. La versione corrente è la 1.2.1: notifiche su tre canali (push, in-app
ed email), promemoria che non si ripetono, turno palloni e solleciti presenze automatici (vedi
`docs/CHANGELOG.md`).
Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane
da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
@@ -25,10 +26,8 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 36 migration in `supabase/migrations/`, fino a `m25_promemoria_solo_giocatori` (29/09/2026).
Le prime 33 (fino a `m22_notifiche_email`) sono applicate in produzione, verificato con
`npx supabase migration list` il 29/09/2026; **`m23`, `m24` e `m25` sono verificate solo in locale
e non ancora applicate in produzione**
- 37 migration in `supabase/migrations/`, fino a `m26_notifiche_automatiche` (30/09/2026), tutte
applicate in produzione, verificato con `npx supabase migration list` il 30/09/2026
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
@@ -207,8 +206,8 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0) e le notifiche su tre
canali (DD-036–DD-039, 1.2.0) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0), le notifiche su tre
canali (DD-036–DD-039, 1.2.0) e quelle automatiche di palloni e solleciti (DD-040, 1.2.1) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
---
+49
View File
@@ -13,6 +13,55 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [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
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
### Aggiunto
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
turno» resta.
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
non riceve tutte le notifiche insieme.
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
- **Documentazione** — DD-040 in `DESIGN_DECISIONS.md`; sezione «Testi delle notifiche» nel catalogo di
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
### Modificato
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
palloni manuale non parte più per email.
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
di riconsegna, come già descritto nel catalogo.
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
«successivo» coincidono con quelli dei job del database.
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
### Corretto
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
## [1.2.0] - 2026-09-29
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
+26 -10
View File
@@ -75,16 +75,32 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Turni e notifiche
| Tabella | Scopo | Note |
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. |
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. |
| Tabella | Scopo | Note |
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. Migration `m26_notifiche_automatiche` (DD-040): il vincolo `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job; `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti. Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi automatici ma non i due dei pulsanti. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). Con DD-040 (migration `m26`) il trigger salta i tipi del turno palloni, che non mandano email. |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. Con DD-040 (migration `m26`) il `CHECK` sul tipo ammette anche i tipi dei palloni (12h, 6h, 3h, revocato) e dei solleciti (24h, 12h, 6h); è anche la memoria che permette la revoca. |
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. Con DD-040 (migration `m26`) accoda anche turno palloni e solleciti presenze. |
### Funzioni SQL e job delle notifiche
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
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.
| 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_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_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) |
| `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. | — |
| `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). | — |
## Funzioni speciali
+103
View File
@@ -55,6 +55,7 @@ Serve a rispondere a domande del tipo:
| [DD-037](#dd-037--i-promemoria-evento-ricordano-di-essere-già-stati-generati) | Promemoria evento senza ritorni |
| [DD-038](#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker) | Tre canali per ogni notifica |
| [DD-039](#dd-039--lallenatore-non-riceve-i-promemoria-degli-eventi) | Niente promemoria all'allenatore |
| [DD-040](#dd-040--promemoria-con-oggi-o-domani-turno-palloni-e-solleciti-automatici) | Palloni e solleciti automatici |
**In valutazione**
@@ -939,6 +940,9 @@ 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
prima dell'evento, per gli eventi con un incaricato confermato; il pulsante e la route restano.
### DD-026 — Il testo della notifica viaggia dentro la push
**Stato:** accettata · **Data:** settembre 2026
@@ -1710,6 +1714,9 @@ Vercel e avevano già i tre canali.
allenatori, anche di giocatori non più attivi.
- Le altre notifiche non cambiano canali e non ricevono push doppie.
**Aggiornamento (30 settembre 2026)** — DD-040 esclude dalle email le notifiche del turno palloni: per
quei tipi i canali sono due (push e in-app).
**Riesame**
Se l'host di casa si rivela poco affidabile per una notifica sensibile al tempo come la push, o se
il database dovesse comunque chiamare l'app per altro, valutare di nuovo la chiamata dal database
@@ -1755,3 +1762,99 @@ restano invariati.
**Riesame**
Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme
alla sua possibilità di rispondere.
### DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici
**Data:** 30 settembre 2026
**Stato:** Accettata (implementata e verificata in locale: migration `m26`, job, route e test; la
migration non è ancora applicata in produzione)
**Contesto**
Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno
lo premeva, nessuno veniva avvisato. I testi erano inoltre brevi e informali, e i promemoria non dicevano
quando e dove. Si vuole che turno palloni e sollecito presenze partano anche da soli, a orari fissi prima
dell'evento, e che i testi siano più formali e completi. I testi nuovi sono nel
[catalogo](modules/notifiche.md#testi-delle-notifiche).
**Decisione**
1. **Promemoria a 24 e 3 ore:** nuovo testo con data, ora e luogo. La parola nel titolo e nella riga
«Data» è **«domani»** se la data dell'evento è quella di domani (fuso `Europe/Rome`), **«oggi»**
altrimenti. Vale per entrambi i promemoria, quindi anche quello a 3 ore di un evento dopo la
mezzanotte dice «domani». La parola la calcola la funzione SQL quando genera la notifica.
2. **Turno palloni automatico.** Un job `pg_cron` ogni 15 minuti avvisa per gli eventi di tipo allenamento
o partita, in tre tempi con finestre che non si sovrappongono: **12 ore** (tra 12 e 6 ore prima
dell'inizio), **6 ore** (tra 6 e 3) e **3 ore** (meno di 3). Parte **solo se per l'evento c'è un
incaricato confermato** in `turni_palloni`: le proposte automatiche di `completaTurni()` non salvate non
contano. Gli avvisi sono due, con lo stesso testo nei tre tempi: «incarico assegnato» all'incaricato e
«riconsegna» a chi aveva i palloni all'evento precedente, se è un'altra persona.
- **Pulsante manuale.** «Avvisa chi è di turno» e la route `promemoria-palloni` **restano**: tipo
`turno_palloni`, testo nuovo, push mandata dalla route e non dalla coda. È una notifica in più;
riceverne due (automatica e manuale) va bene.
- **Incaricato cambiato dopo un avviso.** Il nuovo incaricato riceve il suo avviso al giro successivo,
con il tipo della finestra in cui cade. Chi aveva già ricevuto un avviso e non è più destinatario
riceve **una sola volta** «Turno palloni: incarico revocato» (`turno_palloni_revocato`), se
l'evento non è iniziato. Il job lo scopre confrontando i destinatari attuali con il **registro** degli
avvisi automatici già generati (non con le notifiche, che il giocatore può aver eliminato), senza
trigger su `turni_palloni`. Gli avvisi del pulsante non contano: possono riguardare una proposta non
confermata.
- **Niente email.** Gli avvisi palloni (automatici, revoca e pulsante) arrivano come push e nel centro
notifiche, **non per email**: il trigger `notifiche_utente_accoda_email` salta i tipi
`turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. È un'eccezione a
DD-038 («ogni notifica ha tre canali») solo per questi tipi; l'interruttore «Email» non cambia.
3. **Sollecito presenze automatico in tre tempi.** Lo stesso job manda il sollecito **24 ore** (tra 24 e
12 ore prima), **12 ore** (tra 12 e 6) e **6 ore** (meno di 6) prima, al massimo una notifica per
giocatore e per tipo. Lo riceve solo chi in quel momento non ha risposto o ha risposto «forse» ed è
destinatario dell'evento: i convocati, o tutta la rosa attiva se `convocati` è vuoto, mai l'allenatore.
Chi risponde presente, assente o in ritardo smette di riceverli; chi resta su «forse» li riceve tutti e
tre. Un evento creato a meno di 12 ore dall'inizio riceve solo il sollecito della sua finestra.
- **Pulsante manuale.** «Sollecita» e la route `sollecita-presenze` **restano**, anche per l'allenatore:
tipo `sollecita_presenze`, push mandata dalla route, testo nuovo con in più la riga «Richiesta di:
_Nome_». I destinatari sono **gli stessi dell'automatico** (convocati senza risposta o con «forse»);
oggi la route guarda invece tutti i giocatori attivi.
4. **Stessa infrastruttura dei promemoria** (DD-037, DD-038): le notifiche automatiche nascono nel
database, il registro `promemoria_eventi_generati` impedisce i doppioni anche se il giocatore le
elimina, la push la manda il worker dalla coda `notifiche_push_coda`, l'email (quando c'è) nasce dal
trigger su `notifiche_utente`. Tipi nuovi: `sollecita_presenze_24h`, `_12h`, `_6h`;
`turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. Il `turno_palloni` del pulsante resta.
5. **Migration `m26`:** estende i `CHECK` sui tipi di `notifiche_utente` e del registro, il trigger della
coda push, il trigger della coda email (che salta i tipi dei palloni) e ridefinisce la funzione dei
promemoria. Le funzioni nuove riusano `giocatori_destinatari_evento()`.
**Alternative scartate**
- Togliere i pulsanti manuali (turno palloni e sollecito) una volta arrivati gli automatici → scartata su
indicazione esplicita: servono all'admin per rimandare un avviso o sollecitare fuori dagli orari fissi.
- Mandare tutti i solleciti (o gli avvisi palloni) a un evento creato tardi → scartata: più messaggi nel
giro di pochi minuti sono rumore.
- «Domani» e «oggi» fissi nei due testi → scartata: il job delle 24 ore scatta anche per eventi che
iniziano oggi, e quello delle 3 ore attraversa la mezzanotte.
- Calcolare il testo nell'app invece che nel database → scartata: le notifiche nascono nei job `pg_cron`,
che non chiamano l'app (DD-038).
- Spostare nel worker il calcolo di evento precedente e successivo dei palloni → scartata: un secondo
punto che scrive in `notifiche_utente`, per un vantaggio piccolo.
- Ritirare l'avviso già consegnato al vecchio incaricato → scartata: per la push non si può, e in-app
confonde. Si manda la revoca.
**Conseguenze**
- Cambia la regola «quattro notifiche su sei dipendono da un gesto manuale»: restano solo manuali il
messaggio dello staff e il sondaggio pre-partita; per turno palloni e sollecito gli automatici si
aggiungono ai pulsanti, e un giocatore può ricevere lo stesso avviso due volte.
- **Logica duplicata:** «evento precedente» e «evento successivo» per i palloni, in `palloni-core.ts`
(usato dal pulsante e dalla schermata), sono riscritti in SQL per i job (`genera_avvisi_palloni_fascia`).
Ordine comune: data, ora, id. Se si cambia una regola in una sola delle due versioni, pulsante e job danno
risposte diverse: `notifiche-automatiche.test.ts` confronta testi e destinatari sugli stessi dati, anche a
parità di data e ora.
- `destinatariSollecito()` riceve anche i convocati e serve al sollecito manuale; un giocatore non
convocato non è più sollecitato. `avvisiPalloniEvento()` resta come riferimento per i test finché il SQL
non la sostituisce del tutto.
- Se un incaricato revocato viene poi riassegnato non riceve un secondo avviso, perché il registro lo ha
già generato per quella finestra: la revoca resta l'ultima notifica che ha letto.
- Le mail automatiche per evento e giocatore sono al massimo **cinque** (due promemoria, tre solleciti),
più quelle dei pulsanti (sollecito, sondaggio). Il turno palloni non ne manda.
- Il testo dei promemoria già generati non cambia (il registro impedisce di rigenerarli).
**Riesame**
Dopo la prima stagione di uso: se i solleciti o gli avvisi palloni risultano troppi, ridurre le
finestre o escludere chi ha risposto «forse» più di una volta.
+2
View File
@@ -44,6 +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,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036)
- [x] Notifiche automatiche — promemoria con «oggi/domani», turno palloni 12, 6 e 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)
## Prossimo
+1 -1
View File
@@ -1,7 +1,7 @@
# Worker email: installazione e gestione
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
per email e la push dei promemoria a 24 e 3 ore. Cosa fa il canale email sta in
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
[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
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa)
+2 -2
View File
@@ -114,7 +114,7 @@ a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso.
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.
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
@@ -129,7 +129,7 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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). |
| 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. |
| 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. |
+3 -1
View File
@@ -50,7 +50,9 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a
3 ore, turno palloni, sollecito presenze, sondaggio pre-partita. Un tipo nuovo che in futuro finisse in quella tabella
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
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
**non** vengono inviate: il trigger non ha backfill.
+100 -33
View File
@@ -31,27 +31,69 @@ particolare il certificato in scadenza **non** genera push né voci nel centro n
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
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, quindi
la colonna Email coincide con la colonna In-app).
([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).
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | ----- |
| 1 | **Promemoria a 24 ore** — «Promemoria: _titolo_», corpo «data alle ora» | 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ì |
| 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ì |
| 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** — «Tocca a te prendere i palloni» / «Porta i palloni» | Un admin, con il pulsante nella pagina dell'evento. Solo eventi di tipo allenamento o partita. Route `promemoria-palloni`, tipo `turno_palloni`. | 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ì | sì |
| 5 | **Sollecito presenze** — «Manca la tua risposta» | Un admin o un allenatore, con il pulsante nella pagina dell'evento. Route `sollecita-presenze`, tipo `sollecita_presenze`. | I giocatori attivi (mai gli allenatori) che non hanno ancora 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ì |
| 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 |
| # | 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ì |
| 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ì |
| 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) |
| 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ì |
| 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 |
Tutte le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038). La
differenza sta in come parte la push: quelle dei pulsanti (3-6) la mandano le route dell'app, quelle
dei promemoria (1 e 2) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Le righe 4 e 5 riscrivono la notifica esistente se l'admin preme di nuovo il pulsante
per lo stesso evento, e lo stesso fa il sondaggio (vedi «Centro notifiche in-app»).
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
automatiche (1, 2, 4 e 5) 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
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
> verificate in locale; la migration va ancora applicata in produzione. Finché non lo è, l'app si comporta
> come in 1.2.0: promemoria con il testo breve, turno palloni e sollecito presenze solo da pulsante.
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
### Testi delle notifiche
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato 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`.
| # | 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) |
| 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 |
| 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 |
| 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.` |
Le righe 1-2, 4a-4c e 5 nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
`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
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
dall'emoji. Non entrano nel centro notifiche e non generano email.
| Situazione | Titolo | Corpo |
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
### Cosa non genera nessuna notifica
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
@@ -81,27 +123,39 @@ Sono l'effetto attuale della logica, non scelte documentate altrove:
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
- **Le notifiche 3, 4 e 5, una volta eliminate, non tornano da sole:** riappaiono solo se un admin
rimanda il messaggio o ripreme il pulsante.
- **Le notifiche eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
rimanda il messaggio o riapre il sondaggio; quelle automatiche (1, 2, 4 e 5) non tornano affatto,
perché il registro ricorda di averle già generate.
- **Un evento già iniziato non genera più promemoria.**
- **Quattro notifiche su sei dipendono da un gesto manuale** (3, 4, 5 e 6): se nessuno preme il
pulsante, nessuno viene avvisato. Gli unici automatismi sono i due promemoria.
- **Per ogni evento i giocatori ricevono al massimo due mail automatiche** (24 e 3 ore prima),
più quelle dei pulsanti manuali.
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
- **Il turno palloni parte solo se l'incaricato è confermato** (DD-040): per una partita la
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
«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
prima dell'inizio, 12 ore tra 12 e 6, 6 ore sotto le 6. Un evento creato a meno di 12 ore
dall'inizio riceve solo quello della sua finestra. Chi risponde presente, assente o in ritardo
smette di riceverli; chi resta su «forse» li riceve tutti e tre.
- **Per ogni evento un giocatore riceve al massimo cinque mail automatiche** (DD-040): due
promemoria e tre solleciti, 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.
- **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.
### Test che verificano il catalogo
| 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` |
| 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` |
| 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` |
| 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`) |
| 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` |
| 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` |
| 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` |
| 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` |
| 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`) |
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
@@ -155,7 +209,7 @@ copre in particolare le sessioni lunghe della webapp (vedi il
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
`promemoria-palloni.ts` e `apri-sondaggio.ts` .
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
@@ -179,6 +233,9 @@ variabile d'ambiente.
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
anche per l'allenatore, `richiediGestoreEventi()`).
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
@@ -224,7 +281,9 @@ non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico per
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
Cinque sorgenti scrivono in `notifiche_utente`, mai il client:
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`/
`_12h`/`_6h`; i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
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).
@@ -249,6 +308,9 @@ invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stess
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
@@ -285,6 +347,11 @@ DD-038).
La push dei promemoria dipende solo dai dispositivi iscritti, non dall'interruttore «Email». Se il
worker non ha le chiavi VAPID parte lo stesso e manda solo le email, segnalandolo nel log.
Con DD-040 il trigger accoda in `notifiche_push_coda` anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h` e `_6h`, perché nascono nel database e nessuna route manda la
push (quelle dei pulsanti, `turno_palloni` e `sollecita_presenze`, restano fuori dalla coda): le regole di invio, backoff e scarto sono quelle qui sopra. Un sollecito in coda da oltre 3 ore si scarta come un
promemoria.
## Canale email (M22)
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
+13 -5
View File
@@ -10,8 +10,8 @@
## Obiettivo
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 promemoria push il
giorno stesso. Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
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
compleanni non c'entrano: non richiedono palloni.
---
@@ -26,7 +26,7 @@ compaiono.
## Implementazione
- `eventiPalloni()` (`palloni-core.ts`) è il filtro comune a tutta la pipeline (proposte,
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
scarta eventi extra-campo e compleanni.
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
@@ -55,6 +55,13 @@ oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un al
## Route API pubblica `/api/public/promemoria-palloni`
> **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
> 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» /
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
@@ -69,8 +76,9 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
## Limiti noti
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
"assente" o "infortunato" per quell'evento.
+13 -5
View File
@@ -68,14 +68,22 @@ compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
↓
src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
├─ destinatariSollecito() → src/lib/presenze.ts
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
├─ per ciascuno invia una push col testo cifrato nel payload
│ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410)
```
> **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
> 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
> 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).
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
@@ -86,9 +94,9 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
server, la UI risponde subito.
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
(DD-024).
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron`
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).
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"sideEffects": false,
"type": "module",
+3 -1
View File
@@ -110,7 +110,9 @@ function RigaNotifica({
className="touch-pan-y bg-card p-3 pr-8 text-sm"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? <p className="mt-0.5 text-muted-foreground">{notifica.corpo}</p> : null}
{notifica.corpo ? (
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
) : null}
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
+6
View File
@@ -133,6 +133,12 @@ export type RigaClassifica = {
punti: number;
};
/** Data numerica `GG/MM/AAAA`, come scritta dai testi delle notifiche (stessa del database). */
export function formatDataNumerica(iso: string): string {
const [anno, mese, giorno] = iso.split("-");
return `${giorno}/${mese}/${anno}`;
}
export function formatData(iso: string) {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
+7
View File
@@ -16,7 +16,14 @@ export type TipoNotifica =
| "evento_promemoria_24h"
| "evento_promemoria_3h"
| "turno_palloni"
| "turno_palloni_12h"
| "turno_palloni_6h"
| "turno_palloni_3h"
| "turno_palloni_revocato"
| "sollecita_presenze"
| "sollecita_presenze_24h"
| "sollecita_presenze_12h"
| "sollecita_presenze_6h"
| "sondaggio_cacche";
export type NotificaUtente = {
+39 -23
View File
@@ -1,4 +1,4 @@
import { formatData } from "./crapp-data";
import { formatDataNumerica } from "./crapp-data";
import type { Evento } from "./eventi";
import { dataOggi } from "./scout-live";
import { aggiornaSerie } from "./serie";
@@ -8,12 +8,19 @@ export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: st
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
export type CandidatoTurno = { id: string; nome: string };
/** Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data. */
/**
* Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data e, a parità,
* di ora. Lo stesso ordine lo usa il database per i job automatici (M26, DD-040).
*/
export function eventiPalloni(eventi: Evento[]): Evento[] {
return eventi
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
.slice()
.sort((a, b) => a.data.localeCompare(b.data));
.sort((a, b) => {
if (a.data !== b.data) return a.data < b.data ? -1 : 1;
if (a.ora !== b.ora) return a.ora < b.ora ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
/**
@@ -139,11 +146,12 @@ export function destinatariPromemoriaPalloni(
}
/**
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025, DD-040).
*
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
* «riconsegna». Se per l'evento non c'è un incaricato non parte nulla. Il testo viaggia cifrato
* dentro la push, quindi il service worker lo mostra senza rete.
*/
export function avvisiPalloniEvento(
turni: Record<string, string>,
@@ -153,29 +161,37 @@ export function avvisiPalloniEvento(
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
if (!evento) return [];
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
const incaricato = turni[evento.id];
if (incaricato) {
const dopo = eventoSuccessivo(eventi, evento.id);
avvisi.push({
giocatoreId: incaricato,
titolo: "Tocca a te prendere i palloni",
testo: dopo
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
: `${quando}: a fine evento porti a casa i palloni.`,
});
}
if (!incaricato) return [];
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
const intestazione = [
`Evento: ${evento.titolo}`,
`Data: ${formatDataNumerica(evento.data)}, ore ${evento.ora}`,
];
const dopo = eventoSuccessivo(eventi, evento.id);
avvisi.push({
giocatoreId: incaricato,
titolo: "Turno palloni: incarico assegnato",
testo: [
...intestazione,
"Incarico: custodia dei palloni al termine dell'evento",
...(dopo ? [`Riconsegna: ${formatDataNumerica(dopo.data)}`] : []),
].join("\n"),
});
// Chi li ha presi la volta scorsa deve ricordarsi di riportarli.
const prima = eventoPrecedente(eventi, evento.id);
const precedente = prima ? turni[prima.id] : undefined;
if (precedente && precedente !== incaricato) {
avvisi.push({
giocatoreId: precedente,
titolo: "Porta i palloni",
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
titolo: "Turno palloni: riconsegna",
testo: [
...intestazione,
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
].join("\n"),
});
}
+31 -4
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import { formatDataNumerica, type Stato } from "./crapp-data";
import type { Evento, EventoTipo } from "./eventi";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { aggiornaSerie } from "./serie";
@@ -82,18 +82,22 @@ export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null
}
/**
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
* Chi va sollecitato per un evento (DD-040): i destinatari dell'evento — i convocati, o tutta la
* rosa se `convocati` è vuoto — che non hanno ancora risposto o hanno risposto «forse». Mai gli
* allenatori (DD-034). Stessa regola del job automatico del database (`giocatori_destinatari_evento`
* più `genera_solleciti_presenze`, migration M26): funzione pura, come `avvisiPalloniEvento()` per i
* palloni, chiamata dalla route `/api/public/sollecita-presenze` con i dati che ha già letto.
*/
export function destinatariSollecito(
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
risposte: Array<{ giocatore_id: string; stato: string }>,
convocati: string[] = [],
): string[] {
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
return squadra
.filter(inRosa)
.filter((g) => convocati.length === 0 || convocati.includes(g.id))
.filter((g) => {
const stato = stati.get(g.id);
return stato === undefined || stato === "forse";
@@ -101,6 +105,29 @@ export function destinatariSollecito(
.map((g) => g.id);
}
/**
* Titolo e corpo del sollecito, gli stessi del job automatico (migration M26). `da` è il nome di
* chi preme il pulsante: compare solo nel sollecito manuale, come riga «Richiesta di».
*/
export function testoSollecito(
evento: Pick<Evento, "titolo" | "data" | "ora" | "luogo">,
stato: string | undefined,
da?: string,
): { titolo: string; testo: string } {
const luogo = evento.luogo.trim();
return {
titolo: `Conferma di partecipazione richiesta: ${evento.titolo}`,
testo: [
`Data: ${formatDataNumerica(evento.data)}`,
`Ora: ${evento.ora}`,
...(luogo ? [`Luogo: ${luogo}`] : []),
...(da ? [`Richiesta di: ${da}`] : []),
`Risposta attuale: ${stato === "forse" ? "forse" : "nessuna"}`,
"Azione: indicare presente, assente o in ritardo",
].join("\n"),
};
}
/**
* Serie di presenze consecutive su eventi già passati, in ordine di data:
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
+17 -11
View File
@@ -2,10 +2,9 @@ import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediGestoreEventi } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { destinatariSollecito } from "@/lib/presenze";
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
@@ -35,19 +34,24 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
.eq("evento_id", evento.id);
const squadra = await leggiGiocatoriSquadra();
const destinatari = destinatariSollecito(squadra, righe ?? []);
const destinatari = destinatariSollecito(squadra, righe ?? [], evento.convocati);
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, p256dh, auth")
.select("endpoint, giocatore_id, p256dh, auth")
.in("giocatore_id", destinatari);
const titolo = "Manca la tua risposta";
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
} una conferma: presente, assente o in ritardo?`;
// Il testo dice se il giocatore non ha risposto o ha risposto «forse» (DD-040): è
// quindi per destinatario, e la push porta il testo del proprio destinatario.
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
const testi = new Map(
destinatari.map((giocatoreId) => [
giocatoreId,
testoSollecito(evento, stati.get(giocatoreId), parsed.data.da),
]),
);
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
// notifica a non letta, non fallire per il vincolo UNIQUE. `types.ts` non include
@@ -57,8 +61,8 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
destinatari.map((giocatoreId) => ({
giocatore_id: giocatoreId,
tipo: "sollecita_presenze",
titolo,
corpo: testo,
titolo: testi.get(giocatoreId)?.titolo,
corpo: testi.get(giocatoreId)?.testo,
evento_id: evento.id,
letta: false,
creato_il: new Date().toISOString(),
@@ -68,8 +72,10 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
const avviso = testi.get(iscrizione.giocatore_id);
if (!avviso) continue;
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
@@ -0,0 +1,417 @@
-- M26 — Notifiche automatiche di promemoria, turno palloni e sollecito presenze (DD-040)
--
-- Cosa cambia rispetto a M17/M23/M24:
--
-- 1. I promemoria a 24 e 3 ore hanno un testo nuovo, con data, ora e luogo; la parola «domani»
-- compare se la data dell'evento è quella di domani (fuso Europe/Rome), «oggi» altrimenti.
-- 2. Il turno palloni parte anche da solo, a 12, 6 e 3 ore dall'inizio, per gli allenamenti e le
-- partite con un incaricato *confermato* in `turni_palloni`: all'incaricato e, se è un'altra
-- persona, a chi aveva i palloni all'evento precedente. Se l'incaricato cambia dopo un
-- avviso, chi lo aveva ricevuto riceve una sola revoca.
-- 3. Il sollecito presenze parte da solo a 24, 12 e 6 ore dall'inizio, a chi tra i destinatari
-- dell'evento non ha risposto o ha risposto «forse».
-- 4. Le notifiche del turno palloni non generano email: restano push e centro notifiche.
--
-- I pulsanti manuali («Avvisa chi è di turno», «Sollecita») restano: usano gli stessi testi (vedi
-- `src/lib/palloni-core.ts` e `src/lib/presenze.ts`) e mandano la push dalla route, non dalla coda.
--
-- Le fasce sono contigue e senza sovrapposizione: un evento creato a meno di 12 ore dall'inizio
-- riceve solo la notifica della fascia in cui cade, non tutte insieme. Ogni notifica automatica si
-- genera una sola volta per (giocatore, evento, tipo), anche se il giocatore la elimina: lo ricorda
-- il registro `promemoria_eventi_generati` (M23, DD-037), che qui si estende ai tipi nuovi.
-- --- Tipi di notifica --------------------------------------------------------------------------
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
tipo IN (
'admin',
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato',
'sollecita_presenze',
'sollecita_presenze_24h',
'sollecita_presenze_12h',
'sollecita_presenze_6h',
'sondaggio_cacche'
)
);
ALTER TABLE public.promemoria_eventi_generati DROP CONSTRAINT promemoria_eventi_generati_tipo_check;
ALTER TABLE public.promemoria_eventi_generati ADD CONSTRAINT promemoria_eventi_generati_tipo_check CHECK (
tipo IN (
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato',
'sollecita_presenze_24h',
'sollecita_presenze_12h',
'sollecita_presenze_6h'
)
);
-- --- Istante d'inizio di un evento -------------------------------------------------------------
--
-- `data` e `ora` sono ora locale Italia (stessa convenzione di M10 e M17). `ora` è testo libero: se
-- non è un'ora valida il risultato è NULL e l'evento viene solo escluso (vedi `ora_evento_a_time`).
CREATE OR REPLACE FUNCTION public.inizio_evento(p_data date, p_ora text)
RETURNS timestamptz
LANGUAGE sql STABLE AS $$
SELECT (p_data + public.ora_evento_a_time(p_ora)) AT TIME ZONE 'Europe/Rome';
$$;
-- --- Promemoria a 24 e 3 ore: testo nuovo ------------------------------------------------------
--
-- Stessa firma e stessi criteri di M23 (i cron non cambiano). Cambia solo il testo: titolo
-- «Promemoria evento di domani|oggi: <titolo>», corpo con Data, Ora e, se c'è, Luogo.
CREATE OR REPLACE FUNCTION public.genera_promemoria_eventi(p_tipo text, p_finestra interval)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
WITH da_generare AS (
SELECT d AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora, e.luogo,
CASE WHEN e.data = ((now() AT TIME ZONE 'Europe/Rome')::date + 1)
THEN 'domani' ELSE 'oggi' END AS parola
FROM public.eventi_app e
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
WHERE public.inizio_evento(e.data, e.ora) IS NOT NULL
AND public.inizio_evento(e.data, e.ora) - now() BETWEEN interval '0' AND p_finestra
),
nuovi AS (
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, p_tipo FROM da_generare
ON CONFLICT DO NOTHING
RETURNING giocatore_id, evento_id
)
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
SELECT g.giocatore_id, p_tipo,
'Promemoria evento di ' || g.parola || ': ' || g.titolo,
'Data: ' || g.parola || ', ' || to_char(g.data, 'DD/MM/YYYY')
|| E'\nOra: ' || g.ora
|| CASE WHEN btrim(g.luogo) <> '' THEN E'\nLuogo: ' || btrim(g.luogo) ELSE '' END,
g.evento_id
FROM nuovi n
JOIN da_generare g ON g.giocatore_id = n.giocatore_id AND g.evento_id = n.evento_id
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
END;
$$;
-- --- Turno palloni automatico ------------------------------------------------------------------
--
-- «Evento precedente» e «evento successivo» seguono `eventoPrecedente()`/`eventoSuccessivo()` di
-- `src/lib/palloni-core.ts`: solo allenamenti e partite, in ordine di data (a parità, di ora). La
-- logica è volutamente la stessa in due posti (il database non può chiamare il codice dell'app):
-- `test/integration/notifiche-automatiche.test.ts` li confronta sugli stessi dati.
-- Avvisi della fascia [p_da, p_a) prima dell'inizio. Parte solo se l'evento ha un incaricato
-- confermato in `turni_palloni`; i destinatari devono essere giocatori attivi.
CREATE OR REPLACE FUNCTION public.genera_avvisi_palloni_fascia(
p_tipo text, p_da interval, p_a interval
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
WITH ev AS (
SELECT e.id, e.titolo, e.data, e.ora,
public.inizio_evento(e.data, e.ora) AS inizio,
lag(e.id) OVER ordine AS precedente_id,
lead(e.data) OVER ordine AS successivo_data
FROM public.eventi_app e
WHERE e.tipo IN ('allenamento', 'partita')
WINDOW ordine AS (
ORDER BY e.data, public.ora_evento_a_time(e.ora), e.id COLLATE "C"
)
),
turni AS (
SELECT ev.*, t.giocatore_id AS incaricato, tp.giocatore_id AS precedente
FROM ev
JOIN public.turni_palloni t ON t.evento_id = ev.id
LEFT JOIN public.turni_palloni tp ON tp.evento_id = ev.precedente_id
WHERE ev.inizio IS NOT NULL
AND ev.inizio - now() >= p_da
AND ev.inizio - now() < p_a
),
destinatari AS (
SELECT incaricato AS giocatore_id, id AS evento_id, true AS e_incaricato,
titolo, data, ora, successivo_data
FROM turni
UNION ALL
SELECT precedente, id, false, titolo, data, ora, successivo_data
FROM turni
WHERE precedente IS NOT NULL AND precedente <> incaricato
),
validi AS (
SELECT d.*
FROM destinatari d
JOIN public.giocatori_squadra g
ON g.id = d.giocatore_id AND g.attivo = true AND g.tipo = 'giocatore'
),
nuovi AS (
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, p_tipo FROM validi
ON CONFLICT DO NOTHING
RETURNING giocatore_id, evento_id
)
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
SELECT v.giocatore_id, p_tipo,
CASE WHEN v.e_incaricato THEN 'Turno palloni: incarico assegnato'
ELSE 'Turno palloni: riconsegna' END,
'Evento: ' || v.titolo
|| E'\nData: ' || to_char(v.data, 'DD/MM/YYYY') || ', ore ' || v.ora
|| CASE WHEN v.e_incaricato
THEN E'\nIncarico: custodia dei palloni al termine dell''evento'
|| CASE WHEN v.successivo_data IS NOT NULL
THEN E'\nRiconsegna: ' || to_char(v.successivo_data, 'DD/MM/YYYY')
ELSE '' END
ELSE E'\nIncarico: riconsegna dei palloni in custodia dal turno precedente'
END,
v.evento_id
FROM nuovi n
JOIN validi v ON v.giocatore_id = n.giocatore_id AND v.evento_id = n.evento_id
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
END;
$$;
-- Revoca: chi ha ricevuto un avviso automatico per un evento non ancora iniziato e non è più
-- destinatario (incaricato cambiato o tolto, o turno precedente riassegnato) riceve una sola volta
-- «incarico revocato». Si guarda il registro, non `notifiche_utente`: il giocatore può aver
-- eliminato l'avviso. Gli avvisi del pulsante non contano: possono riguardare una proposta non
-- confermata, che qui non esiste.
CREATE OR REPLACE FUNCTION public.genera_revoche_palloni()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
WITH ev AS (
SELECT e.id, e.titolo, e.data, e.ora,
public.inizio_evento(e.data, e.ora) AS inizio,
lag(e.id) OVER ordine AS precedente_id
FROM public.eventi_app e
WHERE e.tipo IN ('allenamento', 'partita')
WINDOW ordine AS (
ORDER BY e.data, public.ora_evento_a_time(e.ora), e.id COLLATE "C"
)
),
attuali AS (
SELECT t.giocatore_id, ev.id AS evento_id
FROM ev JOIN public.turni_palloni t ON t.evento_id = ev.id
UNION
SELECT tp.giocatore_id, ev.id
FROM ev
JOIN public.turni_palloni t ON t.evento_id = ev.id
JOIN public.turni_palloni tp ON tp.evento_id = ev.precedente_id
JOIN public.giocatori_squadra g ON g.id = tp.giocatore_id
WHERE tp.giocatore_id <> t.giocatore_id
),
da_revocare AS (
SELECT DISTINCT r.giocatore_id, r.evento_id, ev.titolo, ev.data, ev.ora
FROM public.promemoria_eventi_generati r
JOIN ev ON ev.id = r.evento_id
WHERE r.tipo IN ('turno_palloni_12h', 'turno_palloni_6h', 'turno_palloni_3h')
AND ev.inizio IS NOT NULL
AND ev.inizio >= now()
AND NOT EXISTS (
SELECT 1 FROM attuali a
JOIN public.giocatori_squadra g
ON g.id = a.giocatore_id AND g.attivo = true AND g.tipo = 'giocatore'
WHERE a.giocatore_id = r.giocatore_id AND a.evento_id = r.evento_id
)
),
nuovi AS (
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, 'turno_palloni_revocato' FROM da_revocare
ON CONFLICT DO NOTHING
RETURNING giocatore_id, evento_id
)
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
SELECT d.giocatore_id, 'turno_palloni_revocato',
'Turno palloni: incarico revocato',
'Evento: ' || d.titolo
|| E'\nData: ' || to_char(d.data, 'DD/MM/YYYY') || ', ore ' || d.ora
|| E'\nIncarico: non più a tuo carico per questo evento',
d.evento_id
FROM nuovi n
JOIN da_revocare d ON d.giocatore_id = n.giocatore_id AND d.evento_id = n.evento_id
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
END;
$$;
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_12h', interval '6 hours', interval '12 hours');
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_6h', interval '3 hours', interval '6 hours');
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_3h', interval '0', interval '3 hours');
PERFORM public.genera_revoche_palloni();
END;
$$;
-- --- Sollecito presenze automatico -------------------------------------------------------------
--
-- Destinatari: quelli dell'evento (`giocatori_destinatari_evento`: convocati, o tutta la rosa
-- attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno una risposta o
-- hanno risposto «forse».
CREATE OR REPLACE FUNCTION public.genera_solleciti_presenze_fascia(
p_tipo text, p_da interval, p_a interval
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
WITH da_generare AS (
SELECT d.gid AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora, e.luogo, r.stato
FROM public.eventi_app e
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d(gid)
LEFT JOIN public.risposte_presenze r
ON r.evento_id = e.id AND r.giocatore_id = d.gid
WHERE public.inizio_evento(e.data, e.ora) IS NOT NULL
AND public.inizio_evento(e.data, e.ora) - now() >= p_da
AND public.inizio_evento(e.data, e.ora) - now() < p_a
AND (r.stato IS NULL OR r.stato = 'forse')
),
nuovi AS (
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, p_tipo FROM da_generare
ON CONFLICT DO NOTHING
RETURNING giocatore_id, evento_id
)
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
SELECT g.giocatore_id, p_tipo,
'Conferma di partecipazione richiesta: ' || g.titolo,
'Data: ' || to_char(g.data, 'DD/MM/YYYY')
|| E'\nOra: ' || g.ora
|| CASE WHEN btrim(g.luogo) <> '' THEN E'\nLuogo: ' || btrim(g.luogo) ELSE '' END
|| E'\nRisposta attuale: ' || CASE WHEN g.stato = 'forse' THEN 'forse' ELSE 'nessuna' END
|| E'\nAzione: indicare presente, assente o in ritardo',
g.evento_id
FROM nuovi n
JOIN da_generare g ON g.giocatore_id = n.giocatore_id AND g.evento_id = n.evento_id
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
END;
$$;
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 '12 hours', interval '24 hours');
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_12h', interval '6 hours', interval '12 hours');
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_6h', interval '0', interval '6 hours');
END;
$$;
-- Come gli altri job di M17: solo il proprietario (pg_cron) e la service role li eseguono.
REVOKE ALL ON FUNCTION public.inizio_evento(date, text) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni_fascia(text, interval, interval)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.genera_revoche_palloni() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze_fascia(text, interval, interval)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.inizio_evento(date, text) TO service_role;
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni_fascia(text, interval, interval)
TO service_role;
GRANT EXECUTE ON FUNCTION public.genera_revoche_palloni() TO service_role;
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni() TO service_role;
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze_fascia(text, interval, interval)
TO service_role;
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze() TO service_role;
SELECT cron.schedule(
'avvisi-palloni-automatici',
'*/15 * * * *',
$$ SELECT public.genera_avvisi_palloni() $$
);
SELECT cron.schedule(
'solleciti-presenze-automatici',
'*/15 * * * *',
$$ SELECT public.genera_solleciti_presenze() $$
);
-- --- Push: anche le notifiche automatiche di palloni e solleciti -------------------------------
--
-- Nascono nel database come i promemoria: nessuna route le manda, quindi la push passa dalla coda
-- (DD-038). Gli avvisi dei pulsanti (`turno_palloni`, `sollecita_presenze`) restano fuori: la push
-- la manda già la route e una seconda sarebbe un doppione.
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
AFTER INSERT ON public.notifiche_utente
FOR EACH ROW
WHEN (NEW.tipo IN (
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato',
'sollecita_presenze_24h',
'sollecita_presenze_12h',
'sollecita_presenze_6h'
))
EXECUTE FUNCTION public.accoda_push_promemoria();
-- --- Email: il turno palloni non ne manda ------------------------------------------------------
--
-- Stessa funzione di M22, con un'uscita anticipata per i tipi dei palloni (automatici, revoca e
-- pulsante). L'`upsert` del pulsante riscrive `creato_il` e fa scattare il trigger: anche quel
-- caso esce senza accodare nulla.
CREATE OR REPLACE FUNCTION public.accoda_notifica_email()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF NEW.tipo IN (
'turno_palloni',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato'
) THEN
RETURN NEW;
END IF;
INSERT INTO public.notifiche_email_coda (notifica_id)
VALUES (NEW.id)
ON CONFLICT (notifica_id) DO UPDATE
SET stato = 'in_coda',
tentativi = 0,
prossimo_tentativo = now(),
errore = NULL,
inviata_il = NULL,
aggiornata_il = now();
RETURN NEW;
END;
$$;
+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura
| 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 |
| `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) 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ì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
| 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 |
| `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ì |
| `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 | — |
## Server di sviluppo con Node vecchio
+92 -5
View File
@@ -77,6 +77,7 @@ if (!locale) {
const ALLENATORE = "g6";
const PARTITA = `${PREFISSO}-partita`;
const PRESENZE = `${PREFISSO}-presenze`;
const PRESENZE_CONVOCATI = `${PREFISSO}-presenze-convocati`;
const PALLONI_1 = `${PREFISSO}-palloni-1`;
const PALLONI_2 = `${PREFISSO}-palloni-2`;
const GENERICO = `${PREFISSO}-generico`;
@@ -150,10 +151,10 @@ if (!locale) {
/** A quali slot è arrivata una push dall'ultimo `svuota()`, senza ripetizioni e in ordine. */
const destinatariPush = () => [...new Set(ricevute)].sort();
type Notifica = { giocatore_id: string; titolo: string; tipo: string };
type Notifica = { giocatore_id: string; titolo: string; corpo: string; tipo: string };
const notifiche = (filtro: string) =>
leggi<Notifica>(
`notifiche_utente?${filtro}&select=giocatore_id,titolo,tipo&order=giocatore_id`,
`notifiche_utente?${filtro}&select=giocatore_id,titolo,corpo,tipo&order=giocatore_id`,
);
const slot = (righe: Notifica[]) => righe.map((r) => r.giocatore_id);
@@ -191,6 +192,7 @@ if (!locale) {
for (const [id, tipo, data] of [
[PARTITA, "partita", "2031-04-01"],
[PRESENZE, "allenamento", "2031-04-02"],
[PRESENZE_CONVOCATI, "partita", "2031-04-03"],
[PALLONI_1, "allenamento", "2031-05-01"],
[PALLONI_2, "allenamento", "2031-05-08"],
[GENERICO, "evento", "2031-05-09"],
@@ -201,7 +203,8 @@ if (!locale) {
titolo: `${PREFISSO} ${id}`,
data,
ora: "20:00",
convocati: [],
luogo: id === PRESENZE ? "PalaCRAP" : "",
convocati: id === PRESENZE_CONVOCATI ? [G_QUATTRO, G_CINQUE] : [],
});
}
@@ -373,6 +376,72 @@ if (!locale) {
},
);
await prova(
"il sollecito manuale ha il testo nuovo, con chi lo chiede e la risposta attuale",
async () => {
svuota();
const res = await chiama("sollecita-presenze", tokenAdmin, {
eventoId: PRESENZE,
da: "Mario Rossi",
});
assert.equal(res.status, 200);
const righe = await notifiche(`evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze`);
const cinque = righe.find((r) => r.giocatore_id === G_CINQUE);
const altro = righe.find((r) => r.giocatore_id !== G_CINQUE);
assert.equal(
cinque?.titolo,
`Conferma di partecipazione richiesta: ${PREFISSO} ${PRESENZE}`,
);
assert.equal(
cinque?.corpo,
[
"Data: 02/04/2031",
"Ora: 20:00",
"Luogo: PalaCRAP",
"Richiesta di: Mario Rossi",
"Risposta attuale: forse",
"Azione: indicare presente, assente o in ritardo",
].join("\n"),
);
assert.match(altro?.corpo ?? "", /Risposta attuale: nessuna/, "chi non ha risposto");
const conId = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze&select=id`,
);
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${conId.map((r) => r.id).join(",")})&select=notifica_id`,
);
assert.equal(email.length, conId.length, "il sollecito manuale parte anche per email");
assert.equal(
righe.filter((r) => r.giocatore_id === G_CINQUE).length,
1,
"ripremere il pulsante riscrive la notifica, non la duplica",
);
assert.deepEqual(destinatariPush(), [G_CINQUE]);
},
);
await prova(
"il sollecito manuale segue i convocati, come quello automatico (DD-040)",
async () => {
const res = await chiama("sollecita-presenze", tokenAdmin, {
eventoId: PRESENZE_CONVOCATI,
});
assert.equal(res.status, 200);
const righe = await notifiche(
`evento_id=eq.${PRESENZE_CONVOCATI}&tipo=eq.sollecita_presenze`,
);
assert.deepEqual(
slot(righe),
[G_QUATTRO, G_CINQUE],
"solo i convocati: i non convocati non ricevono nulla, nemmeno senza risposta",
);
assert.ok(
!righe[0]!.corpo.includes("Richiesta di"),
"senza nome, senza la riga «Richiesta di»",
);
},
);
await prova("un giocatore non può sollecitare le presenze", async () => {
const res = await chiama("sollecita-presenze", giocatore.token, { eventoId: PRESENZE });
assert.ok(!res.ok, `stato ${res.status}`);
@@ -390,8 +459,25 @@ if (!locale) {
const righe = await notifiche(`evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni`);
assert.deepEqual(slot(righe), [G_UNO, G_DUE]);
const per = new Map(righe.map((r) => [r.giocatore_id, r.titolo]));
assert.equal(per.get(G_DUE), "Tocca a te prendere i palloni");
assert.equal(per.get(G_UNO), "Porta i palloni");
assert.equal(per.get(G_DUE), "Turno palloni: incarico assegnato");
assert.equal(per.get(G_UNO), "Turno palloni: riconsegna");
const corpoDue = righe.find((r) => r.giocatore_id === G_DUE)?.corpo ?? "";
assert.match(
corpoDue,
/^Evento: test-destinatari-notifiche test-destinatari-notifiche-palloni-2\n/,
);
assert.match(corpoDue, /\nData: 08\/05\/2031, ore 20:00\n/);
assert.match(corpoDue, /Incarico: custodia dei palloni al termine dell'evento/);
// DD-040: il turno palloni arriva come push e in-app, mai per email.
const conId = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni&select=id`,
);
assert.equal(conId.length, righe.length);
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${conId.map((r) => r.id).join(",")})&select=notifica_id`,
);
assert.equal(email.length, 0, "nessuna email per il turno palloni");
});
await prova(
@@ -427,6 +513,7 @@ if (!locale) {
for (const id of [
PARTITA,
PRESENZE,
PRESENZE_CONVOCATI,
PALLONI_1,
PALLONI_2,
GENERICO,
@@ -0,0 +1,808 @@
/**
* Notifiche automatiche di turno palloni e sollecito presenze (M26, DD-040):
* `bun test/integration/notifiche-automatiche.test.ts`.
*
* Esegue i job veri del database (`genera_avvisi_palloni`, `genera_solleciti_presenze`) su eventi
* creati con orari relativi a «adesso» e verifica:
*
* - le finestre contigue e senza sovrapposizione (palloni 12·6·3 ore, solleciti 24·12·6 ore);
* - i destinatari (incaricato e turno precedente; convocati senza risposta o con «forse»; mai
* l'allenatore né chi non è più attivo);
* - una sola generazione per giocatore, evento e tipo, anche se la notifica viene eliminata;
* - la revoca dell'incarico, una sola volta, guardando il registro e non le notifiche;
* - il canale: push in coda, nessuna email per i palloni, email per i solleciti;
* - che il testo generato in SQL sia identico a quello del codice TypeScript (`avvisiPalloniEvento`,
* `testoSollecito`) e che «evento precedente/successivo» seguano lo stesso ordine di
* `palloni-core.ts`.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa eventi con il prefisso
* `test-notifiche-automatiche` e gli slot `g4`, `g5`, `g7`, `g8`, `g9` (inattivo) e `g10`
* (allenatore) della rosa seed, ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import type { Evento } from "@/lib/eventi";
import { avvisiPalloniEvento, eventoPrecedente, eventoSuccessivo } from "@/lib/palloni-core";
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche automatiche", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-automatiche");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`notifiche automatiche su ${URL_BASE}`);
const PREFISSO = "test-notifiche-automatiche";
const PASSWORD = "prova-notifiche-automatiche-123";
const G4 = "g4";
const G5 = "g5";
const G6 = "g6";
const G7 = "g7";
const G8 = "g8";
const INATTIVO = "g9";
const ALLENATORE = "g10";
// Palloni: in ordine cronologico PASSATO < PREC < MAIN < EV6 < EV12 < LONTANO.
const PASSATO = `${PREFISSO}-passato`;
const PREC = `${PREFISSO}-prec`;
const MAIN = `${PREFISSO}-main`;
const EV6 = `${PREFISSO}-ev6`;
const EV12 = `${PREFISSO}-ev12`;
const LONTANO = `${PREFISSO}-lontano`;
const GENERICO = `${PREFISSO}-generico`;
const COPPIA_A = `${PREFISSO}-coppia-a`;
const COPPIA_B = `${PREFISSO}-coppia-b`;
const ORA_ROTTA = `${PREFISSO}-ora-rotta`;
// Solleciti: eventi extra-campo, così non entrano nell'ordine dei palloni.
const S24 = `${PREFISSO}-s24`;
const S12 = `${PREFISSO}-s12`;
const S6 = `${PREFISSO}-s6`;
const S_TARDI = `${PREFISSO}-s-tardi`;
const S_FORSE = `${PREFISSO}-s-forse`;
const S_FUORI = `${PREFISSO}-s-fuori`;
const TUTTI = [
PASSATO,
PREC,
MAIN,
EV6,
EV12,
LONTANO,
GENERICO,
COPPIA_A,
COPPIA_B,
ORA_ROTTA,
S24,
S12,
S6,
S_TARDI,
S_FORSE,
S_FUORI,
];
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function leggi<T>(percorso: string): Promise<T[]> {
const res = await rest(percorso, SERVIZIO);
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
return (await res.json()) as T[];
}
async function scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
const res = await rest(percorso, token, {
method: metodo,
body: corpo === undefined ? null : JSON.stringify(corpo),
});
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
}
async function rpc(nome: string, corpo: unknown = {}, token = SERVIZIO) {
return fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
method: "POST",
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(corpo),
});
}
async function esegui(nome: string, corpo: unknown = {}) {
const res = await rpc(nome, corpo);
if (!res.ok) throw new Error(`${nome}: ${res.status} ${await res.text()}`);
}
async function creaUtente(email: string): Promise<{ id: string; token: string }> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
/** Data e ora dell'inizio tra `ore` ore, nel fuso Europe/Rome (convenzione di `eventi_app`). */
function tra(ore: number): { data: string; ora: string } {
const quando = new Date(Date.now() + ore * 60 * 60 * 1000);
const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando);
const ora = new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(quando);
return { data, ora };
}
const gg = (iso: string) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
const notifiche = (evento: string, tipo?: string) =>
leggi<Riga>(
`notifiche_utente?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}` +
`&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id,tipo`,
);
const destinatari = async (evento: string, tipo: string) =>
(await notifiche(evento, tipo)).map((r) => r.giocatore_id).sort();
const registro = (evento: string, tipo?: string) =>
leggi<{ giocatore_id: string; tipo: string }>(
`promemoria_eventi_generati?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}` +
`&select=giocatore_id,tipo&order=giocatore_id`,
);
const inCodaEmail = async (ids: string[]) =>
ids.length === 0
? []
: leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
const inCodaPush = async (ids: string[]) =>
ids.length === 0
? []
: leggi<{ notifica_id: string; stato: string }>(
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id,stato`,
);
type RigaEvento = {
id: string;
tipo: Evento["tipo"];
titolo: string;
luogo: string;
data: string;
ora: string;
convocati: string[];
};
/** Gli eventi del database come li vedrebbe il codice dell'app (solo i campi che servono). */
const eventiDelDatabase = async (): Promise<Evento[]> =>
(await leggi<RigaEvento>("eventi_app?select=id,tipo,titolo,luogo,data,ora,convocati")).map(
(e) => ({ ...e, note: "", campionato: false, casa: true, pagelleChiuse: false }),
);
const turniDelDatabase = async (): Promise<Record<string, string>> =>
Object.fromEntries(
(await leggi<{ evento_id: string; giocatore_id: string }>("turni_palloni?select=*")).map(
(t) => [t.evento_id, t.giocatore_id],
),
);
const creaEvento = (
id: string,
tipo: string,
quando: { data: string; ora: string },
extra: Record<string, unknown> = {},
) =>
scrivi("eventi_app", "POST", {
id,
tipo,
titolo: `${PREFISSO} ${id.slice(PREFISSO.length + 1)}`,
luogo: "PalaCRAP",
...quando,
convocati: [],
...extra,
});
const spostaEvento = (id: string, ore: number) =>
scrivi(`eventi_app?id=eq.${id}`, "PATCH", tra(ore));
const assegna = (evento: string, giocatore: string) =>
scrivi("turni_palloni", "POST", { evento_id: evento, giocatore_id: giocatore });
const cambiaTurno = (evento: string, giocatore: string) =>
scrivi(`turni_palloni?evento_id=eq.${evento}`, "PATCH", { giocatore_id: giocatore });
const rispondi = (evento: string, giocatore: string, stato: string) =>
scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", {
evento_id: evento,
giocatore_id: giocatore,
stato,
}).catch(async () =>
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", {
stato,
}),
);
const idUtenti: string[] = [];
let tokenAdmin = "";
const originali = new Map<string, Record<string, unknown>>();
try {
// --- Preparazione: un admin per toccare gli slot, un allenatore e un giocatore inattivo ---
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
const allenatore = await creaUtente(`${PREFISSO}-allenatore-${Date.now()}@example.test`);
idUtenti.push(admin.id, allenatore.id);
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [ALLENATORE, INATTIVO]) {
const [riga] = await leggi<Record<string, unknown>>(
`giocatori_squadra?id=eq.${id}&select=tipo,attivo,auth_user_id`,
);
originali.set(id, riga ?? { tipo: "giocatore", attivo: true, auth_user_id: null });
}
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
await scrivi(
`giocatori_squadra?id=eq.${ALLENATORE}`,
"PATCH",
{ tipo: "allenatore", auth_user_id: allenatore.id },
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.
await creaEvento(PASSATO, "partita", tra(-60));
await creaEvento(PREC, "allenamento", tra(-30));
await creaEvento(MAIN, "allenamento", tra(2.5));
await creaEvento(EV6, "partita", tra(4.5));
await creaEvento(EV12, "allenamento", tra(9));
await creaEvento(LONTANO, "allenamento", tra(40));
await creaEvento(GENERICO, "evento", tra(2.2));
await assegna(PREC, G5);
await assegna(MAIN, G4);
await assegna(EV6, G7);
await assegna(EV12, G8);
await assegna(GENERICO, G4); // extra-campo: il turno non conta
// --- Turno palloni: finestre e destinatari ---------------------------------------------
await prova("ogni evento riceve l'avviso della sua fascia e di nessun'altra", async () => {
await esegui("genera_avvisi_palloni");
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4, G5]);
assert.deepEqual(await destinatari(EV6, "turno_palloni_6h"), [G4, G7]);
assert.deepEqual(await destinatari(EV12, "turno_palloni_12h"), [G7, G8]);
for (const [evento, altri] of [
[MAIN, ["turno_palloni_6h", "turno_palloni_12h"]],
[EV6, ["turno_palloni_3h", "turno_palloni_12h"]],
[EV12, ["turno_palloni_3h", "turno_palloni_6h"]],
] as const) {
for (const tipo of altri) {
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
}
}
});
await prova(
"nessun avviso per eventi lontani, passati, extra-campo o senza incaricato",
async () => {
assert.equal((await notifiche(LONTANO)).length, 0, "tra 40 ore: fuori da ogni fascia");
assert.equal((await notifiche(PREC)).length, 0, "già iniziato");
assert.equal((await notifiche(GENERICO)).length, 0, "un evento extra-campo non ha palloni");
assert.equal((await registro(GENERICO)).length, 0);
},
);
await prova(
"il testo è quello di «incarico assegnato» e «riconsegna», identico al codice",
async () => {
const eventi = await eventiDelDatabase();
const turni = await turniDelDatabase();
for (const [evento, tipo] of [
[MAIN, "turno_palloni_3h"],
[EV6, "turno_palloni_6h"],
[EV12, "turno_palloni_12h"],
] as const) {
const attesi = avvisiPalloniEvento(turni, eventi, evento);
assert.equal(attesi.length, 2, evento);
const righe = await notifiche(evento, tipo);
for (const atteso of attesi) {
const r = righe.find((x) => x.giocatore_id === atteso.giocatoreId);
assert.ok(r, `${evento}: manca l'avviso a ${atteso.giocatoreId}`);
assert.equal(r.titolo, atteso.titolo, `${evento} titolo`);
assert.equal(r.corpo, atteso.testo, `${evento} corpo`);
}
}
const [incarico] = (await notifiche(MAIN, "turno_palloni_3h")).filter(
(r) => r.giocatore_id === G4,
);
const eventiEv6 = eventi.find((e) => e.id === EV6)!;
const main = eventi.find((e) => e.id === MAIN)!;
assert.equal(
incarico?.corpo,
[
`Evento: ${PREFISSO} main`,
`Data: ${gg(main.data)}, ore ${main.ora}`,
"Incarico: custodia dei palloni al termine dell'evento",
`Riconsegna: ${gg(eventiEv6.data)}`,
].join("\n"),
"la riconsegna è la data dell'evento successivo",
);
},
);
await prova("registro e canali: push in coda, nessuna email", async () => {
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2);
const righe = [
...(await notifiche(MAIN, "turno_palloni_3h")),
...(await notifiche(EV6, "turno_palloni_6h")),
...(await notifiche(EV12, "turno_palloni_12h")),
];
const ids = righe.map((r) => r.id);
assert.equal(ids.length, 6);
assert.equal((await inCodaPush(ids)).length, 6, "la push la manda il worker");
assert.equal((await inCodaEmail(ids)).length, 0, "i palloni non mandano email (DD-040)");
});
await prova("un secondo giro non duplica nulla; eliminata, l'avviso non torna", async () => {
const prima = await notifiche(MAIN, "turno_palloni_3h");
await esegui("genera_avvisi_palloni");
assert.deepEqual(
(await notifiche(MAIN, "turno_palloni_3h")).map((r) => r.id),
prima.map((r) => r.id),
);
const mia = prima.find((r) => r.giocatore_id === G5)!;
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
await esegui("genera_avvisi_palloni");
await esegui("genera_avvisi_palloni");
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4], "g5 non la riavrà");
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2, "il registro la ricorda");
});
await prova(
"un incaricato non più attivo o un evento con ora illeggibile non avvisano",
async () => {
await creaEvento(ORA_ROTTA, "allenamento", { data: tra(24 * 60).data, ora: "boh" });
await assegna(ORA_ROTTA, G4);
await esegui("genera_avvisi_palloni");
assert.equal(
(await notifiche(ORA_ROTTA)).length,
0,
"ora non valida: escluso, senza errori",
);
await scrivi(`turni_palloni?evento_id=eq.${EV12}`, "PATCH", { giocatore_id: INATTIVO });
await esegui("genera_avvisi_palloni");
assert.ok(
!(await destinatari(EV12, "turno_palloni_12h")).includes(INATTIVO),
"chi non è più attivo non riceve nulla",
);
await cambiaTurno(EV12, G8);
},
);
// --- Revoca dell'incarico ---------------------------------------------------------------
await prova(
"incaricato cambiato: il nuovo riceve l'avviso, il vecchio una sola revoca",
async () => {
await cambiaTurno(MAIN, G6);
await esegui("genera_avvisi_palloni");
assert.deepEqual(
await destinatari(MAIN, "turno_palloni_3h"),
[G4, G6],
"g4 aveva già il suo; g6 è nuovo; g5 aveva eliminato il proprio e non lo riavrà",
);
const revoche = await notifiche(MAIN, "turno_palloni_revocato");
assert.deepEqual(
revoche.map((r) => r.giocatore_id),
[G4],
"solo chi aveva l'incarico e non ce l'ha più; g5 è ancora il turno precedente",
);
assert.equal(revoche[0]!.titolo, "Turno palloni: incarico revocato");
const main = (await eventiDelDatabase()).find((e) => e.id === MAIN)!;
assert.equal(
revoche[0]!.corpo,
[
`Evento: ${PREFISSO} main`,
`Data: ${gg(main.data)}, ore ${main.ora}`,
"Incarico: non più a tuo carico per questo evento",
].join("\n"),
);
await esegui("genera_avvisi_palloni");
assert.equal(
(await notifiche(MAIN, "turno_palloni_revocato")).length,
1,
"la revoca parte una volta sola",
);
},
);
await prova("la revoca ha push in coda e nessuna email", async () => {
const [revoca] = await notifiche(MAIN, "turno_palloni_revocato");
assert.equal((await inCodaPush([revoca!.id])).length, 1);
assert.equal((await inCodaEmail([revoca!.id])).length, 0);
});
await prova("riassegnato al vecchio incaricato: nessun secondo avviso", async () => {
await cambiaTurno(MAIN, G4);
await esegui("genera_avvisi_palloni");
assert.deepEqual(
(await notifiche(MAIN)).filter((r) => r.giocatore_id === G4).map((r) => r.tipo),
["turno_palloni_3h", "turno_palloni_revocato"],
"il registro ha già generato l'avviso di quella fascia: resta la revoca come ultima",
);
await cambiaTurno(MAIN, G4);
});
await prova(
"turno tolto: chi era stato avvisato, incaricato o riconsegna, viene revocato",
async () => {
// La notifica dell'avviso può essere stata eliminata: conta il registro.
const [avvisoG7] = (await notifiche(EV6, "turno_palloni_6h")).filter(
(r) => r.giocatore_id === G7,
);
await scrivi(`notifiche_utente?id=eq.${avvisoG7!.id}`, "DELETE");
await scrivi(`turni_palloni?evento_id=eq.${EV6}`, "DELETE");
await esegui("genera_avvisi_palloni");
const revocati = await destinatari(EV6, "turno_palloni_revocato");
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");
// Senza incaricato non parte nulla: nemmeno un nuovo avviso di fascia per nessuno.
assert.equal((await notifiche(EV6, "turno_palloni_3h")).length, 0);
await assegna(EV6, G7);
},
);
await prova("gli avvisi del pulsante (`turno_palloni`) non provocano revoche", async () => {
await scrivi("notifiche_utente", "POST", {
giocatore_id: G8,
tipo: "turno_palloni",
titolo: "manuale",
corpo: "proposta non confermata",
evento_id: MAIN,
});
await esegui("genera_avvisi_palloni");
assert.ok(
!(await destinatari(MAIN, "turno_palloni_revocato")).includes(G8),
"la proposta del pulsante può non essere un turno confermato",
);
});
await prova("un evento già iniziato non genera la revoca", async () => {
await scrivi("promemoria_eventi_generati", "POST", {
giocatore_id: G4,
evento_id: PREC,
tipo: "turno_palloni_3h",
});
await esegui("genera_avvisi_palloni");
assert.equal((await notifiche(PREC, "turno_palloni_revocato")).length, 0);
});
// --- Stesso ordine del codice: precedente e successivo ---------------------------------
await prova("precedente e successivo seguono lo stesso ordine di palloni-core.ts", async () => {
const ora = "20:00";
const data = tra(24 * 8).data;
await creaEvento(COPPIA_B, "partita", { data, ora });
await creaEvento(COPPIA_A, "allenamento", { data, ora });
await assegna(COPPIA_A, G4);
await assegna(COPPIA_B, G5);
const eventi = await eventiDelDatabase();
const turni = await turniDelDatabase();
assert.equal(
eventoPrecedente(eventi, COPPIA_B)?.id,
COPPIA_A,
"a parità di data e ora: per id",
);
assert.equal(eventoSuccessivo(eventi, COPPIA_A)?.id, COPPIA_B);
// Solo i due eventi della coppia cadono nella fascia: 8 giorni ± un giorno.
await esegui("genera_avvisi_palloni_fascia", {
p_tipo: "turno_palloni_12h",
p_da: `${24 * 7} hours`,
p_a: `${24 * 9} hours`,
});
for (const evento of [COPPIA_A, COPPIA_B]) {
const righe = await notifiche(evento, "turno_palloni_12h");
const attesi = avvisiPalloniEvento(turni, eventi, evento);
assert.equal(righe.length, attesi.length, evento);
for (const a of attesi) {
const r = righe.find((x) => x.giocatore_id === a.giocatoreId);
assert.ok(r, `${evento}: ${a.giocatoreId}`);
assert.equal(r.titolo, a.titolo);
assert.equal(r.corpo, a.testo);
}
}
});
// --- Solleciti presenze -----------------------------------------------------------------
await creaEvento(S24, "evento", tra(18), { convocati: [G4, G5, G7, INATTIVO, ALLENATORE] });
await creaEvento(S12, "evento", tra(9));
await creaEvento(S6, "evento", tra(4));
await creaEvento(S_FUORI, "evento", tra(30));
await rispondi(S24, G7, "presente");
await rispondi(S24, G5, "forse");
await rispondi(S12, G4, "assente");
await rispondi(S12, G5, "forse");
await rispondi(S6, G4, "ritardo");
const tuttiGiocatoriAttivi = async () =>
(await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&tipo=eq.giocatore&select=id"))
.map((g) => g.id)
.sort();
await prova(
"ogni evento riceve il sollecito della sua fascia, a chi deve rispondere",
async () => {
await esegui("genera_solleciti_presenze");
assert.deepEqual(
await destinatari(S24, "sollecita_presenze_24h"),
[G4, G5],
"convocati senza risposta (g4) o «forse» (g5): g7 ha risposto, inattivo e allenatore esclusi",
);
const senzaRisposta = (await tuttiGiocatoriAttivi()).filter((g) => g !== G4);
assert.deepEqual(
await destinatari(S12, "sollecita_presenze_12h"),
senzaRisposta,
"convocati vuoto: tutta la rosa attiva, meno chi ha risposto; g5 («forse») resta",
);
assert.deepEqual(
await destinatari(S6, "sollecita_presenze_6h"),
(await tuttiGiocatoriAttivi()).filter((g) => g !== G4),
);
assert.equal((await notifiche(S_FUORI)).length, 0, "tra 30 ore: fuori da ogni fascia");
for (const [evento, altri] of [
[S24, ["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}`);
}
}
},
);
await prova("mai all'allenatore né a chi non è attivo", async () => {
for (const evento of [S24, S12, S6]) {
const tutti = (await notifiche(evento)).map((r) => r.giocatore_id);
assert.ok(!tutti.includes(ALLENATORE), `${evento}: allenatore`);
assert.ok(!tutti.includes(INATTIVO), `${evento}: inattivo`);
}
});
await prova("il testo è quello del sollecito, identico al codice", async () => {
const eventi = await eventiDelDatabase();
const squadra = await leggi<{
id: string;
attivo: boolean;
tipo: "giocatore" | "allenatore";
}>("giocatori_squadra?select=id,attivo,tipo");
for (const [id, tipo] of [
[S24, "sollecita_presenze_24h"],
[S12, "sollecita_presenze_12h"],
[S6, "sollecita_presenze_6h"],
] as const) {
const evento = eventi.find((e) => e.id === id)!;
const risposte = await leggi<{ giocatore_id: string; stato: string }>(
`risposte_presenze?evento_id=eq.${id}&select=giocatore_id,stato`,
);
const attesi = destinatariSollecito(squadra, risposte, evento.convocati);
assert.deepEqual(await destinatari(id, tipo), [...attesi].sort(), `${id}: destinatari`);
for (const r of await notifiche(id, tipo)) {
const { titolo, testo } = testoSollecito(
evento,
risposte.find((x) => x.giocatore_id === r.giocatore_id)?.stato,
);
assert.equal(r.titolo, titolo, `${id} titolo`);
assert.equal(r.corpo, testo, `${id} corpo di ${r.giocatore_id}`);
}
}
const [g5] = (await notifiche(S24, "sollecita_presenze_24h")).filter(
(r) => r.giocatore_id === G5,
);
assert.match(g5!.corpo, /Risposta attuale: forse/);
assert.ok(!g5!.corpo.includes("Richiesta di"), "l'automatico non ha un mittente");
});
await prova("il sollecito ha push in coda ed email", async () => {
const ids = (await notifiche(S24, "sollecita_presenze_24h")).map((r) => r.id);
assert.equal((await inCodaPush(ids)).length, ids.length);
assert.equal((await inCodaEmail(ids)).length, ids.length);
});
await prova("un secondo giro non duplica; eliminato, il sollecito non torna", async () => {
const prima = await notifiche(S24, "sollecita_presenze_24h");
await esegui("genera_solleciti_presenze");
assert.equal((await notifiche(S24, "sollecita_presenze_24h")).length, prima.length);
await scrivi(`notifiche_utente?id=eq.${prima[0]!.id}`, "DELETE");
await esegui("genera_solleciti_presenze");
assert.equal((await notifiche(S24, "sollecita_presenze_24h")).length, prima.length - 1);
});
await prova(
"chi risponde smette di riceverli, chi resta su «forse» li riceve tutti e tre",
async () => {
await creaEvento(S_FORSE, "evento", tra(18), { convocati: [G4, G5, G8] });
await rispondi(S_FORSE, G5, "forse");
await esegui("genera_solleciti_presenze");
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_24h"), [G4, G5, G8]);
await rispondi(S_FORSE, G4, "presente"); // risponde dopo il primo sollecito
await spostaEvento(S_FORSE, 9); // entra nella fascia delle 12 ore
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 esegui("genera_solleciti_presenze");
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_6h"), [G5]);
assert.equal(
(await notifiche(S_FORSE)).filter((r) => r.giocatore_id === G5).length,
3,
"g5, sempre «forse»: 24, 12 e 6 ore",
);
},
);
await prova("un evento creato tardi riceve solo il sollecito della sua fascia", async () => {
await creaEvento(S_TARDI, "evento", tra(5), { convocati: [G4] });
await esegui("genera_solleciti_presenze");
assert.deepEqual(
(await notifiche(S_TARDI)).map((r) => r.tipo),
["sollecita_presenze_6h"],
"non tre solleciti insieme",
);
});
await prova("il sollecito del pulsante e quello automatico convivono", async () => {
await scrivi("notifiche_utente", "POST", {
giocatore_id: G5,
tipo: "sollecita_presenze",
titolo: "manuale",
corpo: "manuale",
evento_id: S24,
});
assert.deepEqual(
(await notifiche(S24)).filter((r) => r.giocatore_id === G5).map((r) => r.tipo),
["sollecita_presenze", "sollecita_presenze_24h"],
);
});
// --- Schema, permessi, pianificazione ---------------------------------------------------
await prova("le funzioni dei job sono riservate alla service role", async () => {
for (const nome of [
"genera_avvisi_palloni",
"genera_solleciti_presenze",
"genera_revoche_palloni",
]) {
assert.ok(!(await rpc(nome, {}, ANON)).ok, `${nome}: anon`);
assert.ok(!(await rpc(nome, {}, admin.token)).ok, `${nome}: utente autenticato`);
}
for (const nome of ["genera_avvisi_palloni_fascia", "genera_solleciti_presenze_fascia"]) {
const corpo = { p_tipo: "turno_palloni_3h", p_da: "0", p_a: "1 hour" };
assert.ok(!(await rpc(nome, corpo, ANON)).ok, `${nome}: anon`);
assert.ok(!(await rpc(nome, corpo, admin.token)).ok, `${nome}: utente autenticato`);
}
});
await prova("il registro accetta solo i tipi automatici", async () => {
const res = await rest("promemoria_eventi_generati", SERVIZIO, {
method: "POST",
body: JSON.stringify({ giocatore_id: G4, evento_id: MAIN, tipo: "turno_palloni" }),
});
assert.ok(!res.ok, "`turno_palloni` è il tipo del pulsante: non entra nel registro");
});
await prova("ogni tipo nuovo è accettato da notifiche_utente", async () => {
for (const tipo of [
"turno_palloni_12h",
"turno_palloni_6h",
"turno_palloni_3h",
"turno_palloni_revocato",
"sollecita_presenze_24h",
"sollecita_presenze_12h",
"sollecita_presenze_6h",
]) {
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
body: JSON.stringify({
giocatore_id: G4,
tipo,
titolo: "x",
corpo: "y",
evento_id: LONTANO,
}),
});
assert.ok(res.ok, `${tipo}: ${res.status}`);
}
});
await prova("i due job sono pianificati ogni 15 minuti", 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",
"-F",
"|",
"-c",
"select jobname, schedule, command from cron.job where jobname in " +
"('avvisi-palloni-automatici','solleciti-presenze-automatici') order by jobname",
],
{ encoding: "utf8" },
);
assert.equal(esito.status, 0, esito.stderr);
const righe = esito.stdout.trim().split("\n");
assert.equal(righe.length, 2);
assert.match(
righe[0]!,
/^avvisi-palloni-automatici\|\*\/15 \* \* \* \*\|.*genera_avvisi_palloni\(\)/,
);
assert.match(
righe[1]!,
/^solleciti-presenze-automatici\|\*\/15 \* \* \* \*\|.*genera_solleciti_presenze\(\)/,
);
});
await prova("cancellare l'evento toglie registro e notifiche collegate", async () => {
assert.ok((await registro(MAIN)).length > 0);
await scrivi(`eventi_app?id=eq.${MAIN}`, "DELETE");
assert.equal((await registro(MAIN)).length, 0);
assert.equal((await notifiche(MAIN)).length, 0);
});
} finally {
for (const id of TUTTI) {
await rest(`notifiche_utente?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await rest(`promemoria_eventi_generati?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await rest(`turni_palloni?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await rest(`risposte_presenze?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
if (tokenAdmin) {
for (const [id, valori] of originali) {
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify(valori),
});
}
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("notifiche-automatiche");
}
}
+59 -14
View File
@@ -217,20 +217,39 @@ if (!locale) {
assert.equal(riga?.inviata_il, null);
});
await prova("ogni tipo di notifica esistente viene accodato", async () => {
for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) {
await prova("ogni tipo di notifica con email viene accodato", async () => {
for (const tipo of [
"evento_promemoria_24h",
"evento_promemoria_3h",
"sollecita_presenze",
"sollecita_presenze_24h",
"sollecita_presenze_12h",
"sollecita_presenze_6h",
"sondaggio_cacche",
]) {
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
creati.push(id);
assert.equal((await coda(id))?.stato, "in_coda", tipo);
}
const id = await creaNotifica(SLOT_COLLEGATO, {
tipo: "sollecita_presenze",
evento_id: EVENTO_TIPI,
});
creati.push(id);
assert.equal((await coda(id))?.stato, "in_coda", "sollecita_presenze");
});
await prova(
"il turno palloni non genera email, in nessuna delle sue forme (DD-040)",
async () => {
for (const tipo of [
"turno_palloni",
"turno_palloni_12h",
"turno_palloni_6h",
"turno_palloni_3h",
"turno_palloni_revocato",
]) {
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
creati.push(id);
assert.equal(await coda(id), undefined, `${tipo}: nessuna riga nella coda email`);
}
},
);
await prova("segnare come letta non rimette in coda una mail già inviata", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
@@ -243,7 +262,7 @@ if (!locale) {
await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => {
const id = await creaNotifica(SLOT_COLLEGATO, {
tipo: "turno_palloni",
tipo: "sollecita_presenze",
evento_id: EVENTO_UPSERT,
});
creati.push(id);
@@ -251,7 +270,36 @@ if (!locale) {
await esito(id, "inviata");
assert.equal((await coda(id))?.stato, "inviata");
// Come `promemoria-palloni.ts` e `sollecita-presenze.ts`: stesso (giocatore, evento, tipo).
// Come `sollecita-presenze.ts`: stesso (giocatore, evento, tipo).
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
body: JSON.stringify({
giocatore_id: SLOT_COLLEGATO,
tipo: "sollecita_presenze",
titolo: `${PREFISSO} aggiornato`,
corpo: "nuovo testo",
evento_id: EVENTO_UPSERT,
letta: false,
creato_il: new Date().toISOString(),
}),
});
assert.ok(res.ok, `upsert: ${res.status}`);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
assert.equal(riga?.inviata_il, null);
});
await prova("il rinvio con upsert del turno palloni non accoda nessuna mail", async () => {
const id = await creaNotifica(SLOT_COLLEGATO, {
tipo: "turno_palloni",
evento_id: EVENTO_UPSERT,
});
creati.push(id);
assert.equal(await coda(id), undefined);
// Come `promemoria-palloni.ts`: stesso (giocatore, evento, tipo), `creato_il` riscritto.
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
@@ -266,10 +314,7 @@ if (!locale) {
}),
});
assert.ok(res.ok, `upsert: ${res.status}`);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
assert.equal(riga?.inviata_il, null);
assert.equal(await coda(id), undefined, "nemmeno dopo il rinvio");
});
await prova(
+104 -1
View File
@@ -27,6 +27,9 @@ if (!locale) {
const EVENTO = `${PREFISSO}-a`;
const EVENTO_LONTANO = `${PREFISSO}-lontano`;
const EVENTO_CASCATA = `${PREFISSO}-cascata`;
const EVENTO_DOMANI = `${PREFISSO}-domani`;
const EVENTO_OGGI = `${PREFISSO}-oggi`;
const EVENTO_SENZA_LUOGO = `${PREFISSO}-senza-luogo`;
const G4 = "g4";
const G5 = "g5";
const G6 = "g6";
@@ -201,6 +204,99 @@ if (!locale) {
assert.ok(!inserimento.ok, "anon non può scrivere nel registro");
});
// --- Testo (DD-040) ------------------------------------------------------------------
// «domani» se la data dell'evento è quella di domani a Roma, «oggi» altrimenti. Per non
// dipendere dall'ora in cui gira il test: un evento domani a mezzanotte cade sempre nella
// finestra delle 24 ore, uno oggi alle 23:59 pure.
const oggiRoma = tra(0).data;
const domaniRoma = new Date(new Date(`${oggiRoma}T12:00:00Z`).getTime() + 24 * 3600 * 1000)
.toISOString()
.slice(0, 10);
const gg = (iso: string) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
const testoDi = async (evento: string, tipo: string) => {
const [n] = await leggi<{ titolo: string; corpo: string }>(
`notifiche_utente?evento_id=eq.${evento}&tipo=eq.${tipo}&giocatore_id=eq.${G4}&select=titolo,corpo`,
);
assert.ok(n, `promemoria ${tipo} di ${evento} non generato`);
return n;
};
await prova("evento di domani: titolo e data dicono «domani», con ora e luogo", async () => {
await scrivi("eventi_app", "POST", {
id: EVENTO_DOMANI,
tipo: "partita",
titolo: `${PREFISSO} cena`,
data: domaniRoma,
ora: "00:00",
luogo: " PalaCRAP ",
convocati: [G4],
});
await cron("evento_promemoria_24h");
const n = await testoDi(EVENTO_DOMANI, "evento_promemoria_24h");
assert.equal(n.titolo, `Promemoria evento di domani: ${PREFISSO} cena`);
assert.equal(
n.corpo,
`Data: domani, ${gg(domaniRoma)}\nOra: 00:00\nLuogo: PalaCRAP`,
"il luogo è ripulito dagli spazi",
);
});
await prova("evento di oggi: titolo e data dicono «oggi»", async () => {
await scrivi("eventi_app", "POST", {
id: EVENTO_OGGI,
tipo: "allenamento",
titolo: `${PREFISSO} oggi`,
data: oggiRoma,
ora: "23:59",
luogo: "Palestra",
convocati: [G4],
});
await cron("evento_promemoria_24h");
const n = await testoDi(EVENTO_OGGI, "evento_promemoria_24h");
assert.equal(n.titolo, `Promemoria evento di oggi: ${PREFISSO} oggi`);
assert.equal(n.corpo, `Data: oggi, ${gg(oggiRoma)}\nOra: 23:59\nLuogo: Palestra`);
});
await prova("lo stesso testo vale per il promemoria a 3 ore", async () => {
const { data, ora } = tra(2);
await scrivi("eventi_app", "POST", {
id: EVENTO_SENZA_LUOGO,
tipo: "evento",
titolo: `${PREFISSO} senza luogo`,
data,
ora,
convocati: [G4],
});
await cron("evento_promemoria_3h");
const n = await testoDi(EVENTO_SENZA_LUOGO, "evento_promemoria_3h");
const parola = data === domaniRoma ? "domani" : "oggi";
assert.equal(
n.titolo,
`Promemoria evento di ${parola}: ${PREFISSO} senza luogo`,
"anche il promemoria a 3 ore di un evento dopo mezzanotte dice «domani»",
);
assert.equal(
n.corpo,
`Data: ${parola}, ${gg(data)}\nOra: ${ora}`,
"senza luogo la riga «Luogo» manca",
);
});
await prova(
"il promemoria nuovo parte come push e come email, con lo stesso testo",
async () => {
const [n] = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTO_DOMANI}&tipo=eq.evento_promemoria_24h&giocatore_id=eq.${G4}&select=id`,
);
assert.ok(n);
assert.equal((await inCoda([n.id])).length, 1, "email in coda");
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=eq.${n.id}&select=notifica_id`,
);
assert.equal(push.length, 1, "push in coda");
},
);
await prova("cancellare l'evento toglie anche il registro", async () => {
const { data, ora } = { data: tra(2).data, ora: tra(2).ora };
await scrivi("eventi_app", "POST", {
@@ -217,7 +313,14 @@ if (!locale) {
assert.equal((await registro(EVENTO_CASCATA)).length, 0);
});
} finally {
for (const id of [EVENTO, EVENTO_LONTANO, EVENTO_CASCATA]) {
for (const id of [
EVENTO,
EVENTO_LONTANO,
EVENTO_CASCATA,
EVENTO_DOMANI,
EVENTO_OGGI,
EVENTO_SENZA_LUOGO,
]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
riepilogo("promemoria-eventi");
+25
View File
@@ -217,6 +217,31 @@ if (!locale) {
}
});
await prova(
"le notifiche automatiche di palloni e solleciti (DD-040) vanno in coda push; i pulsanti no",
async () => {
const automatiche = [
"turno_palloni_12h",
"turno_palloni_6h",
"turno_palloni_3h",
"turno_palloni_revocato",
"sollecita_presenze_24h",
"sollecita_presenze_12h",
"sollecita_presenze_6h",
];
for (const tipo of automatiche) {
const riga = await coda(await creaNotifica(G4, tipo, EVENTI[2]));
assert.equal(riga?.stato, "in_coda", tipo);
assert.equal(riga?.tentativi, 0, tipo);
}
// `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti: la push la manda
// la route, e una seconda dal worker sarebbe un doppione.
for (const tipo of ["turno_palloni", "sollecita_presenze"]) {
assert.equal(await coda(await creaNotifica(G4, tipo, EVENTI[2])), undefined, tipo);
}
},
);
await prova(
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
async () => {
+10 -1
View File
@@ -1,6 +1,6 @@
/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */
import assert from "node:assert/strict";
import { formatData, giocatori, statoMeta } from "@/lib/crapp-data";
import { formatData, formatDataNumerica, giocatori, statoMeta } from "@/lib/crapp-data";
// --- rosa --------------------------------------------------------------------
assert.ok(giocatori.length > 0, "la rosa non è vuota");
@@ -29,6 +29,15 @@ for (const g of giocatori) {
// --- formatData --------------------------------------------------------------
assert.equal(formatData("2026-09-01"), "mar 01 settembre");
// --- formatDataNumerica: la data dei testi delle notifiche, come to_char(…, 'DD/MM/YYYY') ----
assert.equal(formatDataNumerica("2026-09-01"), "01/09/2026");
assert.equal(formatDataNumerica("2026-12-31"), "31/12/2026");
assert.equal(
formatDataNumerica("2026-03-29"),
"29/03/2026",
"nessuna conversione di fuso: il giorno del cambio ora resta quello scritto",
);
assert.equal(formatData("2026-01-31"), "sab 31 gennaio");
assert.ok(
!formatData("2026-03-29").includes("28"),
+18
View File
@@ -197,6 +197,24 @@ await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () =>
assert.equal(escapeHtml(`<&>"'`), "&lt;&amp;&gt;&quot;&#39;");
});
await prova(
"il corpo a righe etichettate delle notifiche automatiche (DD-040) resta leggibile",
() => {
const corpo = "Data: domani, 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
const m = componiMail(
notifica({
oggetto: "Promemoria evento di domani: Allenamento",
testo: corpo,
id_evento: "ev-1",
}),
OPZ,
);
assert.equal(m.subject, "Promemoria evento di domani: Allenamento");
assert.ok(m.text.startsWith(`${corpo}\n\nApri CrAPP: `), "in testo semplice le righe restano");
assert.match(m.html, /Data: domani, 01\/10\/2026<br>Ora: 21:00<br>Luogo: PalaCRAP/);
},
);
await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => {
const m = componiMail(notifica({ oggetto: "<b>Ciao</b>" }), OPZ);
assert.ok(!m.html.includes("<b>"));
+31
View File
@@ -1,10 +1,12 @@
/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
contatoreBadge,
daRiga,
pallinoNotifiche,
type RigaNotifica,
type TipoNotifica,
} from "@/lib/notifiche-utente";
// --- daRiga: conversione database -> modello applicativo ---------------------
@@ -48,4 +50,33 @@ assert.deepEqual(pallinoNotifiche(30, 12), { testo: "9+", daLeggere: true });
// Tutte lette: il pallino resta per poterle ancora aprire ed eliminare.
assert.deepEqual(pallinoNotifiche(3, 0), { testo: "3", daLeggere: false });
// --- i tipi automatici di DD-040 attraversano daRiga senza perdere le righe del testo -----
const tipiNuovi: TipoNotifica[] = [
"turno_palloni_12h",
"turno_palloni_6h",
"turno_palloni_3h",
"turno_palloni_revocato",
"sollecita_presenze_24h",
"sollecita_presenze_12h",
"sollecita_presenze_6h",
];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) {
const n = daRiga({ ...riga, tipo, corpo: corpoMultiriga });
assert.equal(n.tipo, tipo);
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
}
// Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
const uiBits = readFileSync(
new URL("../../src/components/crapp/ui-bits.tsx", import.meta.url),
"utf8",
);
assert.match(
uiBits,
/whitespace-pre-line[^"]*"[^>]*>\s*\{notifica\.corpo\}/,
"il corpo della notifica mantiene gli a capo",
);
console.log("notifiche-utente: ok");
+49 -3
View File
@@ -179,20 +179,46 @@ assert.deepEqual(
);
// --- avvisiPalloniEvento ------------------------------------------------------
// Il promemoria per un evento scelto dall'admin: due destinatari, con testi diversi.
// L'avviso per un evento scelto: due destinatari, con testi diversi (DD-040). Gli stessi testi dei
// job automatici del database: `test/integration/notifiche-automatiche.test.ts` li confronta.
const avvisi = avvisiPalloniEvento(turniPush, eventiPush, "p2");
assert.deepEqual(
avvisi.map((a) => a.giocatoreId),
["g2", "g1"],
"l'incaricato di questo evento, e chi ha i palloni dalla volta prima",
);
assert.match(avvisi[0]!.titolo, /prendere i palloni/);
assert.match(avvisi[1]!.titolo, /Porta i palloni/);
assert.equal(avvisi[0]!.titolo, "Turno palloni: incarico assegnato");
assert.equal(avvisi[1]!.titolo, "Turno palloni: riconsegna");
assert.equal(
avvisi[0]!.testo,
[
"Evento: Evento p2",
"Data: 02/02/2026, ore 21:00",
"Incarico: custodia dei palloni al termine dell'evento",
"Riconsegna: 03/02/2026",
].join("\n"),
"incarico: evento, data numerica, incarico e data di riconsegna dell'evento successivo",
);
assert.equal(
avvisi[1]!.testo,
[
"Evento: Evento p2",
"Data: 02/02/2026, ore 21:00",
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
].join("\n"),
"riconsegna: nessuna riga «Riconsegna», chi la riceve li riporta",
);
assert.ok(
avvisi.every((a) => !/oggi/i.test(a.testo)),
"il testo nomina l'evento, non 'oggi': può arrivare giorni prima",
);
const ultimo = avvisiPalloniEvento(turniPush, eventiPush, "p3");
assert.ok(
ultimo.every((a) => !a.testo.includes("Riconsegna:")),
"l'ultimo evento non ha un successivo: la riga «Riconsegna» manca",
);
assert.deepEqual(
avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId),
["g1"],
@@ -213,6 +239,26 @@ assert.deepEqual(
[],
"senza turni assegnati non c'è nessuno da avvisare",
);
assert.deepEqual(
avvisiPalloniEvento({ p1: "g1" }, eventiPush, "p2"),
[],
"senza un incaricato per l'evento non parte nulla, nemmeno la riconsegna (DD-040)",
);
// --- ordine: a parità di data conta l'ora, poi l'id --------------------------
const stessoGiorno: Evento[] = [
{ ...evento("z", "2026-06-01"), ora: "21:00" },
{ ...evento("y", "2026-06-01"), ora: "18:30" },
{ ...evento("x", "2026-06-01"), ora: "21:00" },
evento("a", "2026-05-31"),
];
assert.deepEqual(
eventiPalloni(stessoGiorno).map((e) => e.id),
["a", "y", "x", "z"],
"data, poi ora, poi id: lo stesso ordine dei job del database",
);
assert.equal(eventoPrecedente(stessoGiorno, "x")?.id, "y");
assert.equal(eventoSuccessivo(stessoGiorno, "y")?.id, "x");
// --- serieConsecutivaPalloni: volte consecutive in cui li ha portati lui -----
const eventiSerie: Evento[] = [
+60
View File
@@ -9,6 +9,7 @@ import {
includeInfortunato,
serieConferme,
serieConsecutiva,
testoSollecito,
totaliEventiGiocatore,
type MappaPresenze,
type MappaTempiRisposta,
@@ -158,6 +159,65 @@ assert.deepEqual(
);
assert.deepEqual(destinatariSollecito([], risposte), [], "rosa vuota, nessun destinatario");
// --- il sollecito segue i convocati (DD-040) ----------------------------------
assert.deepEqual(
destinatariSollecito(squadra, risposte, ["g1", "g2", "g3"]),
["g2", "g3"],
"solo i convocati senza risposta o con «forse»: g1 ha risposto, g4 non è convocato",
);
assert.deepEqual(
destinatariSollecito(squadra, risposte, ["g4"]),
[],
"l'unico convocato ha risposto: nessun sollecito",
);
assert.deepEqual(
destinatariSollecito(squadra, [], ["g3", "g5", "g6", "g9"]),
["g3"],
"convocati inattivi, allenatori o fuori rosa restano esclusi anche se convocati",
);
assert.deepEqual(
destinatariSollecito(squadra, risposte, []),
["g2", "g3"],
"nessun convocato indicato vale per tutta la rosa, come nel database",
);
// --- testo del sollecito -----------------------------------------------------
const eventoSollecito = {
titolo: "Allenamento del giovedì",
data: "2026-10-01",
ora: "21:00",
luogo: "PalaCRAP",
};
const automatico = testoSollecito(eventoSollecito, undefined);
assert.equal(automatico.titolo, "Conferma di partecipazione richiesta: Allenamento del giovedì");
assert.equal(
automatico.testo,
[
"Data: 01/10/2026",
"Ora: 21:00",
"Luogo: PalaCRAP",
"Risposta attuale: nessuna",
"Azione: indicare presente, assente o in ritardo",
].join("\n"),
);
assert.match(testoSollecito(eventoSollecito, "forse").testo, /Risposta attuale: forse/);
assert.match(
testoSollecito(eventoSollecito, "assente").testo,
/Risposta attuale: nessuna/,
"qualunque stato diverso da «forse» si legge come nessuna risposta",
);
const manuale = testoSollecito(eventoSollecito, undefined, "Mario Rossi");
assert.equal(
manuale.testo.split("\n")[3],
"Richiesta di: Mario Rossi",
"il sollecito manuale nomina chi lo chiede, tra il luogo e la risposta attuale",
);
assert.ok(!automatico.testo.includes("Richiesta di"), "l'automatico non ha un mittente");
assert.ok(
!testoSollecito({ ...eventoSollecito, luogo: " " }, undefined).testo.includes("Luogo:"),
"senza luogo la riga manca",
);
// --- serie conferme: risposta entro 24h dalla convocazione --------------------
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
...ev(id, "allenamento", data),