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

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

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

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

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

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

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

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

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

No files matched your search

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