2 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 985fc2fcc1 Notifica di compleanno: auguri al festeggiato, avviso agli altri (DD-047)
Ogni giorno, dalle 8:00 (Europe/Rome), un job pg_cron orario manda a chi compie
gli anni «Buon compleanno, <Nome>! 🎂» e a tutti gli altri membri attivi,
allenatori compresi, «Oggi è il compleanno di <Nome>» (con più compleanni lo
stesso giorno un solo messaggio a testa, nomi in ordine alfabetico). Solo
titolo, senza corpo né età. Canali: push, centro notifiche ed email.

- migration m31: tipi `compleanno` e `compleanno_auguri`, registro
  `compleanni_notificati` (una sola volta per destinatario e giorno, anche se la
  notifica viene eliminata), funzione `genera_avvisi_compleanno()` e job
  `avvisi-compleanno`, trigger push esteso ai nuovi tipi. Non ancora applicata
  in produzione.
- 29 febbraio non gestito: chi è nato quel giorno è avvisato solo negli anni
  bisestili (limite noto, documentato).
- test: integrazione `notifiche-compleanno` (ora e ora legale, destinatari,
  più compleanni, registro, coda push/email, 29 febbraio, job pianificato);
  unit sui nuovi tipi e sul corpo vuoto del service worker.
- documentazione: DD-047, catalogo e testi delle notifiche, DATABASE, CHANGELOG,
  ROADMAP, PROJECT_STATE.

Chiude la issue #11.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:18:22 +02:00
davideandClaude Sonnet 5.5 7347fdba6e Avviso certificati mancanti (DD-046), dipendenze aggiornate, versione 1.2.3
Il giocatore senza certificato vede una card rossa cliccabile, l'admin
l'elenco dei mancanti sopra scaduti e in scadenza. Nessuna migration.

Aggiorna @tanstack/react-start alla 1.168.60 (con router, plugin e query) e
chiude le vulnerabilità di npm audit: Vercel bloccava il deploy con la
1.168.34. Il tipo di error in __root.tsx diventa unknown, come nel nuovo tipo
di TanStack.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:29:47 +02:00
18 changed files with 965 additions and 2095 deletions

No files matched your search

+4 -3
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 01/10/2026 Ultimo aggiornamento: 06/10/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in 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 [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. 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 - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte - **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie `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. `foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
+12 -1
View File
@@ -13,15 +13,26 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [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 ## [1.2.3] - 2026-10-04
Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration. Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
### Modificato ### Modificato
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa: - **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046). il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
### Sicurezza
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
## [1.2.2] - 2026-10-01 ## [1.2.2] - 2026-10-01
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
+13 -11
View File
@@ -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) | | `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) | | `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) | | `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) | | `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) | | `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) | | `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. - 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 - Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039). 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_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali. `sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi - 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`) ### 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 riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040. [modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` | | Funzione (migration) | Cosa fa | Job `pg_cron` |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | | `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, 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_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | | `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26, 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_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. | — | | `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) |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | | `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. | — |
| `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). | — | | `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 ## Badge
+3
View File
@@ -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 - [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) 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 ## Prossimo
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina - [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
+65
View File
@@ -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, <Nome>! 🎂`, **senza corpo**;
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **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 <A> e <B>` (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).
+1
View File
@@ -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-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-046](DD-046.md) | Avviso certificati mancanti | | [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore | | [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo | | [DD-044](DD-044.md) | Via la foto tessera dal profilo |
+1
View File
@@ -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 . | | Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
| Turno palloni | no | I turni riguardano solo i giocatori. | | Turno palloni | no | I turni riguardano solo i giocatori. |
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. | | Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| 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. | | Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
+1 -1
View File
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi ## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, 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 `m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
+32 -21
View File
@@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti: indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito - **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, - **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato. 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 ([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
quelle del turno palloni**, che restano solo push e in-app: DD-040). quelle del turno palloni**, che restano solo push e in-app: DD-040).
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email | | # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- | | --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
| 1 | ~~Promemoria a 24 ore~~ — **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`). | — | — | — | — | | 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`). | — | — | — | — | | 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì | | 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **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) | | 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ì | | 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | | 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | | 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
| 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 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 promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
lo stesso evento (vedi «Centro notifiche in-app»). lo stesso evento (vedi «Centro notifiche in-app»).
@@ -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 [notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`. disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
| # | Notifica | Titolo | Corpo | | # | Notifica | Titolo | Corpo |
| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) | | 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. | | 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca | | 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` | | 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta | | 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore | | 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | | 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, 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` Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`, (`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
@@ -103,6 +106,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
- Assenze e infortuni. - Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore. - La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035). - 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 ### 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 - **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 sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app. (avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
- **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») - **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere. e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
@@ -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` | | Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` | | Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Promemoria con «oggi/domani», turno palloni a 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` | | 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` | | Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) | | L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
+249 -2046
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -20,10 +20,10 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1", "@tanstack/react-query": "^5.104.1",
"@tanstack/react-router": "^1.170.18", "@tanstack/react-router": "^1.170.41",
"@tanstack/react-start": "^1.168.32", "@tanstack/react-start": "^1.168.60",
"@tanstack/router-plugin": "^1.168.23", "@tanstack/router-plugin": "^1.168.42",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
+3 -1
View File
@@ -24,7 +24,9 @@ export type TipoNotifica =
| "sollecita_presenze_24h" | "sollecita_presenze_24h"
| "sollecita_presenze_12h" | "sollecita_presenze_12h"
| "sollecita_presenze_6h" | "sollecita_presenze_6h"
| "sondaggio_cacche"; | "sondaggio_cacche"
| "compleanno"
| "compleanno_auguri";
export type NotificaUtente = { export type NotificaUtente = {
id: string; id: string;
+1 -1
View File
@@ -43,7 +43,7 @@ function NotFoundComponent() {
); );
} }
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) { function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
console.error(error); console.error(error);
const router = useRouter(); const router = useRouter();
@@ -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();
+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | Cartella | Cosa verifica | Serve rete? |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No | | `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-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ì | | `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ì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Server di sviluppo con Node vecchio ## Server di sviluppo con Node vecchio
@@ -0,0 +1,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<T>(percorso: string): Promise<T[]> {
const res = await rest(percorso, SERVIZIO);
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
return (await res.json()) as T[];
}
async function scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
const res = await rest(percorso, token, {
method: metodo,
body: corpo === undefined ? null : JSON.stringify(corpo),
});
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
}
/** 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<Slot>("giocatori_squadra?select=id,nome,nascita,attivo&order=id");
const notifiche = () =>
leggi<Riga>(
`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<string, Record<string, unknown>>();
// 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<Record<string, unknown>>(
`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");
}
}
+9
View File
@@ -59,6 +59,8 @@ const tipiNuovi: TipoNotifica[] = [
"sollecita_presenze_24h", "sollecita_presenze_24h",
"sollecita_presenze_12h", "sollecita_presenze_12h",
"sollecita_presenze_6h", "sollecita_presenze_6h",
"compleanno",
"compleanno_auguri",
]; ];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP"; const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) { 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`); 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 // 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. // `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
const uiBits = readFileSync( const uiBits = readFileSync(
+6
View File
@@ -57,6 +57,12 @@ const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [
titolo: "🏐 Palloni", titolo: "🏐 Palloni",
testo: "Sabato tocca a te.", 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: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
{ {
data: { data: {