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