From 274fed93c5cc09b815307ae3cb6290c2036abffd Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 9 Oct 2026 10:54:47 +0200 Subject: [PATCH] =?UTF-8?q?Implementa=20DD-050:=20avviso=20=C2=ABPresenza?= =?UTF-8?q?=20modificata=C2=BB,=20in=20prova=20solo=20per=20gli=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration m32: tipo presenza_modificata, funzione e trigger su risposte_presenze che avvisano gli admin attivi se una risposta cambia a 0-6 ore da una partita o un allenamento (push, email e centro notifiche). Gli allenatori non la ricevono finché dura la prova. Applicata in produzione il 09/10/2026. Test di integrazione e unitario; DD-050, notifiche, DATABASE, CHANGELOG, ROADMAP e PROJECT_STATE aggiornati (slot g18 ora giocatore). Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 10 +- docs/CHANGELOG.md | 7 + docs/DATABASE.md | 23 +- docs/ROADMAP.md | 2 + docs/decisions/DD-050.md | 17 +- docs/modules/notifiche.md | 2 +- src/lib/notifiche-utente.ts | 3 +- ...20261009120000_m32_presenza_modificata.sql | 140 ++++++++ test/README.md | 12 +- test/integration/presenza-modificata.test.ts | 335 ++++++++++++++++++ test/unit/notifiche-utente.test.ts | 1 + 11 files changed, 523 insertions(+), 29 deletions(-) create mode 100644 supabase/migrations/20261009120000_m32_presenza_modificata.sql create mode 100644 test/integration/presenza-modificata.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index ea82321..668f04f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -15,9 +15,8 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio Project Ref `hetycilxgkdmccelwerq` non si usano più. -- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a - `m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il - 01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie +- **Migration:** 43 file in `supabase/migrations/`, fino a `m32_presenza_modificata`. Tutte applicate in produzione (verificato con `npx supabase migration list` il + 09/10/2026); la `m32` (DD-050, avviso «Presenza modificata» agli admin in prova) è stata applicata quel giorno. La `m29` toglie `foto_path`: il codice che non la legge più (1.2.2) va rilasciato. - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo @@ -33,9 +32,8 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in - Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo. - Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno slot lo occupa anche in produzione e va liberato da un admin. -- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99 - e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora - presenti. +- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e + ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente. ## Note diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 07ce516..f67b95d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,13 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] +### Aggiunto + +- **Notifica «Presenza modificata» (DD-050), in prova solo per gli admin** — quando una risposta a una partita o a + un allenamento cambia, compare o viene ritirata a meno di 6 ore dall'inizio, gli admin ricevono una notifica + (centro notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Gli allenatori non la ricevono + finché dura la prova. Migration `m32`, applicata in produzione il 09/10/2026. + ## [1.3.1] - 2026-10-06 Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d96ce5d..74a917c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -122,7 +122,7 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027). - Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data. - Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta la rosa se `convocati` è vuoto; mai gli allenatori (DD-039). -- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e +- Il `CHECK` sul tipo ammette anche `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti manuali. - Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi @@ -152,16 +152,17 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in [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. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | -| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) | -| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | -| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) | -| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) | -| `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). | — | +| 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. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | +| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) | +| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | +| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) | +| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) | +| `avvisa_presenza_modificata()` (M32) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin attivo (in prova, DD-050), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` | +| `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). | — | ## Badge diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eacf5f4..6187f4b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,6 +49,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push, centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047) +- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa + gli admin (in prova solo loro, poi anche gli allenatori) ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050) ## Prossimo diff --git a/docs/decisions/DD-050.md b/docs/decisions/DD-050.md index 8830585..1e03268 100644 --- a/docs/decisions/DD-050.md +++ b/docs/decisions/DD-050.md @@ -1,7 +1,7 @@ # DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento **Data:** 8 ottobre 2026 -**Stato:** In valutazione (solo documentata: nessun codice né migration scritti) +**Stato:** Implementata in fase di prova, **solo per gli admin** (migration `m32`, applicata in produzione il 9 ottobre 2026) **Contesto** Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore) @@ -44,6 +44,15 @@ risposta (`notifiche.md`, «Cosa non genera nessuna notifica»). 6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036). +**Fase di prova** + +Per ora l'avviso arriva **solo agli admin attivi** (ruolo `admin` e slot attivo), non agli allenatori: serve a +vedere in pratica quante notifiche genera prima di aprirlo a chi gestisce gli eventi. Il punto 2 resta la regola +a regime. Per allargarlo basta una nuova migration che aggiunge `'allenatore'` alla lista dei ruoli in +`avvisa_presenza_modificata()` (oggi `ur.role IN ('admin')`) e il rimando in +[allenatore.md](../modules/allenatore.md#notifiche); il resto non cambia. Finché dura la prova l'allenatore non +riceve nulla. + **Alternative scartate** - **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client @@ -55,9 +64,9 @@ risposta (`notifiche.md`, «Cosa non genera nessuna notifica»). **Conseguenze** -- Nuova migration `m32` (CHECK dei tipi, due funzioni, due trigger); `docs/DATABASE.md` e il catalogo in - `notifiche.md` si aggiornano con essa. **Va applicata in produzione** con `npx supabase db push`. -- L'allenatore, che oggi non riceve nulla sulle presenze, riceve questa notifica: va aggiunta a +- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in + `notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026. +- A regime l'allenatore, che oggi non riceve nulla sulle presenze, riceve questa notifica (non in fase di prova): va aggiunta a [allenatore.md](../modules/allenatore.md#notifiche). - Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare per evento, non togliere l'avviso. diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 94a939c..ca03bd0 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -44,7 +44,7 @@ quelle del turno palloni**, che restano solo push e in-app: DD-040). | 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 | | 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì | -| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(proposta, DD-050: non ancora implementata)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli admin e gli allenatori attivi, tranne il giocatore a cui appartiene la presenza. | sì | sì | sì | +| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32`: in prova, solo admin)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | **In fase di prova solo gli admin attivi**; a regime anche gli allenatori attivi (DD-050). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì | Le notifiche generate dal server (righe 1-6 e 8) 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 diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts index a02835d..d1ff9fd 100644 --- a/src/lib/notifiche-utente.ts +++ b/src/lib/notifiche-utente.ts @@ -26,7 +26,8 @@ export type TipoNotifica = | "sollecita_presenze_6h" | "sondaggio_cacche" | "compleanno" - | "compleanno_auguri"; + | "compleanno_auguri" + | "presenza_modificata"; export type NotificaUtente = { id: string; diff --git a/supabase/migrations/20261009120000_m32_presenza_modificata.sql b/supabase/migrations/20261009120000_m32_presenza_modificata.sql new file mode 100644 index 0000000..03b8974 --- /dev/null +++ b/supabase/migrations/20261009120000_m32_presenza_modificata.sql @@ -0,0 +1,140 @@ +-- M32 — Avviso se una presenza cambia a meno di 6 ore dall'evento (DD-050) +-- +-- Quando una riga di `risposte_presenze` cambia (nuova, modificata o ritirata) e mancano da 0 a 6 ore +-- all'inizio di una partita o di un allenamento, ai destinatari arriva una notifica «Presenza modificata». +-- +-- FASE DI PROVA: i destinatari sono solo gli admin (DD-050, «Fase di prova»). Per aprirla agli allenatori +-- basta aggiungere 'allenatore' alla lista dei ruoli qui sotto, con una nuova migration. +-- +-- Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'email, quello delle push +-- (esteso qui con il tipo nuovo) accoda la push, la riga è il centro notifiche in-app (DD-038). + +-- --- Tipo 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', + 'compleanno', + 'compleanno_auguri', + 'presenza_modificata' + ) +); + +-- --- Funzione e trigger ------------------------------------------------------------------------ +-- +-- `evento_id` resta NULL (come i messaggi `admin`): la UNIQUE (giocatore_id, evento_id, tipo) non si +-- applica e due modifiche dello stesso evento restano due notifiche distinte. +-- +-- Cancellare un evento elimina le risposte con un trigger AFTER DELETE su `eventi_app` (M14): quando +-- scatta questo, l'evento non esiste più, quindi non parte nessun avviso. + +CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END; + giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END; + prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END; + dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END; + ev record; + chi record; +BEGIN + IF prima IS NOT DISTINCT FROM dopo THEN + RETURN NULL; + END IF; + + SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio + INTO ev + FROM public.eventi_app e + WHERE e.id = evento_id_r; + + IF NOT FOUND + OR ev.tipo NOT IN ('partita', 'allenamento') + OR ev.inizio IS NULL + OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN + RETURN NULL; + END IF; + + SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo + INTO chi + FROM public.giocatori_squadra g + WHERE g.id = giocatore_r; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo) + SELECT d.id, + 'presenza_modificata', + 'Presenza modificata: ' || chi.nome_completo, + 'Evento: ' || ev.titolo + || E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora + || E'\nDa: ' || public.nome_stato_presenza(prima) + || E'\nA: ' || public.nome_stato_presenza(dopo) + FROM public.giocatori_squadra d + WHERE d.attivo = true + AND d.id <> giocatore_r + AND EXISTS ( + SELECT 1 + FROM public.user_roles ur + WHERE ur.user_id = d.auth_user_id + AND ur.role IN ('admin') + ); + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION public.avvisa_presenza_modificata() FROM PUBLIC, anon, authenticated; + +CREATE OR REPLACE FUNCTION public.nome_stato_presenza(p_stato text) +RETURNS text +LANGUAGE sql IMMUTABLE AS $$ + SELECT CASE WHEN p_stato = 'ritardo' THEN 'in ritardo' ELSE coalesce(p_stato, 'nessuna risposta') END; +$$; + +CREATE TRIGGER risposte_presenze_avvisa_modifica + AFTER INSERT OR UPDATE OF stato OR DELETE ON public.risposte_presenze + FOR EACH ROW + EXECUTE FUNCTION public.avvisa_presenza_modificata(); + +-- --- Push: anche la presenza modificata -------------------------------------------------------- +-- +-- Stesso trigger di M31 con il tipo nuovo. L'email passa già dal trigger di M22/M26. + +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', + 'compleanno', + 'compleanno_auguri', + 'presenza_modificata' + )) + EXECUTE FUNCTION public.accoda_push_promemoria(); diff --git a/test/README.md b/test/README.md index 38faca7..7e0615c 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 (calcolo e card di dettaglio), avvisi in Home, 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-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), 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 (calcolo e card di dettaglio), avvisi in Home, 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-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), l'avviso «Presenza modificata» agli admin con finestra di 6 ore, stati e nessun avviso a cancellazione dell'evento (`presenza-modificata`, M32, DD-050), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), 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/presenza-modificata.test.ts b/test/integration/presenza-modificata.test.ts new file mode 100644 index 0000000..6f43717 --- /dev/null +++ b/test/integration/presenza-modificata.test.ts @@ -0,0 +1,335 @@ +/** + * Avviso «Presenza modificata» (M32, DD-050): `bun test/integration/presenza-modificata.test.ts`. + * + * Scrive su `risposte_presenze` come farebbe l'app e verifica, sul database vero: + * + * - una risposta nuova, modificata o ritirata a meno di 6 ore da una partita o da un allenamento + * avvisa gli admin attivi, con titolo e corpo del catalogo; + * - in fase di prova (DD-050) non riceve nulla un giocatore semplice né un allenatore, e non riceve + * nulla l'admin a cui appartiene la presenza; + * - nessun avviso se lo stato non cambia, se l'evento è oltre le 6 ore, già iniziato, o di un tipo che + * non è partita né allenamento, né quando una risposta sparisce perché l'evento è stato cancellato; + * - ogni modifica è una notifica a sé (nessun raggruppamento) e ha la coda email e la coda push. + * + * Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4` (chi risponde), `g5` (admin), + * `g6` (giocatore) e `g10` (allenatore) della rosa seed, ripristinati alla fine. + */ +import assert from "node:assert/strict"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("presenza modificata", "stack locale non attivo (npx supabase start)"); + riepilogo("presenza-modificata"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`presenza modificata su ${URL_BASE}`); + + const PASSWORD = "prova-presenza-modificata-123"; + const RISPONDE = "g4"; + const ADMIN = "g5"; + const GIOCATORE = "g6"; + const ALLENATORE = "g10"; + const PREFISSO = "test-presenza-mod-"; + + 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, 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 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 (AAAA-MM-GG) e ora (HH:MM) a Roma, tra `ore` ore da adesso. */ + function traOre(ore: number): { data: string; ora: string } { + const parti: Record = Object.fromEntries( + new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Rome", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .formatToParts(new Date(Date.now() + ore * 3_600_000)) + .map((p) => [p.type, p.value]), + ); + return { + data: `${parti["year"]}-${parti["month"]}-${parti["day"]}`, + ora: `${parti["hour"]}:${parti["minute"]}`, + }; + } + + type Riga = { id: string; giocatore_id: string; titolo: string; corpo: string }; + + const notifiche = () => + leggi( + "notifiche_utente?tipo=eq.presenza_modificata&select=id,giocatore_id,titolo,corpo&order=creato_il", + ); + const svuota = async () => { + await rest("notifiche_utente?tipo=eq.presenza_modificata", SERVIZIO, { method: "DELETE" }); + }; + const risposta = (evento: string, stato: string) => + scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", { + evento_id: evento, + giocatore_id: RISPONDE, + stato, + }); + const aggiorna = (evento: string, stato: string, giocatore = RISPONDE) => + scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", { + stato, + }); + const ritira = (evento: string) => + scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${RISPONDE}`, "DELETE"); + async function creaEvento(id: string, tipo: string, ore: number, titolo = `Prova ${id}`) { + const { data, ora } = traOre(ore); + await scrivi("eventi_app", "POST", { id, tipo, titolo, data, ora }); + return { data, ora, titolo }; + } + + let tokenAdmin = ""; + const idUtenti: string[] = []; + const idEventi: string[] = []; + 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`); + idUtenti.push(admin.id, allenatore.id); + await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" }); + tokenAdmin = admin.token; + for (const id of [RISPONDE, ADMIN, GIOCATORE, ALLENATORE]) { + const [riga] = await leggi>( + `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.${ADMIN}`, + "PATCH", + { auth_user_id: admin.id }, + tokenAdmin, + ); + await scrivi( + `giocatori_squadra?id=eq.${ALLENATORE}`, + "PATCH", + { tipo: "allenatore", auth_user_id: allenatore.id }, + tokenAdmin, + ); + await svuota(); + + const [chi] = await leggi<{ nome: string; cognome: string }>( + `giocatori_squadra?id=eq.${RISPONDE}&select=nome,cognome`, + ); + const nomeCompleto = `${chi!.nome} ${chi!.cognome}`.trim(); + + // Il seed può avere altri admin con uno slot attivo: l'elenco atteso lo calcola il test. + const attesi = async () => { + const ruoli = await leggi<{ user_id: string }>("user_roles?role=eq.admin&select=user_id"); + const slot = await leggi<{ id: string; auth_user_id: string }>( + "giocatori_squadra?attivo=eq.true&auth_user_id=not.is.null&select=id,auth_user_id", + ); + const idAdmin = new Set(ruoli.map((r) => r.user_id)); + return slot + .filter((s) => idAdmin.has(s.auth_user_id) && s.id !== RISPONDE) + .map((s) => s.id) + .sort(); + }; + + // --- Partita a 3 ore --------------------------------------------------------------------- + const ev = await creaEvento(`${PREFISSO}partita`, "partita", 3, "Partita di prova"); + idEventi.push(`${PREFISSO}partita`); + const dataIt = `${ev.data.slice(8, 10)}/${ev.data.slice(5, 7)}/${ev.data.slice(0, 4)}`; + + await prova("risposta nuova: avvisa gli admin, con il testo del catalogo", async () => { + await risposta(`${PREFISSO}partita`, "presente"); + const righe = await notifiche(); + assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), await attesi()); + assert.ok( + righe.some((r) => r.giocatore_id === ADMIN), + "l'admin di prova è tra i destinatari", + ); + const mia = righe.find((r) => r.giocatore_id === ADMIN)!; + assert.equal(mia.titolo, `Presenza modificata: ${nomeCompleto}`); + assert.equal( + mia.corpo, + [ + `Evento: ${ev.titolo}`, + `Data: ${dataIt}, ore ${ev.ora}`, + "Da: nessuna risposta", + "A: presente", + ].join("\n"), + ); + }); + + await prova("in prova non ricevono nulla il giocatore semplice e l'allenatore", async () => { + const destinatari = (await notifiche()).map((r) => r.giocatore_id); + assert.ok(!destinatari.includes(GIOCATORE)); + assert.ok(!destinatari.includes(ALLENATORE)); + assert.ok(!destinatari.includes(RISPONDE), "chi risponde non avvisa se stesso"); + }); + + await prova("la riga ha la coda email e la coda push", async () => { + const [mia] = (await notifiche()).filter((r) => r.giocatore_id === ADMIN); + const email = await leggi<{ notifica_id: string }>( + `notifiche_email_coda?notifica_id=eq.${mia!.id}&select=notifica_id`, + ); + assert.equal(email.length, 1, "email in coda"); + const push = await leggi<{ notifica_id: string }>( + `notifiche_push_coda?notifica_id=eq.${mia!.id}&select=notifica_id`, + ); + assert.equal(push.length, 1, "push in coda"); + }); + + await prova("modifica: ogni cambio è una notifica a sé, con Da e A giusti", async () => { + const prima = (await notifiche()).length; + await aggiorna(`${PREFISSO}partita`, "ritardo"); + const righe = await notifiche(); + assert.equal(righe.length, prima + (await attesi()).length); + const ultima = righe.filter((r) => r.giocatore_id === ADMIN).at(-1)!; + assert.match(ultima.corpo, /\nDa: presente\nA: in ritardo$/); + }); + + await prova("stesso stato di prima: nessun avviso", async () => { + const prima = (await notifiche()).length; + await aggiorna(`${PREFISSO}partita`, "ritardo"); + assert.equal((await notifiche()).length, prima); + }); + + await prova("ritiro della risposta: «nessuna risposta» come stato finale", async () => { + await ritira(`${PREFISSO}partita`); + const ultima = (await notifiche()).filter((r) => r.giocatore_id === ADMIN).at(-1)!; + assert.match(ultima.corpo, /\nDa: in ritardo\nA: nessuna risposta$/); + }); + + await prova("la presenza dell'admin non avvisa l'admin stesso", async () => { + await svuota(); + await scrivi("risposte_presenze", "POST", { + evento_id: `${PREFISSO}partita`, + giocatore_id: ADMIN, + stato: "assente", + }); + const destinatari = (await notifiche()).map((r) => r.giocatore_id); + assert.ok(!destinatari.includes(ADMIN)); + await scrivi( + `risposte_presenze?evento_id=eq.${PREFISSO}partita&giocatore_id=eq.${ADMIN}`, + "DELETE", + ); + }); + + // --- Casi in cui non deve partire nulla -------------------------------------------------- + await prova("allenamento a 5 ore: avvisa", async () => { + await svuota(); + await creaEvento(`${PREFISSO}allenamento`, "allenamento", 5); + idEventi.push(`${PREFISSO}allenamento`); + await risposta(`${PREFISSO}allenamento`, "assente"); + assert.equal((await notifiche()).length, (await attesi()).length); + }); + + for (const [nome, tipo, ore] of [ + ["oltre le 6 ore", "partita", 7], + ["già iniziato", "partita", -1], + ["un tipo che non è partita né allenamento", "evento", 3], + ] as const) { + await prova(`nessun avviso: ${nome}`, async () => { + await svuota(); + const id = `${PREFISSO}no-${idEventi.length}`; + await creaEvento(id, tipo, ore); + idEventi.push(id); + await risposta(id, "presente"); + assert.equal((await notifiche()).length, 0); + }); + } + + await prova("cancellare l'evento elimina le risposte senza avvisare nessuno", async () => { + await svuota(); + const id = `${PREFISSO}cancellato`; + await creaEvento(id, "partita", 2); + idEventi.push(id); + await risposta(id, "presente"); + await svuota(); + await scrivi(`eventi_app?id=eq.${id}`, "DELETE"); + assert.equal((await notifiche()).length, 0); + }); + + await prova("la funzione non è chiamabile dai client", async () => { + for (const token of [ANON, admin.token]) { + const res = await fetch(`${URL_BASE}/rest/v1/rpc/avvisa_presenza_modificata`, { + method: "POST", + headers: { + apikey: ANON, + Authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: "{}", + }); + assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterla eseguire`); + } + }); + } finally { + await svuota(); + for (const id of idEventi) { + 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("presenza-modificata"); + } +} diff --git a/test/unit/notifiche-utente.test.ts b/test/unit/notifiche-utente.test.ts index 4c98f7c..bc9fd32 100644 --- a/test/unit/notifiche-utente.test.ts +++ b/test/unit/notifiche-utente.test.ts @@ -61,6 +61,7 @@ const tipiNuovi: TipoNotifica[] = [ "sollecita_presenze_6h", "compleanno", "compleanno_auguri", + "presenza_modificata", ]; const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP"; for (const tipo of tipiNuovi) {