diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 052aefd..bb56ea7 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 01/10/2026 +Ultimo aggiornamento: 06/10/2026 Questo file dice **a che punto siamo ora**. Le funzionalità stanno in [docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo @@ -14,8 +14,9 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato. - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio Project Ref `hetycilxgkdmccelwerq` non si usano più. -- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte - applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie +- **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 `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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2a30359..a1e0b57 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,13 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] +### Aggiunto + +- **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri + attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo + messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration + `m31_notifica_compleanno`, da applicare in produzione. + ## [1.2.3] - 2026-10-04 Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 3779090..444af8a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -22,6 +22,7 @@ sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011 | `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) | | `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) | | `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) | +| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. | | `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) | | `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | | `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | @@ -121,11 +122,11 @@ 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 `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e +- Il `CHECK` sul tipo ammette anche `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 - automatici ma non quelli dei pulsanti. + automatici (compleanni compresi) ma non quelli dei pulsanti. ### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`) @@ -151,15 +152,16 @@ 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) | -| `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) | +| `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 d609f27..462f19e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,6 +47,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040) +- [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) + ## Prossimo - [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina diff --git a/docs/decisions/DD-047.md b/docs/decisions/DD-047.md new file mode 100644 index 0000000..8678cc3 --- /dev/null +++ b/docs/decisions/DD-047.md @@ -0,0 +1,65 @@ +# DD-047 — Notifica di compleanno + +**Data:** 6 ottobre 2026 +**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica») + +**Contesto** +I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di +nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11 +chiede una notifica del tipo «oggi è il compleanno di _tizio_». + +**Decisione** + +1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed + email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in + `notifiche_utente`, il worker `mailer/` manda push ed email. +2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo + dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro + mancato si recupera all'ora dopo e non si genera mai due volte. +3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi** + della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo: + nessun avviso. +4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra, + non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**: + - **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, ! 🎂`, **senza corpo**; + - **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di `, **senza corpo**. +5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve + un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di e ` (con tre o più: `A, B e C`, + in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**, + senza i nomi degli altri. +6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e + giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la + notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono + `eventi_app`. +7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli + auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda + entrambi; quello delle email li lascia passare, quindi arrivano anche per email. +8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili, + perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1° + marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra. + +**Alternative scartate** + +- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che + chiede la issue. +- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno + sarebbero due notifiche e due mail al giorno per lo stesso destinatario. +- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che + abbiamo scartato; la chiave `(destinatario, giorno)` basta. +- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti». +- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8). + +**Conseguenze** + +- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email + accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker + (`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail. +- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli + altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i + destinatari). Chi la inserisce prima delle 8 viene incluso. +- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno. +- Il compleanno resta anche in Calendario, invariato. + +**Riesame** +Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno +palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 67051e9..71c98c0 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -86,6 +86,7 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand | [DD-038](DD-038.md) | Tre canali per ogni notifica | | [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-040](DD-040.md) | Palloni e solleciti automatici | +| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-046](DD-046.md) | Avviso certificati mancanti | | [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore | | [DD-044](DD-044.md) | Via la foto tessera dal profilo | diff --git a/docs/modules/allenatore.md b/docs/modules/allenatore.md index 10fb41b..c885cd9 100644 --- a/docs/modules/allenatore.md +++ b/docs/modules/allenatore.md @@ -132,6 +132,7 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can | 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. | +| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. | | 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 diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md index a2b2662..6c1937e 100644 --- a/docs/modules/notifiche-email.md +++ b/docs/modules/notifiche-email.md @@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti ## Quali notifiche e a chi -**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla +**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **Fa eccezione il turno palloni** (DD-040, dalla `m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 9f3bbed..3da94a6 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi indipendenti: - **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito - presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)). + presenze, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)). - **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge, serie e obiettivi appena raggiunti; non è un canale push separato. @@ -34,19 +34,20 @@ in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email** ([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne quelle del turno palloni**, che restano solo push e in-app: DD-040). -| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email | -| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- | -| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — | -| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — | -| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì | -| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) | -| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì | -| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | -| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | +| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email | +| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- | +| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — | +| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — | +| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì | +| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) | +| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì | +| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | +| 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ì | -Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La +Le notifiche generate dal server (righe 1-6 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 -automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei +automatiche (4, 5 e 8; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per lo stesso evento (vedi «Centro notifiche in-app»). @@ -63,15 +64,17 @@ volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato d [notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`. -| # | Notifica | Titolo | Corpo | -| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: `, oppure `…di oggi: ` | `Data: domani, `
`Ora: `
`Luogo: ` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) | -| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. | -| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: `
`Data: , ore `
`Incarico: custodia dei palloni al termine dell'evento`
`Riconsegna: ` — senza evento successivo la riga «Riconsegna» manca | -| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: `
`Data: , ore `
`Incarico: riconsegna dei palloni in custodia dal turno precedente` | -| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: `
`Data: , ore `
`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta | -| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: ` | `Data: `
`Ora: `
`Luogo: `
`Richiesta di: ` (solo nel sollecito manuale, e solo se il nome c'è)
`Risposta attuale: nessuna` (oppure `forse`)
`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore | -| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | ` · ore . Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | +| # | Notifica | Titolo | Corpo | +| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: `, oppure `…di oggi: ` | `Data: domani, `
`Ora: `
`Luogo: ` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) | +| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. | +| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: `
`Data: , ore `
`Incarico: custodia dei palloni al termine dell'evento`
`Riconsegna: ` — senza evento successivo la riga «Riconsegna» manca | +| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: `
`Data: , ore `
`Incarico: riconsegna dei palloni in custodia dal turno precedente` | +| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: `
`Data: , ore `
`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta | +| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: ` | `Data: `
`Ora: `
`Luogo: `
`Richiesta di: ` (solo nel sollecito manuale, e solo se il nome c'è)
`Risposta attuale: nessuna` (oppure `forse`)
`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore | +| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | ` · ore . Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | +| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, ! 🎂` | Nessuno: solo il titolo | +| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di ` — con più festeggiati `
e `, o `, e `, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso | Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26` (`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`, @@ -103,6 +106,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email. - Assenze e infortuni. - La registrazione o il collegamento di un nuovo giocatore. - Il certificato in scadenza: resta una card in Home (DD-035). +- Un compleanno non ancora arrivato: l'avviso parte solo il giorno stesso (DD-047). ### Comportamenti da conoscere @@ -143,6 +147,12 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu - **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni (avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app. +- **Il compleanno parte una volta al giorno, dalle 8:00 (fuso `Europe/Rome`)** (DD-047): il job gira ogni ora e + agisce solo dalle 8; il registro `compleanni_notificati` (per destinatario e giorno) impedisce il doppione anche se + il giocatore elimina la notifica. Chi non ha la data di nascita o non è attivo non è né festeggiato né destinatario. + Un nuovo membro, o chi inserisce la nascita, dopo che gli avvisi del giorno sono partiti non genera un secondo giro. +- **29 febbraio: per ora non gestito** (DD-047). Chi è nato il 29 febbraio è avvisato solo negli anni bisestili: negli + altri non parte nulla, né il 28 febbraio né il 1° marzo. Limite noto e accettato. - **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. @@ -156,6 +166,7 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu | Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` | | Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` | | Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` | +| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.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`) | diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts index 8310b0f..a02835d 100644 --- a/src/lib/notifiche-utente.ts +++ b/src/lib/notifiche-utente.ts @@ -24,7 +24,9 @@ export type TipoNotifica = | "sollecita_presenze_24h" | "sollecita_presenze_12h" | "sollecita_presenze_6h" - | "sondaggio_cacche"; + | "sondaggio_cacche" + | "compleanno" + | "compleanno_auguri"; export type NotificaUtente = { id: string; diff --git a/supabase/migrations/20261006120000_m31_notifica_compleanno.sql b/supabase/migrations/20261006120000_m31_notifica_compleanno.sql new file mode 100644 index 0000000..8fdba72 --- /dev/null +++ b/supabase/migrations/20261006120000_m31_notifica_compleanno.sql @@ -0,0 +1,156 @@ +-- M31 — Notifica di compleanno (DD-047, issue #11) +-- +-- Ogni giorno, dalle 8:00 (fuso Europe/Rome), chi ha il compleanno riceve il solo titolo di auguri e tutti gli +-- altri membri attivi della squadra (allenatori compresi) ricevono il solo avviso «Oggi è il compleanno +-- di ...», senza corpo e senza età. +-- Se in un giorno compiono gli anni più persone, ogni destinatario riceve un solo messaggio: chi non +-- festeggia con tutti i nomi, ogni festeggiato solo i propri auguri. +-- +-- Canali come le altre notifiche automatiche (DD-038): la riga in `notifiche_utente` genera la mail +-- (il trigger di M26 lascia passare i nuovi tipi) e, con M31, anche la push dalla coda. +-- +-- 29 febbraio: il confronto è tra giorno e mese, quindi chi è nato il 29 febbraio viene avvisato solo +-- negli anni bisestili. Limite noto e accettato (DD-047): non c'è un ripiego al 28 febbraio. + +-- --- Tipi di notifica -------------------------------------------------------------------------- + +ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check; +ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK ( + tipo IN ( + 'admin', + 'evento_promemoria_24h', + 'evento_promemoria_3h', + 'turno_palloni', + 'turno_palloni_12h', + 'turno_palloni_6h', + 'turno_palloni_3h', + 'turno_palloni_revocato', + 'sollecita_presenze', + 'sollecita_presenze_24h', + 'sollecita_presenze_12h', + 'sollecita_presenze_6h', + 'sondaggio_cacche', + 'compleanno', + 'compleanno_auguri' + ) +); + +-- --- Registro ---------------------------------------------------------------------------------- +-- +-- Una riga per destinatario e giorno: ricorda che l'avviso di quel giorno è già stato generato, +-- anche se il giocatore elimina la notifica (come `promemoria_eventi_generati`, DD-037). Non è legato +-- a un evento: i compleanni non sono `eventi_app`. Sparisce con il giocatore (DD-029). + +CREATE TABLE public.compleanni_notificati ( + giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE, + giorno date NOT NULL, + generato_il timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (giocatore_id, giorno) +); + +COMMENT ON TABLE public.compleanni_notificati IS + 'Avvisi di compleanno già generati per destinatario e giorno: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M31, DD-047).'; + +-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy. +ALTER TABLE public.compleanni_notificati ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.compleanni_notificati FROM PUBLIC, anon, authenticated; +GRANT ALL ON public.compleanni_notificati TO service_role; + +-- --- Job --------------------------------------------------------------------------------------- +-- +-- Gira ogni ora e agisce solo dalle 8:00 in poi, ora locale: un giro mancato si recupera all'ora +-- dopo e il registro impedisce di generare due volte lo stesso avviso. `pg_cron` ragiona in UTC e +-- l'ora legale sposta l'ora locale di una ora: per questo l'ora si legge qui, in `Europe/Rome`. +-- +-- Il registro è per destinatario e giorno: chi entra in squadra o inserisce la data di nascita dopo +-- che gli avvisi del giorno sono partiti non genera un secondo giro (DD-047). +-- +-- `p_adesso` esiste per i test, che simulano un'altra data; il job lo chiama senza argomenti. + +CREATE OR REPLACE FUNCTION public.genera_avvisi_compleanno(p_adesso timestamptz DEFAULT now()) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + adesso timestamp := p_adesso AT TIME ZONE 'Europe/Rome'; + oggi date := (p_adesso AT TIME ZONE 'Europe/Rome')::date; +BEGIN + IF extract(hour FROM adesso) < 8 THEN + RETURN; + END IF; + + WITH festeggiati AS ( + SELECT g.id, g.nome + FROM public.giocatori_squadra g + WHERE g.attivo = true + AND g.nascita IS NOT NULL + AND extract(month FROM g.nascita) = extract(month FROM oggi) + AND extract(day FROM g.nascita) = extract(day FROM oggi) + ), + elenco AS ( + SELECT array_agg(nome ORDER BY nome, id COLLATE "C") AS nomi + FROM festeggiati + ), + nuovi AS ( + INSERT INTO public.compleanni_notificati (giocatore_id, giorno) + SELECT g.id, oggi + FROM public.giocatori_squadra g + WHERE g.attivo = true + AND EXISTS (SELECT 1 FROM festeggiati) + ON CONFLICT DO NOTHING + RETURNING giocatore_id + ) + INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo) + SELECT n.giocatore_id, 'compleanno_auguri', + 'Buon compleanno, ' || f.nome || '! 🎂', + '' + FROM nuovi n + JOIN festeggiati f ON f.id = n.giocatore_id + UNION ALL + SELECT n.giocatore_id, 'compleanno', + 'Oggi è il compleanno di ' + || CASE WHEN cardinality(e.nomi) = 1 THEN e.nomi[1] + ELSE array_to_string(e.nomi[1:cardinality(e.nomi) - 1], ', ') + || ' e ' || e.nomi[cardinality(e.nomi)] + END, + '' + FROM nuovi n + CROSS JOIN elenco e + WHERE NOT EXISTS (SELECT 1 FROM festeggiati f WHERE f.id = n.giocatore_id); +END; +$$; + +REVOKE ALL ON FUNCTION public.genera_avvisi_compleanno(timestamptz) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.genera_avvisi_compleanno(timestamptz) TO service_role; + +SELECT cron.schedule( + 'avvisi-compleanno', + '0 * * * *', + $$ SELECT public.genera_avvisi_compleanno() $$ +); + +-- --- Push: anche i compleanni ------------------------------------------------------------------ +-- +-- Nascono nel database come le altre notifiche automatiche: nessuna route le manda, quindi la push +-- passa dalla coda (DD-038). Stesso trigger di M26 con i due tipi nuovi. + +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' + )) + EXECUTE FUNCTION public.accoda_push_promemoria(); diff --git a/test/README.md b/test/README.md index 40e20bf..5b125cc 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), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | -| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | -| `helpers/` | Avvio del server di test e mini-harness condiviso | — | +| Cartella | Cosa verifica | Serve rete? | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-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 | — | ## Server di sviluppo con Node vecchio diff --git a/test/integration/notifiche-compleanno.test.ts b/test/integration/notifiche-compleanno.test.ts new file mode 100644 index 0000000..c00702e --- /dev/null +++ b/test/integration/notifiche-compleanno.test.ts @@ -0,0 +1,399 @@ +/** + * Notifica di compleanno (M31, DD-047, issue #11): `bun test/integration/notifiche-compleanno.test.ts`. + * + * Esegue il job vero del database (`genera_avvisi_compleanno`) simulando date diverse con il + * parametro `p_adesso`, e verifica: + * + * - l'ora: prima delle 8:00 (fuso Europe/Rome, anche con l'ora legale) non parte nulla; + * - un compleanno: auguri al festeggiato, avviso con il solo nome a tutti gli altri membri attivi + * (allenatore compreso), niente a chi non è attivo; + * - più compleanni lo stesso giorno: un solo messaggio per destinatario, con i nomi in ordine + * alfabetico («A e B», «A, B e C»); ogni festeggiato riceve solo i propri auguri; + * - una sola generazione per destinatario e giorno, anche se la notifica viene eliminata; + * - il canale: push ed email in coda; + * - il 29 febbraio: avvisato solo negli anni bisestili (limite noto, DD-047); + * - che il job sia pianificato ogni ora. + * + * Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4`, `g5`, `g6`, `g9` (inattivo) e + * `g10` (allenatore) della rosa seed, ripristinati alla fine, e date simulate nel 2027/2028 in cui + * nessun altro slot compie gli anni (lo verifica il test stesso). + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("notifiche di compleanno", "stack locale non attivo (npx supabase start)"); + riepilogo("notifiche-compleanno"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`notifiche di compleanno su ${URL_BASE}`); + + const PASSWORD = "prova-notifiche-compleanno-123"; + const G4 = "g4"; + const G5 = "g5"; + const G6 = "g6"; + const INATTIVO = "g9"; + const ALLENATORE = "g10"; + const TIPI = "compleanno,compleanno_auguri"; + + 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()}`); + } + + /** Il job, come se «adesso» fosse `quando` (istante UTC). */ + async function gira(quando: string) { + const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, { + method: "POST", + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + }, + body: JSON.stringify({ p_adesso: quando }), + }); + if (!res.ok) throw new Error(`genera_avvisi_compleanno: ${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 }; + } + + type Slot = { id: string; nome: string; nascita: string | null; attivo: boolean }; + type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string }; + + const slot = () => leggi("giocatori_squadra?select=id,nome,nascita,attivo&order=id"); + const notifiche = () => + leggi( + `notifiche_utente?tipo=in.(${TIPI})&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id`, + ); + const registro = (giorno: string) => + leggi<{ giocatore_id: string }>( + `compleanni_notificati?giorno=eq.${giorno}&select=giocatore_id&order=giocatore_id`, + ); + const impostaNascita = (id: string, nascita: string | null) => + scrivi(`giocatori_squadra?id=eq.${id}`, "PATCH", { nascita }, tokenAdmin); + const svuota = async () => { + await rest(`notifiche_utente?tipo=in.(${TIPI})`, SERVIZIO, { method: "DELETE" }); + await rest("compleanni_notificati?giorno=gte.2000-01-01", SERVIZIO, { method: "DELETE" }); + }; + + /** Chi compie gli anni nel giorno `iso` secondo il database: attivo e con lo stesso giorno e mese. */ + const festeggiatiDi = async (iso: string) => + (await slot()) + .filter((s) => s.attivo && s.nascita?.slice(5) === iso.slice(5)) + .map((s) => s.id) + .sort(); + const attivi = async () => + (await slot()) + .filter((s) => s.attivo) + .map((s) => s.id) + .sort(); + + let tokenAdmin = ""; + const idUtenti: string[] = []; + const originali = new Map>(); + + // Orari UTC corrispondenti a 07:30 e 08:30 a Roma: l'inverno è UTC+1, l'estate UTC+2. + const INVERNO = { prima: "T06:30:00Z", dopo: "T07:30:00Z" }; + const ESTATE = { prima: "T05:30:00Z", dopo: "T06:30:00Z" }; + + try { + const admin = await creaUtente(`test-compleanno-admin-${Date.now()}@example.test`); + const allenatore = await creaUtente(`test-compleanno-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 [G4, G5, G6, INATTIVO, ALLENATORE]) { + const [riga] = await leggi>( + `giocatori_squadra?id=eq.${id}&select=nascita,tipo,attivo,auth_user_id`, + ); + originali.set(id, riga ?? { nascita: null, tipo: "giocatore", attivo: true }); + } + // Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin. + await scrivi( + `giocatori_squadra?id=eq.${ALLENATORE}`, + "PATCH", + { tipo: "allenatore", auth_user_id: allenatore.id }, + tokenAdmin, + ); + await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin); + // Nessuno dei cinque slot di prova parte con una nascita che possa confondere i casi. + for (const id of [G4, G5, G6, INATTIVO]) await impostaNascita(id, null); + await svuota(); + + const tutti = await slot(); + const nome = (id: string) => tutti.find((s) => s.id === id)!.nome; + const attiviIds = await attivi(); + assert.ok(attiviIds.includes(ALLENATORE), "l'allenatore è attivo"); + assert.ok(!attiviIds.includes(INATTIVO), "g9 è inattivo"); + + // --- Un compleanno --------------------------------------------------------------------- + // 15 marzo 2027, orario invernale: 07:30 a Roma è prima, 08:30 è dopo. + const GIORNO1 = "2027-03-15"; + await impostaNascita(G4, "1995-03-15"); + + await prova("prima delle 8:00 a Roma non parte nulla, il registro resta vuoto", async () => { + assert.deepEqual(await festeggiatiDi(GIORNO1), [G4], "solo g4 compie gli anni quel giorno"); + await gira(`${GIORNO1}${INVERNO.prima}`); + assert.equal((await notifiche()).length, 0); + assert.equal((await registro(GIORNO1)).length, 0); + }); + + await prova( + "dalle 8:00: auguri al festeggiato, avviso con il solo nome a tutti gli altri", + async () => { + await gira(`${GIORNO1}${INVERNO.dopo}`); + const righe = await notifiche(); + const mie = righe.filter((r) => r.giocatore_id === G4); + assert.equal(mie.length, 1); + assert.equal(mie[0]!.tipo, "compleanno_auguri"); + assert.equal(mie[0]!.titolo, `Buon compleanno, ${nome(G4)}! 🎂`); + assert.equal(mie[0]!.corpo, "", "solo il titolo"); + + const altri = righe.filter((r) => r.giocatore_id !== G4); + assert.deepEqual( + altri.map((r) => r.giocatore_id).sort(), + attiviIds.filter((id) => id !== G4), + "tutti gli altri membri attivi, allenatore compreso, e nessun inattivo", + ); + for (const r of altri) { + assert.equal(r.tipo, "compleanno"); + assert.equal(r.titolo, `Oggi è il compleanno di ${nome(G4)}`); + assert.equal(r.corpo, "", "solo il titolo, niente età"); + } + assert.equal(righe.length, attiviIds.length, "una notifica a testa"); + assert.deepEqual( + (await registro(GIORNO1)).map((r) => r.giocatore_id), + attiviIds, + ); + }, + ); + + await prova("l'allenatore riceve l'avviso, l'inattivo niente", async () => { + const righe = await notifiche(); + assert.equal(righe.filter((r) => r.giocatore_id === ALLENATORE).length, 1); + assert.equal(righe.filter((r) => r.giocatore_id === INATTIVO).length, 0); + }); + + await prova("push ed email vanno in coda per ogni notifica", async () => { + const ids = (await notifiche()).map((r) => r.id); + const mail = await leggi<{ notifica_id: string }>( + `notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`, + ); + const push = await leggi<{ notifica_id: string }>( + `notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`, + ); + assert.equal(mail.length, ids.length, "una mail per notifica"); + assert.equal(push.length, ids.length, "una push per notifica"); + }); + + await prova("un secondo giro lo stesso giorno non genera doppioni", async () => { + await gira(`${GIORNO1}T09:30:00Z`); + assert.equal((await notifiche()).length, attiviIds.length); + }); + + await prova("eliminare la notifica non la fa tornare", async () => { + const [prima] = await notifiche(); + await scrivi(`notifiche_utente?id=eq.${prima!.id}`, "DELETE"); + await gira(`${GIORNO1}T10:30:00Z`); + assert.equal((await notifiche()).length, attiviIds.length - 1); + }); + + await prova("un giorno senza compleanni non genera nulla, né registro", async () => { + await svuota(); + const giorno = "2027-03-20"; + assert.deepEqual(await festeggiatiDi(giorno), [], "nessuno compie gli anni il 20 marzo"); + await gira(`${giorno}${INVERNO.dopo}`); + assert.equal((await notifiche()).length, 0); + assert.equal((await registro(giorno)).length, 0); + }); + + // --- Ora legale ------------------------------------------------------------------------ + await prova("con l'ora legale le 8:00 di Roma sono le 6:00 UTC", async () => { + await svuota(); + const giorno = "2027-07-15"; + await impostaNascita(G4, "1995-07-15"); + assert.deepEqual(await festeggiatiDi(giorno), [G4]); + await gira(`${giorno}${ESTATE.prima}`); // 07:30 a Roma + assert.equal((await notifiche()).length, 0, "alle 7:30 a Roma non parte ancora"); + await gira(`${giorno}${ESTATE.dopo}`); // 08:30 a Roma + assert.equal((await notifiche()).length, attiviIds.length); + }); + + // --- Più compleanni lo stesso giorno --------------------------------------------------- + await prova( + "due compleanni: un solo messaggio a testa, nomi in ordine alfabetico", + async () => { + await svuota(); + const giorno = "2027-03-16"; + await impostaNascita(G4, "1995-03-16"); + await impostaNascita(G5, "1990-03-16"); + assert.deepEqual(await festeggiatiDi(giorno), [G4, G5]); + await gira(`${giorno}${INVERNO.dopo}`); + + const righe = await notifiche(); + assert.equal(righe.length, attiviIds.length, "un solo messaggio per destinatario"); + const nomi = [nome(G4), nome(G5)].sort((a, b) => a.localeCompare(b, "it")); + for (const id of [G4, G5]) { + const r = righe.find((x) => x.giocatore_id === id)!; + assert.equal(r.tipo, "compleanno_auguri"); + assert.equal(r.titolo, `Buon compleanno, ${nome(id)}! 🎂`); + assert.equal(r.corpo, "", "solo il titolo"); + assert.ok(!r.titolo.includes(nome(id === G4 ? G5 : G4)), "niente nomi degli altri"); + } + const altri = righe.filter((r) => ![G4, G5].includes(r.giocatore_id)); + assert.equal(altri.length, attiviIds.length - 2); + for (const r of altri) { + assert.equal(r.tipo, "compleanno"); + assert.equal(r.titolo, `Oggi è il compleanno di ${nomi[0]} e ${nomi[1]}`); + assert.equal(r.corpo, "", "solo il titolo"); + } + }, + ); + + await prova("tre compleanni: «A, B e C»", async () => { + await svuota(); + const giorno = "2027-03-17"; + await impostaNascita(G4, "1995-03-17"); + await impostaNascita(G5, "1990-03-17"); + await impostaNascita(G6, "2000-03-17"); + assert.deepEqual(await festeggiatiDi(giorno), [G4, G5, G6]); + await gira(`${giorno}${INVERNO.dopo}`); + const nomi = [nome(G4), nome(G5), nome(G6)].sort((a, b) => a.localeCompare(b, "it")); + const altro = (await notifiche()).find((r) => ![G4, G5, G6].includes(r.giocatore_id))!; + assert.equal(altro.titolo, `Oggi è il compleanno di ${nomi[0]}, ${nomi[1]} e ${nomi[2]}`); + assert.equal(altro.corpo, "", "solo il titolo"); + }); + + await prova("un festeggiato inattivo non conta: né avviso né auguri", async () => { + await svuota(); + const giorno = "2027-03-18"; + for (const id of [G4, G5, G6]) await impostaNascita(id, null); + await impostaNascita(INATTIVO, "2003-03-18"); + assert.deepEqual(await festeggiatiDi(giorno), [], "g9 è inattivo"); + await gira(`${giorno}${INVERNO.dopo}`); + assert.equal((await notifiche()).length, 0); + }); + + // --- 29 febbraio: limite noto (DD-047) ------------------------------------------------- + await prova("chi è nato il 29 febbraio è avvisato solo negli anni bisestili", async () => { + await svuota(); + await impostaNascita(INATTIVO, null); + await impostaNascita(G4, "2000-02-29"); + // 2027 non è bisestile: né il 28 febbraio né il 1° marzo. + await gira(`2027-02-28${INVERNO.dopo}`); + await gira(`2027-03-01${INVERNO.dopo}`); + assert.equal((await notifiche()).length, 0, "negli anni normali non parte nulla"); + // 2028 è bisestile: il 29 febbraio sì. + assert.deepEqual(await festeggiatiDi("2028-02-29"), [G4]); + await gira(`2028-02-29${INVERNO.dopo}`); + const righe = await notifiche(); + assert.equal(righe.length, attiviIds.length); + assert.equal(righe.find((r) => r.giocatore_id === G4)!.tipo, "compleanno_auguri"); + }); + + // --- Pianificazione -------------------------------------------------------------------- + await prova("il job è pianificato ogni ora", async () => { + const contenitore = spawnSync( + "docker", + ["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"], + { encoding: "utf8" }, + ) + .stdout.trim() + .split("\n")[0]; + assert.ok(contenitore, "container del database locale non trovato"); + const esito = spawnSync( + "docker", + [ + "exec", + contenitore, + "psql", + "-U", + "postgres", + "-At", + "-c", + "select schedule from cron.job where jobname = 'avvisi-compleanno'", + ], + { encoding: "utf8" }, + ); + assert.equal(esito.status, 0, esito.stderr); + assert.equal(esito.stdout.trim(), "0 * * * *"); + }); + + await prova("il job non è chiamabile dai client", async () => { + for (const token of [ANON, admin.token]) { + const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, { + method: "POST", + headers: { + apikey: ANON, + Authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: "{}", + }); + assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterlo eseguire`); + } + }); + } finally { + await svuota(); + if (tokenAdmin) { + for (const [id, valori] of originali) { + await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify(valori), + }); + } + } + for (const id of idUtenti) { + await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, { + method: "DELETE", + headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` }, + }); + } + riepilogo("notifiche-compleanno"); + } +} diff --git a/test/unit/notifiche-utente.test.ts b/test/unit/notifiche-utente.test.ts index 966aa99..4c98f7c 100644 --- a/test/unit/notifiche-utente.test.ts +++ b/test/unit/notifiche-utente.test.ts @@ -59,6 +59,8 @@ const tipiNuovi: TipoNotifica[] = [ "sollecita_presenze_24h", "sollecita_presenze_12h", "sollecita_presenze_6h", + "compleanno", + "compleanno_auguri", ]; const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP"; for (const tipo of tipiNuovi) { @@ -67,6 +69,13 @@ for (const tipo of tipiNuovi) { assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`); } +// Compleanno (DD-047): titolo e basta, il corpo è vuoto e resta vuoto (la riga non lo inventa). +for (const tipo of ["compleanno", "compleanno_auguri"] as const) { + const n = daRiga({ ...riga, tipo, titolo: "Buon compleanno, Marco! 🎂", corpo: "" }); + assert.equal(n.tipo, tipo); + assert.equal(n.corpo, "", `${tipo}: corpo vuoto`); +} + // Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza // `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico. const uiBits = readFileSync( diff --git a/test/unit/push-sw.test.ts b/test/unit/push-sw.test.ts index d188369..9bbec90 100644 --- a/test/unit/push-sw.test.ts +++ b/test/unit/push-sw.test.ts @@ -57,6 +57,12 @@ const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [ titolo: "🏐 Palloni", testo: "Sabato tocca a te.", }, + { + // Compleanno (DD-047): solo il titolo. Il corpo vuoto resta vuoto, non diventa il testo di ripiego. + data: { json: () => ({ title: "Buon compleanno, Marco! 🎂", body: "" }) }, + titolo: "Buon compleanno, Marco! 🎂", + testo: "", + }, { data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." }, { data: {