From 4760e8650caee6bea95240b18d73b6213aff76b7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 29 Sep 2026 14:48:30 +0200 Subject: [PATCH] L'allenatore non riceve i promemoria degli eventi; test dei destinatari per ruolo (DD-039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m25: giocatori_destinatari_evento() include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria a 24 e 3 ore, per nessun tipo di evento (fino alla m24 li riceveva sempre, DD-034). Riceve ancora i messaggi dello staff. Test: nuovo destinatari-notifiche.test.ts (14 controlli sulle route vere con un servizio push finto: promemoria, messaggio dello staff, sondaggio, sollecito, turno palloni, permessi di allenatore e giocatore) e aggiornato permessi-allenatore. Documentazione: DD-039, tabella delle notifiche dell'allenatore, catalogo e tabella dei test che lo verificano. Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 10 +- docs/CHANGELOG.md | 5 + docs/DATABASE.md | 20 +- docs/DESIGN_DECISIONS.md | 45 +- docs/modules/allenatore.md | 79 +-- docs/modules/notifiche-email.md | 5 +- docs/modules/notifiche.md | 30 +- ...29180000_m25_promemoria_solo_giocatori.sql | 25 + test/README.md | 12 +- .../integration/destinatari-notifiche.test.ts | 457 ++++++++++++++++++ test/integration/permessi-allenatore.test.ts | 46 +- 11 files changed, 665 insertions(+), 69 deletions(-) create mode 100644 supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql create mode 100644 test/integration/destinatari-notifiche.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 8694ee2..79711a8 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -24,10 +24,10 @@ 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` -- 35 migration in `supabase/migrations/`, fino a `m24_push_promemoria_e_sondaggio` (29/09/2026). +- 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` e `m24` sono verificate solo in locale e non - ancora applicate in produzione** + `npx supabase migration list` il 29/09/2026; **`m23`, `m24` e `m25` sono verificate solo in locale + e non ancora applicate in produzione** - 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 è @@ -61,6 +61,10 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). - Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata in `npx supabase migration list` del 24/09/2026) +- Migration `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()` + include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi + (li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con + `npx supabase db reset` e i test di integrazione, **non ancora in produzione** - Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda` con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo `sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b64070d..1a71fdd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,11 @@ versioni `0.y.z` erano pre-release. ### Modificato +- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non + c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di + squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration + `m25_promemoria_solo_giocatori`. + - **Ogni notifica arriva su tutti e tre i canali** (DD-038): i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email, partono ora anche come push, mandata dal worker `mailer/` (migration `m24_push_promemoria_e_sondaggio`). Il sondaggio pre-partita, che era solo push, diff --git a/docs/DATABASE.md b/docs/DATABASE.md index ed2a1fa..5f02c4d 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -75,16 +75,16 @@ 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. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); 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. | +| `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`. | ## Funzioni speciali diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index d71361a..39d99e7 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -54,6 +54,7 @@ Serve a rispondere a domande del tipo: | [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail | | [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 | **In valutazione** @@ -1449,7 +1450,8 @@ collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando immediato e non aspetta la query dei ruoli. - L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra. - In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`; - riceve i promemoria degli eventi e può sollecitare le presenze, non il turno palloni. + riceve i promemoria degli eventi (dal 29/09/2026 non più: DD-039) e può sollecitare le presenze, + non il turno palloni. - Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata. **Riesame** @@ -1712,3 +1714,44 @@ Vercel e avevano già i tre canali. 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 all'app. + +### DD-039 — L'allenatore non riceve i promemoria degli eventi + +**Data:** 29 settembre 2026 +**Stato:** Accettata + +**Contesto** +DD-034 stabilì che l'allenatore riceve i promemoria a 24 e 3 ore prima di un evento, sempre, convocato +o no (`giocatori_destinatari_evento()` riscritta da M21). Ma un promemoria serve a ricordare di +esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i +partecipanti (DD-034). L'incoerenza si vede negli eventi extra-campo, come una cena di squadra: +riceveva l'avviso (in-app, per email e, dopo M24, come push) senza poter dire se ci sarebbe stato né +comparire nell'elenco. + +**Decisione** +I destinatari dei promemoria sono i **giocatori attivi**: i convocati, o tutta la rosa se +`convocati` è vuoto. L'allenatore non li riceve, per nessun tipo di evento. Cambia solo la funzione +SQL (migration `m25_promemoria_solo_giocatori`, `g.tipo = 'giocatore'` al posto di «o allenatore +sempre»); cron, criteri di selezione, registro dei promemoria (DD-037), code email e push e testi +restano invariati. + +**Alternative scartate** + +- Toglierli solo per gli eventi extra-campo → scartata su indicazione esplicita di toglierli per + gli eventi in generale: anche per un allenamento o una partita l'allenatore non è tra i + partecipanti. +- Farlo rispondere agli eventi extra-campo, così da tenere il promemoria → scartata: servono + risposte presenze e regole del database per una categoria nuova, e il conteggio dei presenti. + +**Conseguenze** + +- Le notifiche che l'allenatore riceve dal server sono i **messaggi dello staff** e nient'altro + (sollecito, turno palloni, sondaggio e notifiche smart non lo riguardano già). +- I promemoria già generati per un allenatore prima della migration non si toccano. +- Un allenatore non è avvisato dell'inizio di un evento che gestisce: sa quando è perché lo ha + creato lui o lo vede in Calendario. +- Sostituisce la parte di DD-034 sui promemoria; il resto di DD-034 resta valido. + +**Riesame** +Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme +alla sua possibilità di rispondere. diff --git a/docs/modules/allenatore.md b/docs/modules/allenatore.md index 608c154..1d70b40 100644 --- a/docs/modules/allenatore.md +++ b/docs/modules/allenatore.md @@ -122,47 +122,65 @@ risposte presenze altrui, gestione dei ruoli. ## Notifiche -L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche -in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()` -lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio -Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che -riguardano chi gioca. +Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in +[notifiche.md](notifiche.md#catalogo-delle-notifiche)): + +| Notifica | L'allenatore la riceve? | Note | +| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). | +| 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. | +| 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. | + +Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha +spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal +proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app +funziona come per i giocatori, con i soli messaggi dello staff. ## Esclusione dalla rosa L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario -(`useAnagraficaRosa({ conAllenatori: true })`), i destinatari dei promemoria evento e dei -messaggi admin, che lo includono. +(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo +includono. -| Livello | Dove si esclude l'allenatore | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial` | -| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni | -| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` | -| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore | -| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) | +| Livello | Dove si esclude l'allenatore | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) | +| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni | +| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` | +| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore | +| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) | Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un giocatore). ## Permessi (riepilogo) -| Azione | Giocatore | Allenatore | Admin | -| ----------------------------------------------- | ---------- | ------------------ | ------------------ | -| Modificare nome e cognome | no | sì, i propri | tutti | -| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) | -| Documento, certificato, foto tessera | i propri | — | lettura/download | -| Avviso certificati in Home | il proprio | no | tutta la rosa | -| Creare/modificare/eliminare eventi, convocati | no | sì | sì | -| Sollecitare le presenze | no | sì | sì | -| Promemoria palloni, notifica personalizzata | no | no | sì | -| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) | -| Tab Stagione e Badge del profilo | sì | no | sì | -| Tab Obiettivi e Badge di Squadra | sì | no | sì | -| Badge e classifiche/dati sulle cacche | sì | no | sì | -| Dashboard `/admin` | no | no | sì | +| Azione | Giocatore | Allenatore | Admin | +| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- | +| Modificare nome e cognome | no | sì, i propri | tutti | +| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) | +| Documento, certificato, foto tessera | i propri | — | lettura/download | +| Avviso certificati in Home | il proprio | no | tutta la rosa | +| Creare/modificare/eliminare eventi, convocati | no | sì | sì | +| Sollecitare le presenze | no | sì | sì | +| Promemoria palloni, notifica personalizzata | no | no | sì | +| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot | +| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot | +| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa | +| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa | +| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) | +| Tab Stagione e Badge del profilo | sì | no | sì | +| Tab Obiettivi e Badge di Squadra | sì | no | sì | +| Badge e classifiche/dati sulle cacche | sì | no | sì | +| Dashboard `/admin` | no | no | sì | + +Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve +secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot. ## Implementazione @@ -174,7 +192,7 @@ giocatore). del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger `sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o - allenatore; presenze, cacche e voti chiusi all'allenatore; promemoria evento aperti. + allenatore; presenze, cacche e voti chiusi all'allenatore. - **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato. @@ -183,7 +201,8 @@ giocatore). ## Scelte confermate -- Riceve i promemoria degli eventi: sì. +- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i + messaggi dello staff. - Sollecita le presenze: sì. Promemoria palloni: no. - Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio. - In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md index 754d1cb..5517e45 100644 --- a/docs/modules/notifiche-email.md +++ b/docs/modules/notifiche-email.md @@ -59,8 +59,9 @@ e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_ut arrivano anche per email (DD-038). **Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche -in-app. Gli admin e gli allenatori non hanno un percorso a parte: ricevono ciò che riceverebbero -come giocatori, secondo i destinatari già definiti in M17 e DD-034. +in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio +slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il +sollecito, il turno palloni e il sondaggio (DD-034, DD-038). Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home (DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index af019c4..1229f0c 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -36,13 +36,13 @@ la colonna Email coincide con la colonna In-app). | # | 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; gli allenatori attivi sempre, convocati o no. | sì (worker) | sì | sì | +| 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; nessun invio verso altri dispositivi. | no (solo locale) | no | no | +| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | 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 @@ -50,6 +50,8 @@ dei promemoria (1 e 2) nascono nel database e la manda il worker `mailer/` (vedi 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»). +Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche). + ### Cosa non genera nessuna notifica - Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima @@ -89,6 +91,23 @@ Sono l'effetto attuale della logica, non scelte documentate altrove: - **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`) | + +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 +catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del +codice; l'unico pezzo puro, `inRosa`, è coperto. + --- ## Dati @@ -184,6 +203,13 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non ripetersi — deduplica puramente locale al dispositivo, non sincronizzata. +**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**: +`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore +letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude +gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e +`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la +notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche). + --- ## Centro notifiche in-app (M17) diff --git a/supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql b/supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql new file mode 100644 index 0000000..434c808 --- /dev/null +++ b/supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql @@ -0,0 +1,25 @@ +-- M25 — L'allenatore non riceve i promemoria degli eventi (DD-039) +-- +-- La migration M21 (DD-034) aggiunse `g.tipo = 'allenatore'` ai destinatari dei promemoria a 24 e +-- 3 ore: l'allenatore attivo li riceveva sempre, convocato o no. Ma un promemoria chiede di +-- esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i +-- partecipanti: per un evento come la cena di squadra riceveva un avviso su cui non poteva fare +-- nulla. Da ora i destinatari sono solo i giocatori attivi: i convocati, o tutta la rosa se +-- `convocati` è vuoto. +-- +-- Cambia solo questa funzione: `genera_promemoria_eventi()` (M17, M23), i job pg_cron, le code +-- email e push e il resto delle notifiche restano invariati. I promemoria già generati per un +-- allenatore prima della migration non vengono toccati. I messaggi dello staff continuano ad +-- arrivargli: non dipendono da questa funzione. + +CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text) +RETURNS SETOF text +LANGUAGE sql STABLE AS $$ + SELECT g.id + FROM public.eventi_app e + JOIN public.giocatori_squadra g + ON g.attivo = true + AND g.tipo = 'giocatore' + AND (cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati)) + WHERE e.id = p_evento_id; +$$; diff --git a/test/README.md b/test/README.md index 7c93f9f..5618be0 100644 --- a/test/README.md +++ b/test/README.md @@ -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), 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) 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 diff --git a/test/integration/destinatari-notifiche.test.ts b/test/integration/destinatari-notifiche.test.ts new file mode 100644 index 0000000..92d1540 --- /dev/null +++ b/test/integration/destinatari-notifiche.test.ts @@ -0,0 +1,457 @@ +/** + * Destinatari delle notifiche per ruolo: `bun test/integration/destinatari-notifiche.test.ts`. + * + * Verifica, chiamando le route vere, le regole del catalogo delle notifiche + * (docs/modules/notifiche.md) che finora erano coperte solo dalla lettura del codice: + * + * - i promemoria a 24 e 3 ore arrivano ai convocati (a tutti i giocatori attivi se `convocati` + * è vuoto), mai all'allenatore (DD-039); + * - la push del sondaggio pre-partita non arriva ai dispositivi dell'allenatore, quella del + * messaggio dello staff sì (DD-034, DD-038); + * - il sollecito presenze arriva a chi non ha risposto o ha risposto «forse», mai all'allenatore, + * ed è permesso ad allenatore e admin ma non a un giocatore; + * - il turno palloni arriva all'incaricato e a chi li aveva prima, solo per allenamenti e partite, + * ed è riservato agli admin. + * + * Le push vanno a un servizio push finto in locale: nessuna richiesta esce dalla macchina. + * Gira solo sullo stack locale (`npx supabase start`). Il server di sviluppo che riceve le + * chiamate deve avere chiavi VAPID (il test le imposta se avvia lui il server; con `BASE_URL` + * valgono quelle di quel server) e usa gli slot `g1`, `g2`, `g4`, `g5` e `g6` della rosa seed, + * ripristinati alla fine. + */ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { statoLocale } from "../helpers/locale"; +import { avviaServer } from "../helpers/server"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("destinatari notifiche", "stack locale non attivo (npx supabase start)"); + riepilogo("destinatari-notifiche"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`destinatari notifiche su ${URL_BASE}`); + + // Il server di sviluppo eredita queste: senza, le route punterebbero al progetto cloud. + process.env["SUPABASE_URL"] = URL_BASE; + process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON; + process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO; + + const b64u = (dati: ArrayBuffer | Uint8Array) => + Buffer.from(dati as ArrayBuffer).toString("base64url"); + + // Chiavi VAPID di prova (P-256), nel formato di `webpush.server.ts`: la chiave privata reale + // non deve mai servire ai test. + const vapid = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + ]); + const jwk = await crypto.subtle.exportKey("jwk", vapid.privateKey); + process.env["VAPID_PUBLIC_KEY"] = b64u( + new Uint8Array([ + 4, + ...Buffer.from(jwk.x ?? "", "base64url"), + ...Buffer.from(jwk.y ?? "", "base64url"), + ]), + ); + process.env["VAPID_PRIVATE_KEY"] = jwk.d ?? ""; + + async function chiaviDispositivo() { + const k = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [ + "deriveBits", + ]); + return { + p256dh: b64u(await crypto.subtle.exportKey("raw", k.publicKey)), + auth: b64u(crypto.getRandomValues(new Uint8Array(16))), + }; + } + + const PREFISSO = "test-destinatari-notifiche"; + const PASSWORD = "prova-destinatari-notifiche-123"; + const G_UNO = "g1"; + const G_DUE = "g2"; + const G_QUATTRO = "g4"; + const G_CINQUE = "g5"; + const ALLENATORE = "g6"; + const PARTITA = `${PREFISSO}-partita`; + const PRESENZE = `${PREFISSO}-presenze`; + const PALLONI_1 = `${PREFISSO}-palloni-1`; + const PALLONI_2 = `${PREFISSO}-palloni-2`; + const GENERICO = `${PREFISSO}-generico`; + const PROMEMORIA_CONVOCATI = `${PREFISSO}-promemoria-convocati`; + const PROMEMORIA_TUTTI = `${PREFISSO}-promemoria-tutti`; + const TITOLO_STAFF = `${PREFISSO} messaggio ${Date.now()}`; + + 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(percorso: string): Promise { + 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, token: string, metodo: string, corpo?: unknown) { + 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 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 }; + } + + // --- Servizio push finto: registra a quale dispositivo arriva ogni push ------------------ + const ricevute: string[] = []; + const servizioPush: Server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + ricevute.push((req.url ?? "").replace(/^\//, "")); + res.statusCode = 201; + res.end(); + }); + }); + await new Promise((ok) => servizioPush.listen(0, "127.0.0.1", ok)); + const porta = (servizioPush.address() as AddressInfo).port; + const endpoint = (slot: string) => `http://127.0.0.1:${porta}/${slot}`; + const svuota = () => { + ricevute.length = 0; + }; + /** 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 }; + const notifiche = (filtro: string) => + leggi( + `notifiche_utente?${filtro}&select=giocatore_id,titolo,tipo&order=giocatore_id`, + ); + const slot = (righe: Notifica[]) => righe.map((r) => r.giocatore_id); + + const idUtenti: string[] = []; + let server: { baseUrl: string; stop: () => void } | null = null; + let tokenAdmin = ""; + const originali = new Map(); + + try { + const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`); + const allenatore = await creaUtente(`${PREFISSO}-allenatore-${Date.now()}@example.test`); + const giocatore = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`); + idUtenti.push(admin.id, allenatore.id, giocatore.id); + await scrivi("user_roles", SERVIZIO, "POST", { user_id: admin.id, role: "admin" }); + tokenAdmin = admin.token; + + const [g6] = await leggi<{ tipo: string; auth_user_id: string | null }>( + `giocatori_squadra?id=eq.${ALLENATORE}&select=tipo,auth_user_id`, + ); + originali.set(ALLENATORE, g6 ?? { tipo: "giocatore", auth_user_id: null }); + // Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin. Il + // trigger di M21 assegna il ruolo `allenatore` all'account collegato a uno slot allenatore. + await scrivi(`giocatori_squadra?id=eq.${ALLENATORE}`, tokenAdmin, "PATCH", { + tipo: "allenatore", + auth_user_id: allenatore.id, + }); + + for (const s of [G_QUATTRO, G_CINQUE, ALLENATORE]) { + await scrivi("push_subscriptions", SERVIZIO, "POST", { + giocatore_id: s, + endpoint: endpoint(s), + ...(await chiaviDispositivo()), + }); + } + for (const [id, tipo, data] of [ + [PARTITA, "partita", "2031-04-01"], + [PRESENZE, "allenamento", "2031-04-02"], + [PALLONI_1, "allenamento", "2031-05-01"], + [PALLONI_2, "allenamento", "2031-05-08"], + [GENERICO, "evento", "2031-05-09"], + ] as const) { + await scrivi("eventi_app", SERVIZIO, "POST", { + id, + tipo, + titolo: `${PREFISSO} ${id}`, + data, + ora: "20:00", + convocati: [], + }); + } + + // Promemoria: eventi che iniziano tra 2 ore, dentro le finestre dei job a 24 e a 3 ore. + const tra2Ore = new Date(Date.now() + 2 * 3_600_000); + const dataRoma = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(tra2Ore); + const oraRoma = new Intl.DateTimeFormat("it-IT", { + timeZone: "Europe/Rome", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(tra2Ore); + for (const [id, convocati] of [ + [PROMEMORIA_CONVOCATI, [G_QUATTRO]], + [PROMEMORIA_TUTTI, []], + ] as const) { + await scrivi("eventi_app", SERVIZIO, "POST", { + id, + tipo: "allenamento", + titolo: `${PREFISSO} ${id}`, + data: dataRoma, + ora: oraRoma, + convocati: [...convocati], + }); + } + + server = await avviaServer(); + console.log(`route su ${server.baseUrl}`); + const chiama = (percorso: string, token: string, corpo: unknown) => + fetch(`${server!.baseUrl}/api/public/${percorso}`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify(corpo), + }); + + const attivi = ( + await leggi<{ id: string; tipo: string }>("giocatori_squadra?attivo=eq.true&select=id,tipo") + ).sort((a, b) => a.id.localeCompare(b.id)); + const giocatoriAttivi = attivi.filter((g) => g.tipo === "giocatore").map((g) => g.id); + assert.ok(attivi.some((g) => g.id === ALLENATORE && g.tipo === "allenatore")); + + // --- 0. Promemoria a 24 e 3 ore (generati dal database) -------------------------------- + await prova( + "il promemoria arriva ai soli convocati: l'allenatore non lo riceve (DD-039)", + async () => { + for (const [tipo, finestra] of [ + ["evento_promemoria_24h", "24 hours"], + ["evento_promemoria_3h", "3 hours"], + ] as const) { + const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_promemoria_eventi`, { + method: "POST", + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + }, + body: JSON.stringify({ p_tipo: tipo, p_finestra: finestra }), + }); + assert.ok(res.ok, `genera_promemoria_eventi ${tipo}`); + const righe = await notifiche(`evento_id=eq.${PROMEMORIA_CONVOCATI}&tipo=eq.${tipo}`); + assert.deepEqual(slot(righe), [G_QUATTRO], tipo); + } + }, + ); + + await prova( + "con convocati vuoto il promemoria arriva a tutti i giocatori attivi, mai all'allenatore", + async () => { + for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h"]) { + const righe = await notifiche(`evento_id=eq.${PROMEMORIA_TUTTI}&tipo=eq.${tipo}`); + assert.deepEqual(slot(righe), giocatoriAttivi, tipo); + } + }, + ); + + // --- 1. Messaggio dello staff --------------------------------------------------------- + await prova( + "controllo: la push arriva al servizio finto (il server ha le chiavi VAPID)", + async () => { + svuota(); + const res = await chiama("notifica-personalizzata", tokenAdmin, { + messaggio: `${TITOLO_STAFF} controllo`, + giocatoreId: G_QUATTRO, + }); + assert.equal(res.status, 200); + assert.deepEqual( + destinatariPush(), + [G_QUATTRO], + "il server di prova non manda push: servono VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY, oppure non usare BASE_URL", + ); + }, + ); + + await prova( + "il messaggio dello staff a tutti raggiunge anche i dispositivi dell'allenatore", + async () => { + svuota(); + const res = await chiama("notifica-personalizzata", tokenAdmin, { + messaggio: TITOLO_STAFF, + }); + assert.equal(res.status, 200); + assert.deepEqual(destinatariPush(), [ALLENATORE, G_QUATTRO, G_CINQUE].sort()); + const righe = await notifiche(`corpo=eq.${encodeURIComponent(TITOLO_STAFF)}&tipo=eq.admin`); + assert.deepEqual( + slot(righe), + attivi.map((g) => g.id), + "in-app: tutta la rosa attiva, allenatore compreso", + ); + }, + ); + + await prova("il messaggio a un solo giocatore non arriva agli altri dispositivi", async () => { + svuota(); + await chiama("notifica-personalizzata", tokenAdmin, { + messaggio: `${TITOLO_STAFF} solo g5`, + giocatoreId: G_CINQUE, + }); + assert.deepEqual(destinatariPush(), [G_CINQUE]); + }); + + await prova( + "un allenatore o un giocatore non possono mandare il messaggio dello staff", + async () => { + for (const token of [allenatore.token, giocatore.token]) { + const res = await chiama("notifica-personalizzata", token, { messaggio: "no" }); + assert.ok(!res.ok, `stato ${res.status}`); + } + }, + ); + + // --- 2. Sondaggio pre-partita --------------------------------------------------------- + await prova("il sondaggio non manda la push ai dispositivi dell'allenatore", async () => { + svuota(); + const res = await chiama("apri-sondaggio", tokenAdmin, { eventoId: PARTITA }); + assert.equal(res.status, 200); + assert.deepEqual(destinatariPush(), [G_QUATTRO, G_CINQUE].sort()); + }); + + await prova("il sondaggio non crea l'avviso in-app per l'allenatore", async () => { + const righe = await notifiche(`evento_id=eq.${PARTITA}&tipo=eq.sondaggio_cacche`); + assert.deepEqual(slot(righe), giocatoriAttivi); + }); + + await prova("un allenatore non può aprire il sondaggio", async () => { + const res = await chiama("apri-sondaggio", allenatore.token, { eventoId: PARTITA }); + assert.ok(!res.ok, `stato ${res.status}`); + }); + + // --- 3. Sollecito presenze ------------------------------------------------------------ + await scrivi("risposte_presenze", SERVIZIO, "POST", [ + { evento_id: PRESENZE, giocatore_id: G_QUATTRO, stato: "presente" }, + { evento_id: PRESENZE, giocatore_id: G_CINQUE, stato: "forse" }, + ]); + + await prova( + "l'allenatore può sollecitare: arriva a chi non ha risposto e a chi ha detto «forse»", + async () => { + svuota(); + const res = await chiama("sollecita-presenze", allenatore.token, { eventoId: PRESENZE }); + assert.equal(res.status, 200); + const righe = await notifiche(`evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze`); + assert.deepEqual( + slot(righe), + giocatoriAttivi.filter((id) => id !== G_QUATTRO), + "esclusi chi ha già risposto e l'allenatore, incluso chi ha risposto «forse»", + ); + assert.ok(slot(righe).includes(G_CINQUE)); + assert.deepEqual(destinatariPush(), [G_CINQUE], "push solo ai dispositivi dei sollecitati"); + }, + ); + + 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}`); + }); + + // --- 4. Turno palloni ----------------------------------------------------------------- + await scrivi("turni_palloni", SERVIZIO, "POST", [ + { evento_id: PALLONI_1, giocatore_id: G_UNO }, + { evento_id: PALLONI_2, giocatore_id: G_DUE }, + ]); + + await prova("il turno palloni arriva all'incaricato e a chi li aveva prima", async () => { + const res = await chiama("promemoria-palloni", tokenAdmin, { eventoId: PALLONI_2 }); + assert.equal(res.status, 200); + 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"); + }); + + await prova( + "per un evento che non è allenamento né partita non parte nessun avviso", + async () => { + await scrivi("turni_palloni", SERVIZIO, "POST", { + evento_id: GENERICO, + giocatore_id: G_QUATTRO, + }); + const res = await chiama("promemoria-palloni", tokenAdmin, { eventoId: GENERICO }); + assert.equal(res.status, 200); + assert.equal((await notifiche(`evento_id=eq.${GENERICO}&tipo=eq.turno_palloni`)).length, 0); + }, + ); + + await prova("un allenatore non può far partire il turno palloni", async () => { + const res = await chiama("promemoria-palloni", allenatore.token, { eventoId: PALLONI_2 }); + assert.ok(!res.ok, `stato ${res.status}`); + }); + } finally { + server?.stop(); + servizioPush.close(); + await rest( + `push_subscriptions?endpoint=like.${encodeURIComponent(`http://127.0.0.1:${porta}/*`)}`, + SERVIZIO, + { + method: "DELETE", + }, + ); + await rest(`notifiche_utente?corpo=like.${encodeURIComponent(`${PREFISSO}*`)}`, SERVIZIO, { + method: "DELETE", + }); + for (const id of [ + PARTITA, + PRESENZE, + PALLONI_1, + PALLONI_2, + GENERICO, + PROMEMORIA_CONVOCATI, + PROMEMORIA_TUTTI, + ]) { + await rest(`notifiche_utente?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await rest(`turni_palloni?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("destinatari-notifiche"); + } +} diff --git a/test/integration/permessi-allenatore.test.ts b/test/integration/permessi-allenatore.test.ts index 051f791..4f12c69 100644 --- a/test/integration/permessi-allenatore.test.ts +++ b/test/integration/permessi-allenatore.test.ts @@ -284,21 +284,37 @@ if (!locale) { assert.ok(!votato.ok, `l'allenatore non si vota (${votato.status})`); }); - await prova("i promemoria evento arrivano anche all'allenatore non convocato", async () => { - await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { - method: "PATCH", - body: JSON.stringify({ convocati: [GIOCATORE] }), - }); - const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, { - method: "POST", - body: JSON.stringify({ p_evento_id: EVENTO }), - }); - assert.equal(res.status, 200); - const destinatari = ((await res.json()) as Array>).map((d) => - typeof d === "string" ? d : Object.values(d)[0], - ); - assert.deepEqual([...destinatari].sort(), [GIOCATORE, SLOT].sort()); - }); + await prova( + "i promemoria evento non arrivano all'allenatore, convocato o no (DD-039)", + async () => { + const destinatari = async () => { + const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, { + method: "POST", + body: JSON.stringify({ p_evento_id: EVENTO }), + }); + assert.equal(res.status, 200); + return ((await res.json()) as Array>).map((d) => + typeof d === "string" ? d : Object.values(d)[0], + ); + }; + + // Con i convocati: solo quelli, allenatore escluso anche se attivo. + await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify({ convocati: [GIOCATORE] }), + }); + assert.deepEqual(await destinatari(), [GIOCATORE]); + + // Senza convocati (tutta la rosa): i giocatori attivi, mai l'allenatore. + await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify({ convocati: [] }), + }); + const tutti = await destinatari(); + assert.ok(tutti.includes(GIOCATORE), "il giocatore attivo è tra i destinatari"); + assert.ok(!tutti.includes(SLOT), "l'allenatore non è tra i destinatari"); + }, + ); await prova("scollegare l'account toglie il ruolo", async () => { const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAdmin, {