Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12011d330 | ||
|
|
7143c3d2eb | ||
|
|
71b2d97fae | ||
|
|
9b4e3e4b45 | ||
|
|
563142a30a | ||
|
|
07624f6b9f |
No files matched your search
+7
-8
@@ -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
|
||||
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
|
||||
portato il ruolo allenatore. La versione corrente è la 1.2.0: notifiche su tre canali (push, in-app
|
||||
ed email) e promemoria che non si ripetono (vedi `docs/CHANGELOG.md`).
|
||||
portato il ruolo allenatore. La versione corrente è la 1.2.1: notifiche su tre canali (push, in-app
|
||||
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
|
||||
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
|
||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||
- 36 migration in `supabase/migrations/`, fino a `m25_promemoria_solo_giocatori` (29/09/2026).
|
||||
Le prime 33 (fino a `m22_notifiche_email`) sono applicate in produzione, verificato con
|
||||
`npx supabase migration list` il 29/09/2026; **`m23`, `m24` e `m25` sono verificate solo in locale
|
||||
e non ancora applicate in produzione**
|
||||
- 37 migration in `supabase/migrations/`, fino a `m26_notifiche_automatiche` (30/09/2026), tutte
|
||||
applicate in produzione, verificato con `npx supabase migration list` il 30/09/2026
|
||||
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
|
||||
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
|
||||
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
|
||||
@@ -207,8 +206,8 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0) e le notifiche su tre
|
||||
canali (DD-036–DD-039, 1.2.0) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||
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) 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».
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,55 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [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
|
||||
|
||||
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
|
||||
|
||||
+26
-10
@@ -75,16 +75,32 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
|
||||
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
|
||||
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
|
||||
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. |
|
||||
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. |
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. 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). 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. |
|
||||
| `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`. 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
|
||||
|
||||
|
||||
@@ -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-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-040](#dd-040--promemoria-con-oggi-o-domani-turno-palloni-e-solleciti-automatici) | Palloni e solleciti automatici |
|
||||
|
||||
**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
|
||||
|
||||
**Stato:** accettata · **Data:** settembre 2026
|
||||
@@ -1710,6 +1714,9 @@ Vercel e avevano già i tre canali.
|
||||
allenatori, anche di giocatori non più attivi.
|
||||
- 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**
|
||||
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
|
||||
@@ -1755,3 +1762,99 @@ restano invariati.
|
||||
**Riesame**
|
||||
Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme
|
||||
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.
|
||||
@@ -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,
|
||||
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
|
||||
([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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Worker email: installazione e gestione
|
||||
|
||||
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.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)
|
||||
|
||||
@@ -114,7 +114,7 @@ a lui (`usePuoGestireEventi()`).
|
||||
|
||||
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
|
||||
`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,
|
||||
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). |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -50,7 +50,9 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
||||
## Quali notifiche e a chi
|
||||
|
||||
**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
|
||||
**non** vengono inviate: il trigger non ha backfill.
|
||||
|
||||
|
||||
+100
-33
@@ -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
|
||||
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
|
||||
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
|
||||
la colonna Email coincide con la colonna In-app).
|
||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | ----- |
|
||||
| 1 | **Promemoria a 24 ore** — «Promemoria: _titolo_», corpo «data alle ora» | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì |
|
||||
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Tocca a te prendere i palloni» / «Porta i palloni» | Un admin, con il pulsante nella pagina dell'evento. Solo eventi di tipo allenamento o partita. Route `promemoria-palloni`, tipo `turno_palloni`. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | sì |
|
||||
| 5 | **Sollecito presenze** — «Manca la tua risposta» | Un admin o un allenatore, con il pulsante nella pagina dell'evento. Route `sollecita-presenze`, tipo `sollecita_presenze`. | I giocatori attivi (mai gli allenatori) che non hanno ancora risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | **Promemoria a 24 ore** — «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ì |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **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** — «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ì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
|
||||
Tutte le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3-6) la mandano le route dell'app, quelle
|
||||
dei promemoria (1 e 2) 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
|
||||
per lo stesso evento, e lo stesso fa il sondaggio (vedi «Centro notifiche in-app»).
|
||||
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 e 6) la mandano le route dell'app, quelle
|
||||
automatiche (1, 2, 4 e 5) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||
|
||||
> **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).
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
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.
|
||||
- **Le notifiche 3, 4 e 5, una volta eliminate, non tornano da sole:** riappaiono solo se un admin
|
||||
rimanda il messaggio o ripreme il pulsante.
|
||||
- **Le notifiche eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
|
||||
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.**
|
||||
- **Quattro notifiche su sei dipendono da un gesto manuale** (3, 4, 5 e 6): se nessuno preme il
|
||||
pulsante, nessuno viene avvisato. Gli unici automatismi sono i due promemoria.
|
||||
- **Per ogni evento i giocatori ricevono al massimo due mail automatiche** (24 e 3 ore prima),
|
||||
più quelle dei pulsanti manuali.
|
||||
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
|
||||
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
|
||||
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
|
||||
- **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»)
|
||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| Cosa | Test |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
| Cosa | Test |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| 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` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
|
||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||
@@ -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
|
||||
(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`,
|
||||
`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
|
||||
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 |
|
||||
| `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
|
||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||
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
|
||||
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
|
||||
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)
|
||||
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
|
||||
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
|
||||
— 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
|
||||
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)
|
||||
|
||||
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
|
||||
|
||||
+13
-5
@@ -10,8 +10,8 @@
|
||||
## Obiettivo
|
||||
|
||||
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
|
||||
giorno stesso. Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -26,7 +26,7 @@ compaiono.
|
||||
|
||||
## 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"`,
|
||||
scarta eventi extra-campo e compleanni.
|
||||
- `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`
|
||||
|
||||
> **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
|
||||
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
|
||||
@@ -69,8 +76,9 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
||||
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
|
||||
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.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
|
||||
@@ -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
|
||||
↓
|
||||
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
|
||||
│ (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
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ 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
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
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
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
|
||||
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
|
||||
(DD-024).
|
||||
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron`
|
||||
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
|
||||
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
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -110,7 +110,9 @@ function RigaNotifica({
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
<button
|
||||
|
||||
@@ -133,6 +133,12 @@ export type RigaClassifica = {
|
||||
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) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
|
||||
|
||||
@@ -16,7 +16,14 @@ export type TipoNotifica =
|
||||
| "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";
|
||||
|
||||
export type NotificaUtente = {
|
||||
|
||||
+39
-23
@@ -1,4 +1,4 @@
|
||||
import { formatData } from "./crapp-data";
|
||||
import { formatDataNumerica } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { dataOggi } from "./scout-live";
|
||||
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. */
|
||||
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[] {
|
||||
return eventi
|
||||
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
|
||||
.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
|
||||
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
|
||||
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
|
||||
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
|
||||
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
|
||||
* «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(
|
||||
turni: Record<string, string>,
|
||||
@@ -153,29 +161,37 @@ export function avvisiPalloniEvento(
|
||||
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
|
||||
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];
|
||||
if (incaricato) {
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
if (!incaricato) return [];
|
||||
|
||||
// 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 precedente = prima ? turni[prima.id] : undefined;
|
||||
if (precedente && precedente !== incaricato) {
|
||||
avvisi.push({
|
||||
giocatoreId: precedente,
|
||||
titolo: "Porta i palloni",
|
||||
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
|
||||
titolo: "Turno palloni: riconsegna",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
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ù
|
||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
|
||||
* Chi va sollecitato per un evento (DD-040): i destinatari dell'evento — i convocati, o tutta la
|
||||
* rosa se `convocati` è vuoto — che non hanno ancora risposto o hanno risposto «forse». Mai gli
|
||||
* 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(
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
convocati: string[] = [],
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
|
||||
return squadra
|
||||
.filter(inRosa)
|
||||
.filter((g) => convocati.length === 0 || convocati.includes(g.id))
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
@@ -101,6 +105,29 @@ export function destinatariSollecito(
|
||||
.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:
|
||||
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
|
||||
|
||||
@@ -2,10 +2,9 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediGestoreEventi } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.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";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -35,19 +34,24 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
.eq("evento_id", evento.id);
|
||||
|
||||
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 });
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth")
|
||||
.select("endpoint, giocatore_id, p256dh, auth")
|
||||
.in("giocatore_id", destinatari);
|
||||
|
||||
const titolo = "Manca la tua risposta";
|
||||
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
|
||||
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
|
||||
} una conferma: presente, assente o in ritardo?`;
|
||||
// Il testo dice se il giocatore non ha risposto o ha risposto «forse» (DD-040): è
|
||||
// quindi per destinatario, e la push porta il testo del proprio destinatario.
|
||||
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
|
||||
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
|
||||
// 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) => ({
|
||||
giocatore_id: giocatoreId,
|
||||
tipo: "sollecita_presenze",
|
||||
titolo,
|
||||
corpo: testo,
|
||||
titolo: testi.get(giocatoreId)?.titolo,
|
||||
corpo: testi.get(giocatoreId)?.testo,
|
||||
evento_id: evento.id,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
@@ -68,8 +72,10 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
const avviso = testi.get(iscrizione.giocatore_id);
|
||||
if (!avviso) continue;
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.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
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
||||
|
||||
## Struttura
|
||||
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26, 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ì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
|
||||
## Server di sviluppo con Node vecchio
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ if (!locale) {
|
||||
const ALLENATORE = "g6";
|
||||
const PARTITA = `${PREFISSO}-partita`;
|
||||
const PRESENZE = `${PREFISSO}-presenze`;
|
||||
const PRESENZE_CONVOCATI = `${PREFISSO}-presenze-convocati`;
|
||||
const PALLONI_1 = `${PREFISSO}-palloni-1`;
|
||||
const PALLONI_2 = `${PREFISSO}-palloni-2`;
|
||||
const GENERICO = `${PREFISSO}-generico`;
|
||||
@@ -150,10 +151,10 @@ if (!locale) {
|
||||
/** A quali slot è arrivata una push dall'ultimo `svuota()`, senza ripetizioni e in ordine. */
|
||||
const destinatariPush = () => [...new Set(ricevute)].sort();
|
||||
|
||||
type Notifica = { giocatore_id: string; titolo: string; tipo: string };
|
||||
type Notifica = { giocatore_id: string; titolo: string; corpo: string; tipo: string };
|
||||
const notifiche = (filtro: string) =>
|
||||
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);
|
||||
|
||||
@@ -191,6 +192,7 @@ if (!locale) {
|
||||
for (const [id, tipo, data] of [
|
||||
[PARTITA, "partita", "2031-04-01"],
|
||||
[PRESENZE, "allenamento", "2031-04-02"],
|
||||
[PRESENZE_CONVOCATI, "partita", "2031-04-03"],
|
||||
[PALLONI_1, "allenamento", "2031-05-01"],
|
||||
[PALLONI_2, "allenamento", "2031-05-08"],
|
||||
[GENERICO, "evento", "2031-05-09"],
|
||||
@@ -201,7 +203,8 @@ if (!locale) {
|
||||
titolo: `${PREFISSO} ${id}`,
|
||||
data,
|
||||
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 () => {
|
||||
const res = await chiama("sollecita-presenze", giocatore.token, { eventoId: PRESENZE });
|
||||
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`);
|
||||
assert.deepEqual(slot(righe), [G_UNO, G_DUE]);
|
||||
const per = new Map(righe.map((r) => [r.giocatore_id, r.titolo]));
|
||||
assert.equal(per.get(G_DUE), "Tocca a te prendere i palloni");
|
||||
assert.equal(per.get(G_UNO), "Porta i palloni");
|
||||
assert.equal(per.get(G_DUE), "Turno palloni: incarico assegnato");
|
||||
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(
|
||||
@@ -427,6 +513,7 @@ if (!locale) {
|
||||
for (const id of [
|
||||
PARTITA,
|
||||
PRESENZE,
|
||||
PRESENZE_CONVOCATI,
|
||||
PALLONI_1,
|
||||
PALLONI_2,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -217,20 +217,39 @@ if (!locale) {
|
||||
assert.equal(riga?.inviata_il, null);
|
||||
});
|
||||
|
||||
await prova("ogni tipo di notifica esistente viene accodato", async () => {
|
||||
for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) {
|
||||
await prova("ogni tipo di notifica con email viene accodato", async () => {
|
||||
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 });
|
||||
creati.push(id);
|
||||
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 () => {
|
||||
const id = await creaNotifica(SLOT_COLLEGATO);
|
||||
creati.push(id);
|
||||
@@ -243,7 +262,7 @@ if (!locale) {
|
||||
|
||||
await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => {
|
||||
const id = await creaNotifica(SLOT_COLLEGATO, {
|
||||
tipo: "turno_palloni",
|
||||
tipo: "sollecita_presenze",
|
||||
evento_id: EVENTO_UPSERT,
|
||||
});
|
||||
creati.push(id);
|
||||
@@ -251,7 +270,36 @@ if (!locale) {
|
||||
await esito(id, "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, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
||||
@@ -266,10 +314,7 @@ if (!locale) {
|
||||
}),
|
||||
});
|
||||
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);
|
||||
assert.equal(await coda(id), undefined, "nemmeno dopo il rinvio");
|
||||
});
|
||||
|
||||
await prova(
|
||||
|
||||
@@ -27,6 +27,9 @@ if (!locale) {
|
||||
const EVENTO = `${PREFISSO}-a`;
|
||||
const EVENTO_LONTANO = `${PREFISSO}-lontano`;
|
||||
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 G5 = "g5";
|
||||
const G6 = "g6";
|
||||
@@ -201,6 +204,99 @@ if (!locale) {
|
||||
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 () => {
|
||||
const { data, ora } = { data: tra(2).data, ora: tra(2).ora };
|
||||
await scrivi("eventi_app", "POST", {
|
||||
@@ -217,7 +313,14 @@ if (!locale) {
|
||||
assert.equal((await registro(EVENTO_CASCATA)).length, 0);
|
||||
});
|
||||
} 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" });
|
||||
}
|
||||
riepilogo("promemoria-eventi");
|
||||
|
||||
@@ -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(
|
||||
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
|
||||
async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { formatData, giocatori, statoMeta } from "@/lib/crapp-data";
|
||||
import { formatData, formatDataNumerica, giocatori, statoMeta } from "@/lib/crapp-data";
|
||||
|
||||
// --- rosa --------------------------------------------------------------------
|
||||
assert.ok(giocatori.length > 0, "la rosa non è vuota");
|
||||
@@ -29,6 +29,15 @@ for (const g of giocatori) {
|
||||
|
||||
// --- formatData --------------------------------------------------------------
|
||||
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.ok(
|
||||
!formatData("2026-03-29").includes("28"),
|
||||
|
||||
@@ -197,6 +197,24 @@ await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () =>
|
||||
assert.equal(escapeHtml(`<&>"'`), "<&>"'");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const m = componiMail(notifica({ oggetto: "<b>Ciao</b>" }), OPZ);
|
||||
assert.ok(!m.html.includes("<b>"));
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
contatoreBadge,
|
||||
daRiga,
|
||||
pallinoNotifiche,
|
||||
type RigaNotifica,
|
||||
type TipoNotifica,
|
||||
} from "@/lib/notifiche-utente";
|
||||
|
||||
// --- 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.
|
||||
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");
|
||||
@@ -179,20 +179,46 @@ assert.deepEqual(
|
||||
);
|
||||
|
||||
// --- 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");
|
||||
assert.deepEqual(
|
||||
avvisi.map((a) => a.giocatoreId),
|
||||
["g2", "g1"],
|
||||
"l'incaricato di questo evento, e chi ha i palloni dalla volta prima",
|
||||
);
|
||||
assert.match(avvisi[0]!.titolo, /prendere i palloni/);
|
||||
assert.match(avvisi[1]!.titolo, /Porta i palloni/);
|
||||
assert.equal(avvisi[0]!.titolo, "Turno palloni: incarico assegnato");
|
||||
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(
|
||||
avvisi.every((a) => !/oggi/i.test(a.testo)),
|
||||
"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(
|
||||
avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId),
|
||||
["g1"],
|
||||
@@ -213,6 +239,26 @@ assert.deepEqual(
|
||||
[],
|
||||
"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 -----
|
||||
const eventiSerie: Evento[] = [
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
includeInfortunato,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
testoSollecito,
|
||||
totaliEventiGiocatore,
|
||||
type MappaPresenze,
|
||||
type MappaTempiRisposta,
|
||||
@@ -158,6 +159,65 @@ assert.deepEqual(
|
||||
);
|
||||
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 --------------------
|
||||
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
|
||||
...ev(id, "allenamento", data),
|
||||
|
||||
Reference in new issue
Block a user