Promemoria senza ritorni e tre canali per ogni notifica (DD-037, DD-038)

m23: un registro (promemoria_eventi_generati) ricorda i promemoria a 24 e 3 ore
già generati, così eliminare la notifica non li fa rigenerare dal cron né
riaccodare la mail a ogni giro.

m24: la push dei promemoria, che nascono nel database, la manda il worker
mailer/ con lo stesso modulo dell'app (coda notifiche_push_coda, esiti, backoff,
iscrizioni scadute eliminate, push scartate oltre 3 ore). Il sondaggio
pre-partita scrive anche notifiche_utente (tipo sondaggio_cacche): compare
in-app e arriva per email. L'immagine del worker si costruisce dalla radice
del repository per includere src/lib/webpush.server.ts.

Test: 9 di integrazione per m23, 17 per la coda push, 6 per la route del
sondaggio, nuovi casi unitari sul worker; prova end-to-end con un servizio
push finto. Documentazione e catalogo delle notifiche aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 14:19:54 +02:00
1 parent 58eba642f7
commit e9013b703b
27 files changed
+2076 -92

No files matched your search

+16 -2
View File
@@ -24,8 +24,10 @@ 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`
- 33 migration in `supabase/migrations/`, fino a `m22_notifiche_email` (29/09/2026), tutte - 35 migration in `supabase/migrations/`, fino a `m24_push_promemoria_e_sondaggio` (29/09/2026).
applicate in produzione (verificato con `npx supabase migration list` il 29/09/2026) Le prime 33 (fino a `m22_notifiche_email`) sono applicate in produzione, verificato con
`npx supabase migration list` il 29/09/2026; **`m23` e `m24` 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 è
@@ -59,6 +61,18 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il - Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
in `npx supabase migration list` del 24/09/2026) in `npx supabase migration list` del 24/09/2026)
- Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda`
con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo
`sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email).
Additiva; verificata in locale con `npx supabase db reset`, i test di integrazione e una prova
end-to-end del worker con un servizio push finto, **non ancora in produzione**. Richiede il
worker ricostruito e le chiavi VAPID nel suo `.env` (vedi `docs/WORKER_EMAIL.md`)
- Migration `m23_promemoria_gia_generati` (29/09/2026, DD-037): tabella
`promemoria_eventi_generati` e nuova `genera_promemoria_eventi()`, così un promemoria a 24 o 3
ore eliminato dal centro notifiche non viene più rigenerato dal cron (e non riaccoda mail).
Corregge un difetto presente dalla `m17`, emerso con l'arrivo delle email. Additiva; verificata
in locale con `npx supabase db reset` e `test/integration/promemoria-eventi.test.ts`, **non
ancora in produzione**
- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e - Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e
`preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni `preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni
RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema
+16
View File
@@ -13,6 +13,22 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
### Modificato
- **Ogni notifica arriva su tutti e tre i canali** (DD-038): i promemoria a 24 e 3 ore, che
arrivavano solo in-app e per email, partono ora anche come push, mandata dal worker `mailer/`
(migration `m24_push_promemoria_e_sondaggio`). Il sondaggio pre-partita, che era solo push,
compare anche nel centro notifiche e arriva per email ai giocatori attivi (non agli allenatori).
Il worker va ricostruito e configurato con le chiavi VAPID dell'app; senza, manda solo le email.
### Corretto
- **I promemoria dell'evento non ricompaiono più se li elimini** — il promemoria a 24 e a 3 ore
eliminato dal centro notifiche veniva rigenerato dal job automatico al giro successivo, come non
letto e, con le email, con una nuova mail a ogni giro fino all'inizio dell'evento. Ora il job
ricorda ciò che ha già generato (migration `m23_promemoria_gia_generati`, DD-037). Il difetto
esisteva dal centro notifiche (`m17`) e si è notato con l'arrivo delle email.
### Aggiunto ### Aggiunto
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, - **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff,
+12 -8
View File
@@ -21,6 +21,8 @@ nessuna di esse da M4 (DD-011).
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | | `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) | | `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) | | `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | | `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | | `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) | | `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
@@ -73,14 +75,16 @@ 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`. Quattro sorgenti, tutte in parallelo alla push esistente: 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`), questi ultimi due 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. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); 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. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). | | `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. | | `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. |
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. |
## Funzioni speciali ## Funzioni speciali
+124
View File
@@ -52,6 +52,8 @@ Serve a rispondere a domande del tipo:
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore | | [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home | | [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail | | [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
| [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 |
**In valutazione** **In valutazione**
@@ -1588,3 +1590,125 @@ Se serve un mittente col dominio della squadra, se le mail finiscono in spam anc
giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco
affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron` affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron`
al posto del container. al posto del container.
### DD-037 — I promemoria evento ricordano di essere già stati generati
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
I promemoria a 24 e a 3 ore (M17, DD-030) evitavano i doppioni con `ON CONFLICT DO NOTHING` sul
vincolo `UNIQUE (giocatore_id, evento_id, tipo)` di `notifiche_utente`: la riga della notifica
era l'unica traccia che il promemoria fosse già stato generato. Il giocatore però può eliminarla
(swipe o ×, DD-030). Senza più la riga, al giro successivo del job (ogni ora per il promemoria a
24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il promemoria veniva
rigenerato come non letto. Con il canale email (DD-036) ogni rigenerazione accodava anche una
mail nuova: un giocatore che eliminava un promemoria ne riceveva una ogni ora fino all'inizio
dell'evento. Il difetto è stato trovato controllando la logica il 29/09/2026, il giorno in cui il
worker email è entrato in produzione, e riprodotto sul database locale.
**Decisione**
Un registro separato, `promemoria_eventi_generati` (migration `m23_promemoria_gia_generati`), con
una riga per `(giocatore_id, evento_id, tipo)` scritta dalla funzione `genera_promemoria_eventi()`.
La notifica in-app si crea solo per le righe **appena registrate**, quindi eliminarla non fa
ripartire nulla. Il registro è indipendente da `notifiche_utente`, senza policy per i client, e
sparisce con l'evento o con il giocatore (`ON DELETE CASCADE`). I promemoria già presenti al
momento della migration sono stati registrati come generati, altrimenti la prima notifica
eliminata dopo la migration sarebbe tornata come prima. Il vincolo `UNIQUE` di `notifiche_utente`
resta come seconda difesa. Cron, criteri di selezione, testi e destinatari sono invariati.
**Alternative scartate**
- Non eliminare davvero la notifica (nasconderla con un flag) → scartata: cambia il significato
dell'eliminazione per tutte le sorgenti, tocca la RLS e le query del client, e la tabella
crescerebbe di righe che il giocatore non vede più.
- Togliere l'eliminazione dai soli promemoria, lasciando «segna come letta» → scartata: a un
giocatore che vuole ripulire l'elenco resterebbero notifiche non eliminabili, incoerenti con
le altre.
- Un flag «già generato» sulla riga dell'evento → scartata: il promemoria è per giocatore, e chi
viene aggiunto ai convocati dopo deve comunque riceverlo.
- Confrontare `creato_il` o lo stato «letta» per capire se ricrearlo → scartata: non distingue
una notifica mai generata da una eliminata.
**Conseguenze**
- Eliminare un promemoria lo fa sparire per sempre, e non parte nessuna mail in più. Il numero
massimo di mail automatiche per giocatore ed evento resta due (24 e 3 ore).
- Una tabella in più, che cresce di una riga per giocatore, evento e tipo e si svuota con
l'evento.
- Se un evento viene spostato dopo che il promemoria è partito, non ne parte uno nuovo per la
data nuova: è lo stesso comportamento di prima, ora esplicito.
- `test/integration/promemoria-eventi.test.ts` è la definizione eseguibile: il promemoria
eliminato non ricompare, non riaccoda mail, i due tipi sono indipendenti, un convocato aggiunto
dopo lo riceve comunque, il registro non è leggibile né scrivibile da un client e sparisce con
l'evento. Sul database con la funzione vecchia il test fallisce.
**Riesame**
Se gli eventi potessero essere spostati con frequenza e servisse un nuovo promemoria per la data
nuova, il registro va agganciato anche alla data di inizio, oltre a giocatore, evento e tipo.
### DD-038 — Ogni notifica ha tre canali: la push dei promemoria parte dal worker
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
Il requisito è che ogni notifica arrivi su tutti e tre i canali: push, centro notifiche in-app ed
email. Due casi non lo rispettavano ([catalogo](modules/notifiche.md#catalogo-delle-notifiche)):
- i **promemoria a 24 e 3 ore** nascono dentro il database (job `pg_cron`, M17), che non può
firmare né cifrare una push Web (VAPID, RFC 8291): arrivavano solo in-app e per email;
- il **sondaggio pre-partita** partiva solo come push e non scriveva in `notifiche_utente`, quindi
non compariva in-app e, dato che le email nascono da quella tabella (DD-036), non arrivava per
email.
Le altre notifiche (messaggio dello staff, turno palloni, sollecito presenze) partono dall'app su
Vercel e avevano già i tre canali.
**Decisione**
1. **Sondaggio pre-partita:** la route `apri-sondaggio` scrive anche `notifiche_utente` (tipo
nuovo `sondaggio_cacche`, un `upsert` per giocatore ed evento come le altre), per i giocatori
attivi che non sono allenatori. La mail parte dal trigger di M22 senza altro lavoro. La push
resta com'è. Il vincolo `CHECK` sul tipo si estende con la migration `m24`.
2. **Push dei promemoria:** la manda il **worker `mailer/`**, con un secondo canale accanto alle
email. Un trigger accoda ogni promemoria a 24 o 3 ore in `notifiche_push_coda` (stessa
struttura di `notifiche_email_coda`); il worker prende il lotto con `FOR UPDATE SKIP LOCKED`,
manda la push a tutti i dispositivi iscritti del giocatore e registra l'esito. Usa **lo stesso
modulo dell'app** (`src/lib/webpush.server.ts`, senza dipendenze) invece di una copia: il
container si costruisce dalla radice del repository. Le iscrizioni che rispondono 404 o 410
vengono eliminate, come nelle route. Le push in coda da oltre 3 ore si scartano: un promemoria
arrivato a evento iniziato è solo rumore.
3. La push dei promemoria non dipende dall'interruttore «Email»: dipende solo dai dispositivi
iscritti, come le altre push.
**Alternative scartate**
- Far chiamare all'app dal database (`pg_net`, già abilitata) una route che manda la push →
scartata: il database dipenderebbe dall'indirizzo pubblico di Vercel e da un segreto condiviso,
e `pg_net` è un'estensione del provider (regola 4 di [PORTABILITA.md](PORTABILITA.md)).
- Vercel Cron → scartato: sul piano gratuito gira una volta al giorno, come già notato in DD-036.
- Copiare il modulo push nel worker → scartata: due versioni da tenere allineate per codice che
cifra e firma.
- Mandare dal worker la push di tutte le notifiche → scartata: le altre partono già dall'app, si
avrebbero push doppie.
- Un unico stato di coda per email e push → scartata: le due consegne hanno esiti, tentativi e
destinatari diversi (una push per dispositivo, un'email per giocatore).
**Conseguenze**
- Il worker non fa più solo email: gestisce due canali (il documento operativo resta
[WORKER_EMAIL.md](WORKER_EMAIL.md)). Serve la chiave VAPID privata anche sull'host, un segreto in
più. Senza le chiavi il worker parte lo stesso e manda solo le email, segnalandolo nel log.
- La push dei promemoria dipende dall'host di casa acceso, come le email: se è spento parte alla
ripartenza, ma non oltre le 3 ore dalla creazione.
- Una tabella nuova (`notifiche_push_coda`) e una migration (`m24_push_promemoria_e_sondaggio`).
- Il sondaggio compare nel centro notifiche e arriva per email ai giocatori attivi, non agli
allenatori; la push continua ad andare a tutti i dispositivi iscritti tranne quelli degli
allenatori, anche di giocatori non più attivi.
- Le altre notifiche non cambiano canali e non ricevono push doppie.
**Riesame**
Se l'host di casa si rivela poco affidabile per una notifica sensibile al tempo come la push, o se
il database dovesse comunque chiamare l'app per altro, valutare di nuovo la chiamata dal database
all'app.
+10 -10
View File
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
## Stato attuale ## Stato attuale
| Componente | Portabile? | Note | | Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. | | Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. | | Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. | | Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). | | Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. | | Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. | | Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC) e con un server SMTP a scelta ([WORKER_EMAIL.md](WORKER_EMAIL.md)). | | Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. | | `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche ## Regole da rispettare nelle prossime modifiche
+66 -33
View File
@@ -1,12 +1,16 @@
# Worker email: installazione e gestione # Worker email: installazione e gestione
Come si mette in funzione, si controlla e si diagnostica il worker che invia le notifiche per email Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
(`mailer/`). Cosa fa e come si comporta il canale sta in per email e la push dei promemoria a 24 e 3 ore. Cosa fa il canale email sta in
[modules/notifiche-email.md](modules/notifiche-email.md); perché è fatto così in [modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa). [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)
e [DD-038](DESIGN_DECISIONS.md#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker).
Il nome del documento resta quello delle email, che sono il canale principale.
**Stato in produzione:** migration `m22` applicata e worker in funzione dal 29/09/2026, su un **Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026,
Raspberry Pi con Docker. su un Raspberry Pi con Docker. La push dei promemoria (`m24`) richiede le chiavi VAPID nel `.env` del
worker e la ricostruzione del container.
## Cosa serve ## Cosa serve
@@ -16,8 +20,13 @@ Raspberry Pi con Docker.
| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). | | Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). |
| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. | | Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. |
| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). | | Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). |
| Chiavi VAPID | Le stesse `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` dell'app su Vercel: per la push dei promemoria. |
Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Si
costruisce dalla **radice del repository** (`docker-compose.yml` imposta `context: ..`), perché include
anche `src/lib/webpush.server.ts`, lo stesso modulo push dell'app: `docker compose up -d --build`
va comunque lanciato dentro `mailer/`. `mailer/Dockerfile.dockerignore` ammette nel contesto solo i
file usati, quindi nemmeno il `.env` finisce nell'immagine.
## Account Gmail e password per app ## Account Gmail e password per app
@@ -54,17 +63,23 @@ dominio proprio (vedi DD-036).
2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila: 2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila:
| Variabile | Default | Contenuto | | Variabile | Default | Contenuto |
| --------------------------------------- | ------------------------------ | ---------------------------------------------------------------- | | --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
| `SUPABASE_URL` | — | URL del progetto | | `SUPABASE_URL` | — | URL del progetto |
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password | | `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) | | `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
| `APP_URL` | — | base dei link nelle mail, in https e senza slash finale | | `APP_URL` | — | base dei link nelle mail, in https e senza slash finale |
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente | | `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
| `POLL_SECONDI` | 30 | intervallo di polling | | `POLL_SECONDI` | 30 | intervallo di polling |
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail | | `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail |
| `MAIL_LOTTO` | 10 | mail prese a ogni giro | | `MAIL_LOTTO` | 10 | mail prese a ogni giro |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | — | chiavi per la push dei promemoria (vanno insieme; senza, solo email) |
| `VAPID_SUBJECT` | `mailto:crapp@crapvolley.it` | contatto nell'intestazione VAPID, come nell'app |
Le chiavi VAPID devono essere **le stesse dell'app**: le iscrizioni push dei dispositivi sono
legate alla chiave pubblica con cui sono nate, e con chiavi diverse i servizi push rifiutano l'invio
(403). Se mancano il worker parte lo stesso e nel log compare «push dei promemoria: DISATTIVATA».
`SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un `SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un
server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il
@@ -79,7 +94,8 @@ dominio proprio (vedi DD-036).
3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha 3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host. `restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate»; 4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate» e
«push dei promemoria: attiva»;
dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore
dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi. dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi.
@@ -114,7 +130,8 @@ Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N m
limit 50; limit 50;
``` ```
Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Lo stesso per le push dei
promemoria, con la tabella `notifiche_push_coda` (colonne `stato`, `tentativi`, `errore`, `inviata_il`).
Il testo del messaggio sta in `titolo` (oggetto) e `corpo`. Il testo del messaggio sta in `titolo` (oggetto) e `corpo`.
## Spostare il worker su un altro host ## Spostare il worker su un altro host
@@ -144,16 +161,19 @@ controlla la posta dell'account mittente e conferma l'accesso.
## Diagnosi ## Diagnosi
| Sintomo | Causa probabile | Cosa fare | | Sintomo | Causa probabile | Cosa fare |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. | | Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. |
| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. | | Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. |
| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. | | Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. |
| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. | | Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. |
| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. | | Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. |
| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. | | La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. |
| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. | | Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. |
| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. | | L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. |
| Il log dice «push dei promemoria: DISATTIVATA» | Mancano `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` nel `.env` | Aggiungile (le stesse dell'app) e ricrea il container. |
| Push dei promemoria in `fallita` con errore `403` | Chiavi VAPID diverse da quelle dell'app, o payload rifiutato | Controlla le chiavi; la query sotto mostra l'errore. |
| Push dei promemoria `saltata` | Il giocatore non ha dispositivi iscritti, o il promemoria aveva oltre 3 ore | Normale se nessuno ha attivato le notifiche o il worker è stato fermo a lungo. |
## Prova in locale, senza toccare la produzione ## Prova in locale, senza toccare la produzione
@@ -201,13 +221,26 @@ Per provare anche l'immagine Docker, il container si collega alla rete di Supaba
(`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come (`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come
`SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP. `SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP.
Per provare la **push dei promemoria** senza un telefono: si genera una coppia di chiavi VAPID di
prova, si avvia un piccolo server HTTP locale che finge di essere il servizio push (risponde 201 su un
percorso e 410 su un altro), si inseriscono in `push_subscriptions` due iscrizioni con chiavi ECDH
valide che puntano a quel server e si crea un promemoria in `notifiche_utente`. Il worker lo cifra, lo
spedisce con `Authorization: vapid …`, `Content-Encoding: aes128gcm`, `Urgency: high` e `TTL`, segna
la riga `inviata` ed elimina l'iscrizione che ha risposto 410. Il 29/09/2026 la prova è stata fatta
così.
L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite
non parte, e con lui i test di integrazione che avviano il server. I test del database e del worker non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node:
non ne hanno bisogno. `bun --bun x vite dev --port 8080` (con `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` e
`SUPABASE_SERVICE_ROLE_KEY` dello stack locale) e poi `BASE_URL=http://localhost:8080` davanti ai
test. I test del database e del worker non ne hanno bisogno.
## Sicurezza ## Sicurezza
- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat. - Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat.
- La service role non passa da Vercel: sta solo sull'host del worker. - La service role non passa da Vercel: sta solo sull'host del worker. Anche la chiave VAPID
**privata** sta sull'host, oltre che su Vercel: stessa attenzione, mai in git né in chat.
- Il log delle push riporta solo id notifica e numeri: mai gli endpoint, che identificano il
dispositivo del giocatore.
- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e - Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e
dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`. dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`.
+6 -2
View File
@@ -50,10 +50,14 @@ 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. Un tipo nuovo che in futuro finisse in quella tabella 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita. 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.
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
arrivano anche per email (DD-038).
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche **Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
in-app. Gli admin e gli allenatori non hanno un percorso a parte: ricevono ciò che riceverebbero in-app. Gli admin e gli allenatori non hanno un percorso a parte: ricevono ciò che riceverebbero
come giocatori, secondo i destinatari già definiti in M17 e DD-034. come giocatori, secondo i destinatari già definiti in M17 e DD-034.
@@ -121,7 +125,7 @@ modificare solo in parte (DD-016).
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`. 1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in 2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
`notifiche_email_coda`. L'`upsert` di turno palloni e sollecito presenze riscrive `creato_il`: è `notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING` un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
dei cron non toccano quella colonna e non accodano nulla. dei cron non toccano quella colonna e non accodano nulla.
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante 3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
+105 -10
View File
@@ -14,7 +14,8 @@ centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti: indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze). - **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge, - **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato. serie e obiettivi appena raggiunti; non è un canale push separato.
@@ -25,6 +26,71 @@ particolare il certificato in scadenza **non** genera push né voci nel centro n
--- ---
## Catalogo delle notifiche
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, quindi
la colonna Email coincide con la colonna In-app).
| # | 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; gli allenatori attivi sempre, convocati o no. | sì (worker) | sì | sì |
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Tocca a te prendere i palloni» / «Porta i palloni» | Un admin, con il pulsante nella pagina dell'evento. Solo eventi di tipo allenamento o partita. Route `promemoria-palloni`, tipo `turno_palloni`. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | sì |
| 5 | **Sollecito presenze** — «Manca la tua risposta» | Un admin o un allenatore, con il pulsante nella pagina dell'evento. Route `sollecita-presenze`, tipo `sollecita_presenze`. | I giocatori attivi (mai gli allenatori) che non hanno ancora risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta; nessun invio verso altri dispositivi. | no (solo locale) | no | no |
Tutte le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038). La
differenza sta in come parte la push: quelle dei pulsanti (3-6) la mandano le route dell'app, quelle
dei promemoria (1 e 2) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Le righe 4 e 5 riscrivono la notifica esistente se l'admin preme di nuovo il pulsante
per lo stesso evento, e lo stesso fa il sondaggio (vedi «Centro notifiche in-app»).
### Cosa non genera nessuna notifica
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
dell'inizio (DD-030).
- L'arrivo di un risultato, di una pagella o di un voto MVP.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
### Comportamenti da conoscere
Sono l'effetto attuale della logica, non scelte documentate altrove:
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
allenamenti e partite.
- **Un evento creato a poche ore dall'inizio riceve subito i promemoria che gli spettano:** se
mancano meno di 24 ore parte il promemoria «24 ore» al primo giro del job orario, e se ne
mancano meno di 3 anche quello «3 ore», a distanza di pochi minuti.
- **Ogni promemoria si genera una sola volta per giocatore, evento e tipo, anche se il giocatore
lo elimina** (M23, DD-037): il job registra ciò che ha generato in `promemoria_eventi_generati`,
una tabella indipendente da `notifiche_utente`. Eliminare la notifica dal centro notifiche non
la fa tornare e non accoda una nuova mail. Prima di M23 tornava: la riga eliminata veniva
rigenerata al giro successivo del job, con una nuova mail a ogni giro fino all'inizio
dell'evento.
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
- **Le notifiche 3, 4 e 5, una volta eliminate, non tornano da sole:** riappaiono solo se un admin
rimanda il messaggio o ripreme il pulsante.
- **Un evento già iniziato non genera più promemoria.**
- **Quattro notifiche su sei dipendono da un gesto manuale** (3, 4, 5 e 6): se nessuno preme il
pulsante, nessuno viene avvisato. Gli unici automatismi sono i due promemoria.
- **Per ogni evento i giocatori ricevono al massimo due mail automatiche** (24 e 3 ore prima),
più quelle dei pulsanti manuali.
- **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.
---
## Dati ## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e `push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
@@ -62,9 +128,10 @@ copre in particolare le sessioni lunghe della webapp (vedi il
- **`push-config`** — espone la sola chiave pubblica VAPID. - **`push-config`** — espone la sola chiave pubblica VAPID.
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo. - **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i - **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in
[Scout Live](scout-live.md)). `notifiche_utente` per i giocatori attivi, così compare nel centro notifiche e parte per email
(DD-038; vedi [Scout Live](scout-live.md)).
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
@@ -131,23 +198,27 @@ 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).
Quattro sorgenti scrivono in `notifiche_utente`, mai il client: Cinque sorgenti scrivono in `notifiche_utente`, mai il client:
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).
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15 2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata da un vincolo (o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
`UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda `promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
l'avvicinarsi della data, non il momento in cui è stato messo in calendario. l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina 3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
da `avvisiPalloniEvento()`, in parallelo alla push. da `avvisiPalloniEvento()`, in parallelo alla push.
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una 4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push. riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
Turno palloni e sollecito presenze usano un `upsert` su `(giocatore_id, evento_id, tipo)` Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
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`.
@@ -164,12 +235,36 @@ sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la × che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta. ritrova per sempre nell'elenco, solo segnata come letta.
## Push dei promemoria (M24)
I promemoria a 24 e 3 ore nascono nel database (job `pg_cron`), che non può firmare né cifrare una
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
DD-038).
1. Quando il job crea un promemoria, un trigger lo accoda in `notifiche_push_coda` (solo i tipi
`evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano già la push
dall'app, e una seconda sarebbe un doppione).
2. Il worker, a ogni giro, chiama `prendi_push_promemoria()`, che restituisce testo e dispositivi
iscritti del giocatore, e manda la push a **tutti** i suoi dispositivi con lo stesso modulo
dell'app (`src/lib/webpush.server.ts`), quindi con le stesse regole di cifratura, urgenza e TTL.
3. Un solo esito per notifica: `inviata` se almeno un dispositivo accetta; `riprova` con backoff
1 · 5 · 30 · 120 minuti sugli errori temporanei (429, 5xx, rete), poi `fallita`; `fallita`
subito su un rifiuto permanente (400, 401, 403, 413: tipicamente chiavi VAPID sbagliate);
`saltata` se le iscrizioni erano tutte scadute o il giocatore non ne ha.
4. Le iscrizioni che rispondono 404 o 410 vengono eliminate da `push_subscriptions`, come fanno le
route.
5. Una push in coda da oltre 3 ore dalla creazione del promemoria si scarta (`saltata`): a evento
ormai iniziato sarebbe solo rumore. Vale se il worker resta fermo a lungo.
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.
## 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
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md). un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
indipendenti e le quattro sorgenti scrivono `notifiche_utente` come prima. Il certificato in indipendenti e le sorgenti scrivono `notifiche_utente` come prima. Il certificato in
scadenza resta una card in Home e non genera mail (DD-035). scadenza resta una card in Home e non genera mail (DD-035).
## Limiti noti ## Limiti noti
-5
View File
@@ -1,5 +0,0 @@
node_modules
.env
.env.*
!.env.example
README.md
+6
View File
@@ -8,6 +8,12 @@ SUPABASE_SERVICE_ROLE_KEY=
SMTP_USER=crapp.notifiche@gmail.com SMTP_USER=crapp.notifiche@gmail.com
SMTP_PASS= SMTP_PASS=
# Chiavi VAPID per la push dei promemoria (M24): le stesse VAPID_* dell'app su Vercel, altrimenti i
# dispositivi rifiutano la push. Vanno impostate insieme; se mancano il worker manda solo le email.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:tuo@indirizzo
# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail # URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail
APP_URL=https://<dominio-dell-app> APP_URL=https://<dominio-dell-app>
+7 -3
View File
@@ -1,7 +1,11 @@
# Contesto di build: la radice del repository (vedi docker-compose.yml e Dockerfile.dockerignore).
# Il layout dentro l'immagine rispecchia quello del repository, così mailer.mjs importa
# ../src/lib/webpush.server.ts allo stesso modo in locale e nel container.
FROM oven/bun:1-alpine FROM oven/bun:1-alpine
WORKDIR /app WORKDIR /app/mailer
COPY package.json bun.lock bunfig.toml ./ COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
RUN bun install --frozen-lockfile --production RUN bun install --frozen-lockfile --production
COPY mailer.mjs mailer-core.ts healthcheck.mjs ./ COPY mailer/mailer.mjs mailer/mailer-core.ts mailer/healthcheck.mjs ./
COPY src/lib/webpush.server.ts /app/src/lib/webpush.server.ts
USER bun USER bun
CMD ["bun", "mailer.mjs"] CMD ["bun", "mailer.mjs"]
+11
View File
@@ -0,0 +1,11 @@
# Il contesto di build è la radice del repository (serve src/lib/webpush.server.ts, lo stesso modulo
# push dell'app): si esclude tutto e si ammettono solo i file che l'immagine usa. Così non finiscono
# nel contesto né il .env né il resto del progetto.
*
!mailer/package.json
!mailer/bun.lock
!mailer/bunfig.toml
!mailer/mailer.mjs
!mailer/mailer-core.ts
!mailer/healthcheck.mjs
!src/lib/webpush.server.ts
+3 -1
View File
@@ -1,6 +1,8 @@
services: services:
mailer: mailer:
build: . build:
context: ..
dockerfile: mailer/Dockerfile
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
healthcheck: healthcheck:
+145
View File
@@ -39,6 +39,8 @@ export type Config = {
from: string; from: string;
fromName: string; fromName: string;
appUrl: string; appUrl: string;
/** Chiavi VAPID per la push dei promemoria (M24); `null` = solo email. */
vapid: { publicKey: string; privateKey: string; subject: string | null } | null;
pollSecondi: number; pollSecondi: number;
limiteGiorno: number; limiteGiorno: number;
lotto: number; lotto: number;
@@ -78,6 +80,11 @@ export function leggiConfig(env: Record<string, string | undefined>): LetturaCon
if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme"); if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme");
const from = testo("MAIL_FROM") || user; const from = testo("MAIL_FROM") || user;
if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)"); if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)");
const vapidPubblica = testo("VAPID_PUBLIC_KEY");
const vapidPrivata = testo("VAPID_PRIVATE_KEY");
if (Boolean(vapidPubblica) !== Boolean(vapidPrivata)) {
errori.push("VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme");
}
const porta = intero("SMTP_PORT", 587); const porta = intero("SMTP_PORT", 587);
const pollSecondi = intero("POLL_SECONDI", 30); const pollSecondi = intero("POLL_SECONDI", 30);
const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400); const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400);
@@ -97,6 +104,14 @@ export function leggiConfig(env: Record<string, string | undefined>): LetturaCon
from, from,
fromName: testo("MAIL_FROM_NAME") || "CrAPP", fromName: testo("MAIL_FROM_NAME") || "CrAPP",
appUrl, appUrl,
vapid:
vapidPubblica && vapidPrivata
? {
publicKey: vapidPubblica,
privateKey: vapidPrivata,
subject: testo("VAPID_SUBJECT") || null,
}
: null,
pollSecondi, pollSecondi,
limiteGiorno, limiteGiorno,
lotto, lotto,
@@ -334,3 +349,133 @@ export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise<Risu
} }
return risultato; return risultato;
} }
// --- Push dei promemoria (M24) ----------------------------------------------------------------
/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
/** Riga restituita da `prendi_push_promemoria()`. */
export type PushDaInviare = {
id_notifica: string;
oggetto: string;
testo: string;
tentativi_fatti: number;
iscrizioni: IscrizionePush[];
};
export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata";
export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente";
/**
* Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410
* iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403,
* 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido.
*/
export function classificaStatoPush(stato: number): EsitoStatoPush {
if (stato >= 200 && stato < 300) return "ok";
if (stato === 404 || stato === 410) return "scaduta";
if (stato === 429 || stato >= 500) return "temporaneo";
return "permanente";
}
export type DipendenzePush = {
prendi: (max: number) => Promise<PushDaInviare[]>;
segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise<void>;
invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>;
elimina: (endpoint: string) => Promise<void>;
adesso: () => Date;
log: (messaggio: string) => void;
};
export type RisultatoCicloPush = {
inviate: number;
riprovate: number;
fallite: number;
saltate: number;
iscrizioniRimosse: number;
};
/**
* Un giro delle push dei promemoria: prende un lotto e manda ogni notifica a tutti i dispositivi
* del giocatore. L'esito è uno solo per notifica: `inviata` se almeno un dispositivo ha accettato;
* altrimenti `riprova` (con backoff, poi `fallita`) se qualcuno ha dato un errore temporaneo o è
* fallita la rete; `fallita` se c'è stato solo un rifiuto permanente; `saltata` se le iscrizioni
* erano tutte scadute. I log riportano solo id e numeri: mai gli endpoint, che identificano il
* dispositivo.
*/
export async function elaboraCicloPush(
d: DipendenzePush,
opzioni: { lotto: number },
): Promise<RisultatoCicloPush> {
const risultato: RisultatoCicloPush = {
inviate: 0,
riprovate: 0,
fallite: 0,
saltate: 0,
iscrizioniRimosse: 0,
};
for (const push of await d.prendi(opzioni.lotto)) {
let accettate = 0;
let temporanei = 0;
let permanenti = 0;
const codici: string[] = [];
for (const iscrizione of push.iscrizioni) {
let stato: number | null = null;
try {
stato = (await d.invia(iscrizione, push.oggetto, push.testo)).stato;
} catch {
temporanei += 1;
codici.push("rete");
continue;
}
const tipo = classificaStatoPush(stato);
if (tipo === "ok") accettate += 1;
else {
codici.push(String(stato));
if (tipo === "temporaneo") temporanei += 1;
else if (tipo === "permanente") permanenti += 1;
else {
try {
await d.elimina(iscrizione.endpoint);
risultato.iscrizioniRimosse += 1;
} catch {
d.log(`iscrizione scaduta non eliminata (notifica ${push.id_notifica})`);
}
}
}
}
const motivo = codici.join(",") || undefined;
if (accettate > 0) {
await d.segna(push.id_notifica, "inviata");
risultato.inviate += 1;
d.log(
`push inviata ${push.id_notifica} (${accettate}/${push.iscrizioni.length} dispositivi)`,
);
} else if (temporanei > 0) {
const ritardo = ritardoRiprova(push.tentativi_fatti);
if (ritardo === null) {
await d.segna(push.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`push fallita ${push.id_notifica} (tentativi finiti, ${motivo})`);
} else {
await d.segna(push.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
risultato.riprovate += 1;
d.log(`push da riprovare ${push.id_notifica} tra ${ritardo} min (${motivo})`);
}
} else if (permanenti > 0) {
await d.segna(push.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`push fallita ${push.id_notifica} (${motivo})`);
} else {
await d.segna(push.id_notifica, "saltata", motivo);
risultato.saltate += 1;
d.log(`push saltata ${push.id_notifica} (nessun dispositivo utile)`);
}
}
return risultato;
}
+50 -4
View File
@@ -1,9 +1,11 @@
// Worker delle notifiche email (M22, DD-036): legge la coda su Supabase e invia via SMTP. // Worker delle notifiche (M22 email, M24 push dei promemoria; DD-036, DD-038): legge le code su
// Solo chiamate in uscita, nessuna porta da aprire. Uso: bun mailer/mailer.mjs // Supabase e invia via SMTP e via Web Push. Solo chiamate in uscita, nessuna porta da aprire.
// La logica sta in mailer-core.ts; qui c'è solo la colla con rete e SMTP. // Uso: bun mailer/mailer.mjs. La logica sta in mailer-core.ts; qui c'è solo la colla con rete,
// SMTP e il modulo push dell'app (src/lib/webpush.server.ts, lo stesso usato dalle route).
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import { elaboraCiclo, leggiConfig } from "./mailer-core.ts"; import { inviaPush } from "../src/lib/webpush.server.ts";
import { elaboraCiclo, elaboraCicloPush, leggiConfig } from "./mailer-core.ts";
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok"; const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
@@ -32,6 +34,22 @@ async function rpc(nome, corpo) {
return testo ? JSON.parse(testo) : null; return testo ? JSON.parse(testo) : null;
} }
/** Elimina un'iscrizione push scaduta (il servizio push ha risposto 404 o 410). */
async function eliminaIscrizione(endpoint) {
const risposta = await fetch(
`${config.supabaseUrl}/rest/v1/push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
{
method: "DELETE",
headers: {
apikey: config.serviceRoleKey,
Authorization: `Bearer ${config.serviceRoleKey}`,
},
signal: AbortSignal.timeout(15_000),
},
);
if (!risposta.ok) throw new Error(`delete push_subscriptions: HTTP ${risposta.status}`);
}
const trasporto = nodemailer.createTransport({ const trasporto = nodemailer.createTransport({
host: config.smtp.host, host: config.smtp.host,
port: config.smtp.port, port: config.smtp.port,
@@ -64,6 +82,21 @@ const dipendenze = {
log, log,
}; };
const dipendenzePush = {
prendi: (max) => rpc("prendi_push_promemoria", { p_max: max }),
segna: (id, esito, errore, prossimo) =>
rpc("esito_push_promemoria", {
p_id: id,
p_esito: esito,
p_errore: errore ?? null,
p_prossimo: prossimo ? prossimo.toISOString() : null,
}),
invia: (iscrizione, titolo, testo) => inviaPush(iscrizione, titolo, testo),
elimina: eliminaIscrizione,
adesso: () => new Date(),
log,
};
const opzioni = { const opzioni = {
limiteGiorno: config.limiteGiorno, limiteGiorno: config.limiteGiorno,
lotto: config.lotto, lotto: config.lotto,
@@ -76,6 +109,11 @@ for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo =
const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms)); const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms));
log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`); log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`);
log(
config.vapid
? "push dei promemoria: attiva"
: "push dei promemoria: DISATTIVATA (VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY assenti), solo email",
);
trasporto.verify().then( trasporto.verify().then(
() => log("SMTP raggiungibile e credenziali accettate"), () => log("SMTP raggiungibile e credenziali accettate"),
(errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`), (errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`),
@@ -90,6 +128,14 @@ while (!fermo) {
} catch (errore) { } catch (errore) {
console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`); console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`);
} }
if (config.vapid) {
// Un errore delle push non deve fermare le email né il segnale di salute del worker.
try {
await elaboraCicloPush(dipendenzePush, { lotto: config.lotto });
} catch (errore) {
console.error(`${new Date().toISOString()} ciclo push fallito: ${errore?.message ?? errore}`);
}
}
await attendi(config.pollSecondi * 1000); await attendi(config.pollSecondi * 1000);
} }
log("arrestato"); log("arrestato");
+1 -1
View File
@@ -69,7 +69,7 @@ export function SondaggioCacche({
toast.success( toast.success(
dati.inviate > 0 dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi` ? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con le notifiche attive", : "Avviso inviato in app e per email (nessun dispositivo con le notifiche attive)",
); );
} catch { } catch {
toast.error("Non sono riuscito a inviare la notifica"); toast.error("Non sono riuscito a inviare la notifica");
+26
View File
@@ -1,7 +1,33 @@
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 { inRosa, type TipoMembro } from "./giocatori-squadra";
import { oggiISO } from "./palloni-core"; import { oggiISO } from "./palloni-core";
/**
* Avviso «sondaggio pre-partita aperto» (push, centro notifiche ed email, DD-038): stesso testo su
* tutti e tre i canali.
*/
export function avvisoSondaggio(partita: { titolo: string; ora: string }): {
titolo: string;
testo: string;
} {
return {
titolo: "💩 Sondaggio pre-partita aperto",
testo: `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`,
};
}
/**
* Chi riceve l'avviso in-app e per email: i giocatori attivi, mai gli allenatori, che il sondaggio
* non riguarda (DD-034). Non dipende dai dispositivi iscritti alla push: l'avviso in-app e la mail
* arrivano anche a chi non ha attivato le notifiche.
*/
export function destinatariSondaggio(
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
): string[] {
return squadra.filter(inRosa).map((g) => g.id);
}
/** Ora di apertura del sondaggio, il giorno stesso della partita. */ /** Ora di apertura del sondaggio, il giorno stesso della partita. */
export const ORA_APERTURA_SONDAGGIO = 8; export const ORA_APERTURA_SONDAGGIO = 8;
+3 -2
View File
@@ -4,7 +4,7 @@ import { useGiocatoreId } from "./user-store";
/** /**
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non * Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
* lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un * lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il * messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in * turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
* parallelo alla push esistente). Qui si legge e si segna come letto, con * parallelo alla push esistente). Qui si legge e si segna come letto, con
@@ -16,7 +16,8 @@ export type TipoNotifica =
| "evento_promemoria_24h" | "evento_promemoria_24h"
| "evento_promemoria_3h" | "evento_promemoria_3h"
| "turno_palloni" | "turno_palloni"
| "sollecita_presenze"; | "sollecita_presenze"
| "sondaggio_cacche";
export type NotificaUtente = { export type NotificaUtente = {
id: string; id: string;
+23 -5
View File
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod"; import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server"; import { richiediAdmin } from "@/lib/auth-route.server";
import { avvisoSondaggio, destinatariSondaggio } from "@/lib/cacche";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server"; import { inviaPush } from "@/lib/webpush.server";
import { isAllenatore } from "@/lib/giocatori-squadra"; import { isAllenatore } from "@/lib/giocatori-squadra";
@@ -27,14 +29,30 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
const { data: tutte } = await supabaseAdmin const { data: tutte } = await supabaseAdmin
.from("push_subscriptions") .from("push_subscriptions")
.select("endpoint, p256dh, auth, giocatore_id"); .select("endpoint, p256dh, auth, giocatore_id");
const squadra = await leggiGiocatoriSquadra();
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034). // Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
const allenatori = new Set( const allenatori = new Set(squadra.filter(isAllenatore).map((g) => g.id));
(await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id),
);
const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id)); const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id));
const titolo = "💩 Sondaggio pre-partita aperto"; const { titolo, testo } = avvisoSondaggio(partita);
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
// Storico in-app ed email (DD-038): indipendente dai dispositivi iscritti alla push. Un
// upsert perché ripremere il pulsante per la stessa partita deve riportare la notifica a
// non letta, non fallire per il vincolo UNIQUE; la mail riparte dal trigger di M22.
// `types.ts` non include ancora `notifiche_utente`, stesso aggiramento delle altre route.
const client = supabaseAdmin as unknown as SupabaseClient;
await client.from("notifiche_utente").upsert(
destinatariSondaggio(squadra).map((giocatoreId) => ({
giocatore_id: giocatoreId,
tipo: "sondaggio_cacche",
titolo,
corpo: testo,
evento_id: partita.id,
letta: false,
creato_il: new Date().toISOString(),
})),
{ onConflict: "giocatore_id,evento_id,tipo" },
);
let inviate = 0; let inviate = 0;
for (const iscrizione of iscrizioni) { for (const iscrizione of iscrizioni) {
@@ -0,0 +1,72 @@
-- M23 — I promemoria evento non ricompaiono più se il giocatore elimina la notifica (DD-037)
--
-- Fino a M22 `genera_promemoria_eventi()` evitava i doppioni con `ON CONFLICT DO NOTHING` sul
-- vincolo UNIQUE (giocatore_id, evento_id, tipo) di `notifiche_utente`: la riga della notifica
-- era l'unica traccia che il promemoria fosse già stato generato. Ma il giocatore può eliminare
-- la notifica (swipe o ×, DD-030): senza più la riga, al giro successivo del job (ogni ora per
-- il promemoria a 24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il
-- promemoria veniva rigenerato come non letto e, dal trigger di M22, riaccodato per email.
--
-- Il registro `promemoria_eventi_generati` ricorda in modo indipendente che il promemoria è già
-- stato generato per (giocatore, evento, tipo). Eliminare la notifica non lo tocca: il
-- promemoria non torna, e non parte una nuova mail. Il registro sparisce con l'evento o con il
-- giocatore (ON DELETE CASCADE), come le altre tabelle collegate (DD-029).
CREATE TABLE public.promemoria_eventi_generati (
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
evento_id text NOT NULL REFERENCES public.eventi_app(id) ON DELETE CASCADE,
tipo text NOT NULL CHECK (tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h')),
generato_il timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (giocatore_id, evento_id, tipo)
);
COMMENT ON TABLE public.promemoria_eventi_generati IS
'Promemoria evento già generati per giocatore, evento e tipo: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M23, DD-037).';
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
ALTER TABLE public.promemoria_eventi_generati ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.promemoria_eventi_generati FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.promemoria_eventi_generati TO service_role;
-- I promemoria già presenti in `notifiche_utente` risultano generati. Senza questo, la prima
-- notifica eliminata dopo la migration verrebbe rigenerata come prima.
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, tipo
FROM public.notifiche_utente
WHERE tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h')
AND evento_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- Stessa firma e stessi criteri di M17 (i cron non cambiano). La differenza: prima si registra
-- il promemoria come generato, e la notifica si crea solo per le righe appena registrate. Il
-- vincolo UNIQUE su `notifiche_utente` resta come seconda difesa.
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
FROM public.eventi_app e
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
WHERE public.ora_evento_a_time(e.ora) IS NOT NULL
AND ((e.data + public.ora_evento_a_time(e.ora)) AT TIME ZONE 'Europe/Rome') - 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: ' || g.titolo,
to_char(g.data, 'DD/MM/YYYY') || ' alle ' || g.ora,
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;
$$;
@@ -0,0 +1,197 @@
-- M24 — Tre canali per ogni notifica: push dei promemoria e sondaggio in-app/email (DD-038)
--
-- Due lacune rispetto al requisito «ogni notifica arriva per push, in-app ed email»:
--
-- 1. I promemoria a 24 e 3 ore nascono nel database (job pg_cron, M17), che non può firmare né
-- cifrare una push Web: arrivavano solo in-app e per email. Qui si accodano in
-- `notifiche_push_coda`; la push la manda il worker `mailer/`, con lo stesso modulo
-- dell'app (`src/lib/webpush.server.ts`).
-- 2. Il sondaggio pre-partita partiva solo come push. La route `apri-sondaggio` ora scrive anche
-- `notifiche_utente` (tipo nuovo `sondaggio_cacche`): compare nel centro notifiche e la
-- mail parte dal trigger di M22. Serve solo estendere il vincolo CHECK sul tipo.
--
-- Come per le email (M22), la coda è una tabella a parte con RLS senza policy: la policy UPDATE
-- del giocatore su `notifiche_utente` gli permetterebbe di riportare a 'in_coda' una push già
-- partita. Solo la service role (il worker) la legge e la scrive.
-- --- Tipo `sondaggio_cacche` -------------------------------------------------------------------
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',
'sollecita_presenze',
'sondaggio_cacche'
)
);
-- --- Coda delle push dei promemoria ------------------------------------------------------------
CREATE TABLE public.notifiche_push_coda (
notifica_id uuid PRIMARY KEY REFERENCES public.notifiche_utente(id) ON DELETE CASCADE,
stato text NOT NULL DEFAULT 'in_coda'
CHECK (stato IN ('in_coda', 'in_invio', 'inviata', 'fallita', 'saltata')),
tentativi integer NOT NULL DEFAULT 0,
prossimo_tentativo timestamptz NOT NULL DEFAULT now(),
errore text,
inviata_il timestamptz,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.notifiche_push_coda IS
'Coda delle push per i promemoria evento (M24): una riga per notifica, scritta dal trigger e dal worker. Le altre notifiche mandano la push dall''app.';
CREATE INDEX notifiche_push_coda_da_inviare_idx
ON public.notifiche_push_coda (prossimo_tentativo)
WHERE stato = 'in_coda';
ALTER TABLE public.notifiche_push_coda ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.notifiche_push_coda FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.notifiche_push_coda TO service_role;
-- Solo i promemoria: turno palloni, sollecito presenze, messaggio dello staff e sondaggio la push
-- la mandano già dall'app, e una seconda dal worker sarebbe un doppione. Le notifiche già
-- presenti alla migration non si accodano: il trigger non ha backfill.
CREATE OR REPLACE FUNCTION public.accoda_push_promemoria()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.notifiche_push_coda (notifica_id)
VALUES (NEW.id)
ON CONFLICT (notifica_id) DO NOTHING;
RETURN NEW;
END;
$$;
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'))
EXECUTE FUNCTION public.accoda_push_promemoria();
-- --- RPC del worker ----------------------------------------------------------------------------
-- Prende fino a `p_max` push da spedire, le marca 'in_invio' e restituisce per ciascuna il testo e
-- le iscrizioni (dispositivi) del giocatore. `FOR UPDATE SKIP LOCKED`: due worker avviati per
-- errore non prendono mai la stessa riga. Prima ripulisce tre casi:
-- * righe ferme 'in_invio' da più di 10 minuti (worker morto a metà) tornano 'in_coda';
-- * promemoria creati da più di 3 ore diventano 'saltata': una push arrivata a evento iniziato
-- è solo rumore (worker fermo a lungo);
-- * giocatori senza nessun dispositivo iscritto diventano 'saltata', senza errore.
CREATE OR REPLACE FUNCTION public.prendi_push_promemoria(p_max integer DEFAULT 20)
RETURNS TABLE (
id_notifica uuid,
oggetto text,
testo text,
tentativi_fatti integer,
iscrizioni jsonb
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.notifiche_push_coda c
SET stato = 'in_coda', aggiornata_il = now()
WHERE c.stato = 'in_invio'
AND c.aggiornata_il < now() - interval '10 minutes';
UPDATE public.notifiche_push_coda c
SET stato = 'saltata', aggiornata_il = now()
FROM public.notifiche_utente n
WHERE c.notifica_id = n.id
AND c.stato = 'in_coda'
AND c.prossimo_tentativo <= now()
AND (
n.creato_il < now() - interval '3 hours'
OR NOT EXISTS (
SELECT 1 FROM public.push_subscriptions s WHERE s.giocatore_id = n.giocatore_id
)
);
RETURN QUERY
WITH scelte AS (
SELECT c.notifica_id
FROM public.notifiche_push_coda c
WHERE c.stato = 'in_coda' AND c.prossimo_tentativo <= now()
ORDER BY c.prossimo_tentativo
LIMIT greatest(p_max, 0)
FOR UPDATE SKIP LOCKED
),
presi AS (
UPDATE public.notifiche_push_coda c
SET stato = 'in_invio', aggiornata_il = now()
FROM scelte s
WHERE c.notifica_id = s.notifica_id
RETURNING c.notifica_id, c.tentativi
)
SELECT p.notifica_id,
n.titolo,
n.corpo,
p.tentativi,
coalesce(
(SELECT jsonb_agg(jsonb_build_object(
'endpoint', s.endpoint, 'p256dh', s.p256dh, 'auth', s.auth))
FROM public.push_subscriptions s
WHERE s.giocatore_id = n.giocatore_id),
'[]'::jsonb
)
FROM presi p
JOIN public.notifiche_utente n ON n.id = p.notifica_id
ORDER BY n.creato_il;
END;
$$;
-- Registra l'esito. Agisce solo su righe 'in_invio', così un esito in ritardo non ne sovrascrive
-- uno già deciso.
-- 'inviata' → almeno un dispositivo ha accettato la push
-- 'riprova' → errore temporaneo, torna in coda dopo `p_prossimo`, conta un tentativo
-- 'fallita' → definitiva (rifiuto permanente o tentativi finiti)
-- 'saltata' → nessun dispositivo utile (tutte le iscrizioni erano scadute)
CREATE OR REPLACE FUNCTION public.esito_push_promemoria(
p_id uuid,
p_esito text,
p_errore text DEFAULT NULL,
p_prossimo timestamptz DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF p_esito NOT IN ('inviata', 'riprova', 'fallita', 'saltata') THEN
RAISE EXCEPTION 'esito non valido: %', p_esito;
END IF;
UPDATE public.notifiche_push_coda c
SET stato = CASE p_esito
WHEN 'riprova' THEN 'in_coda'
ELSE p_esito
END,
tentativi = c.tentativi + CASE WHEN p_esito IN ('riprova', 'fallita') THEN 1 ELSE 0 END,
prossimo_tentativo = CASE
WHEN p_esito = 'riprova' THEN coalesce(p_prossimo, now())
ELSE c.prossimo_tentativo
END,
errore = CASE WHEN p_esito = 'inviata' THEN NULL ELSE left(p_errore, 500) END,
inviata_il = CASE WHEN p_esito = 'inviata' THEN now() ELSE c.inviata_il END,
aggiornata_il = now()
WHERE c.notifica_id = p_id AND c.stato = 'in_invio';
END;
$$;
REVOKE ALL ON FUNCTION public.accoda_push_promemoria() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.prendi_push_promemoria(integer) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.esito_push_promemoria(uuid, text, text, timestamptz)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.prendi_push_promemoria(integer) TO service_role;
GRANT EXECUTE ON FUNCTION public.esito_push_promemoria(uuid, text, text, timestamptz)
TO service_role;
+20 -6
View File
@@ -24,12 +24,26 @@ 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 email in `mailer/` e preferenza «Email») | 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), 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) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), 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
I test che avviano il server (`api`, `notifiche-attive-route`, `notifiche-utente`, `permessi-route`,
`sondaggio-notifiche`) chiamano `npm run dev`, che richiede un Node recente: con Node 18 Vite non parte.
Si può avviare il server con bun, puntato allo stack locale, e dire ai test di usarlo:
```bash
SUPABASE_URL=… SUPABASE_PUBLISHABLE_KEY=… SUPABASE_SERVICE_ROLE_KEY=… bun --bun x vite dev --port 8080
BASE_URL=http://localhost:8080 npm run test:integration
```
(`npx supabase status -o env` dà i valori dello stack locale.) Il 29/09/2026 l'intera suite di
integrazione, 30 file, passa così.
## Database locale in Docker ## Database locale in Docker
+225
View File
@@ -0,0 +1,225 @@
/**
* Promemoria evento che non ricompaiono (M23, DD-037): `bun test/integration/promemoria-eventi.test.ts`.
*
* Prima di M23 l'unica traccia che un promemoria fosse già stato generato era la riga in
* `notifiche_utente`: eliminarla dal centro notifiche faceva rigenerare il promemoria (e
* riaccodare la mail) al giro successivo del job. Qui si verifica che il registro
* `promemoria_eventi_generati` lo eviti, senza rompere la generazione normale.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa eventi con il prefisso
* `test-promemoria-eventi` e gli slot `g4`, `g5`, `g6` della rosa seed come convocati; non
* serve un account collegato perché le notifiche si eliminano con la service role.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("promemoria eventi", "stack locale non attivo (npx supabase start)");
riepilogo("promemoria-eventi");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`promemoria eventi su ${URL_BASE}`);
const PREFISSO = "test-promemoria-eventi";
const EVENTO = `${PREFISSO}-a`;
const EVENTO_LONTANO = `${PREFISSO}-lontano`;
const EVENTO_CASCATA = `${PREFISSO}-cascata`;
const G4 = "g4";
const G5 = "g5";
const G6 = "g6";
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) {
const res = await rest(percorso, SERVIZIO, {
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 cron(tipo: "evento_promemoria_24h" | "evento_promemoria_3h") {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_promemoria_eventi`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({
p_tipo: tipo,
p_finestra: tipo === "evento_promemoria_24h" ? "24 hours" : "3 hours",
}),
});
if (!res.ok) throw new Error(`genera_promemoria_eventi: ${res.status} ${await res.text()}`);
}
/** 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 notifiche = (evento: string, tipo: string) =>
leggi<{ id: string; giocatore_id: string; letta: boolean }>(
`notifiche_utente?evento_id=eq.${evento}&tipo=eq.${tipo}&select=id,giocatore_id,letta&order=giocatore_id`,
);
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`,
);
const inCoda = async (ids: string[]) =>
ids.length === 0
? []
: leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
try {
await scrivi("eventi_app", "POST", {
id: EVENTO,
tipo: "allenamento",
titolo: `${PREFISSO} evento`,
...tra(2),
convocati: [G4, G5],
});
await scrivi("eventi_app", "POST", {
id: EVENTO_LONTANO,
tipo: "allenamento",
titolo: `${PREFISSO} lontano`,
...tra(72),
convocati: [G4],
});
await prova("il job genera il promemoria per i convocati e lo registra", async () => {
await cron("evento_promemoria_24h");
const n = await notifiche(EVENTO, "evento_promemoria_24h");
assert.deepEqual(
n.map((r) => r.giocatore_id),
[G4, G5],
);
assert.equal((await registro(EVENTO, "evento_promemoria_24h")).length, 2);
});
await prova("un secondo giro del job non duplica né riaccoda", async () => {
const prima = await notifiche(EVENTO, "evento_promemoria_24h");
const codaPrima = await inCoda(prima.map((r) => r.id));
await cron("evento_promemoria_24h");
const dopo = await notifiche(EVENTO, "evento_promemoria_24h");
assert.deepEqual(
dopo.map((r) => r.id),
prima.map((r) => r.id),
);
assert.equal((await inCoda(dopo.map((r) => r.id))).length, codaPrima.length);
});
await prova(
"eliminata la notifica, il promemoria NON ricompare al giro successivo",
async () => {
const [mia] = await notifiche(EVENTO, "evento_promemoria_24h");
assert.ok(mia);
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
assert.equal((await notifiche(EVENTO, "evento_promemoria_24h")).length, 1);
await cron("evento_promemoria_24h");
await cron("evento_promemoria_24h");
const dopo = await notifiche(EVENTO, "evento_promemoria_24h");
assert.equal(dopo.length, 1, "solo la notifica dell'altro convocato, non quella eliminata");
assert.ok(!dopo.some((r) => r.giocatore_id === mia.giocatore_id));
},
);
await prova("nessuna nuova mail in coda per il promemoria eliminato", async () => {
const righe = await leggi<{ id: string; giocatore_id: string }>(
`notifiche_utente?evento_id=eq.${EVENTO}&tipo=eq.evento_promemoria_24h&select=id,giocatore_id`,
);
const coda = await inCoda(righe.map((r) => r.id));
assert.equal(coda.length, righe.length, "una sola riga in coda per notifica rimasta");
});
await prova("il promemoria a 3 ore è indipendente da quello a 24 ore", async () => {
await cron("evento_promemoria_3h");
const n = await notifiche(EVENTO, "evento_promemoria_3h");
assert.equal(n.length, 2, "l'evento parte tra 2 ore: rientra nella finestra di 3");
const [mia] = n;
assert.ok(mia);
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
await cron("evento_promemoria_3h");
assert.equal((await notifiche(EVENTO, "evento_promemoria_3h")).length, 1);
});
await prova("un convocato aggiunto dopo riceve comunque il suo promemoria", async () => {
await scrivi(`eventi_app?id=eq.${EVENTO}`, "PATCH", { convocati: [G4, G5, G6] });
await cron("evento_promemoria_24h");
const n = await notifiche(EVENTO, "evento_promemoria_24h");
assert.ok(
n.some((r) => r.giocatore_id === G6),
"g6 ha ricevuto il promemoria",
);
assert.equal(n.filter((r) => r.giocatore_id === G6).length, 1);
});
await prova("un evento fuori dalla finestra non genera né registra nulla", async () => {
await cron("evento_promemoria_24h");
await cron("evento_promemoria_3h");
assert.equal((await notifiche(EVENTO_LONTANO, "evento_promemoria_24h")).length, 0);
assert.equal((await registro(EVENTO_LONTANO)).length, 0);
});
await prova("il registro non si vede né si scrive da un client", async () => {
const lettura = await rest("promemoria_eventi_generati?select=*", ANON);
assert.ok(!lettura.ok, "anon non può leggere il registro");
const inserimento = await rest("promemoria_eventi_generati", ANON, {
method: "POST",
body: JSON.stringify({ giocatore_id: G4, evento_id: EVENTO, tipo: "evento_promemoria_3h" }),
});
assert.ok(!inserimento.ok, "anon non può scrivere nel registro");
});
await prova("cancellare l'evento toglie anche il registro", async () => {
const { data, ora } = { data: tra(2).data, ora: tra(2).ora };
await scrivi("eventi_app", "POST", {
id: EVENTO_CASCATA,
tipo: "partita",
titolo: `${PREFISSO} cascata`,
data,
ora,
convocati: [G4],
});
await cron("evento_promemoria_24h");
assert.equal((await registro(EVENTO_CASCATA)).length, 1);
await scrivi(`eventi_app?id=eq.${EVENTO_CASCATA}`, "DELETE");
assert.equal((await registro(EVENTO_CASCATA)).length, 0);
});
} finally {
for (const id of [EVENTO, EVENTO_LONTANO, EVENTO_CASCATA]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
riepilogo("promemoria-eventi");
}
}
+434
View File
@@ -0,0 +1,434 @@
/**
* Push dei promemoria e sondaggio in-app (M24, DD-038): `bun test/integration/push-promemoria.test.ts`.
*
* Lato database: il trigger che accoda solo i promemoria a 24 e 3 ore, le funzioni RPC del worker
* (`prendi_push_promemoria`, `esito_push_promemoria`), la RLS che tiene la coda lontana dai
* client, e il nuovo tipo `sondaggio_cacche`. Il worker vero e la logica degli esiti sono coperti
* da `unit/mailer-core.test.ts`; qui non parte nessuna push.
*
* Gira solo sullo stack locale (`npx supabase start`): usa lo slot `g4` della rosa seed e
* iscrizioni push con endpoint `https://push.test-push-promemoria.example/…`, tolte alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("push promemoria", "stack locale non attivo (npx supabase start)");
riepilogo("push-promemoria");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`push promemoria su ${URL_BASE}`);
const PREFISSO = "test-push-promemoria";
const G4 = "g4";
const G5 = "g5";
const ENDPOINT = (n: number) => `https://push.${PREFISSO}.example/${n}`;
const EVENTI = ["a", "b", "c", "d"].map((x) => `${PREFISSO}-${x}`);
let contatore = 0;
const PASSWORD = "prova-push-promemoria-123";
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, token = SERVIZIO): Promise<T[]> {
const res = await rest(percorso, token);
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) {
const res = await rest(percorso, SERVIZIO, {
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<T = unknown>(nome: string, corpo: Record<string, unknown>, token = SERVIZIO) {
const res = await 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),
});
const testo = await res.text();
return { ok: res.ok, dati: (testo ? JSON.parse(testo) : null) as T };
}
async function creaUtente(email: string): Promise<{ id: string; token: string }> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
type Coda = {
notifica_id: string;
stato: string;
tentativi: number;
prossimo_tentativo: string;
errore: string | null;
inviata_il: string | null;
};
type Preso = {
id_notifica: string;
oggetto: string;
testo: string;
tentativi_fatti: number;
iscrizioni: Array<{ endpoint: string; p256dh: string; auth: string }>;
};
const evento = async (id: string, ore = 2) => {
const quando = new Date(Date.now() + ore * 3_600_000);
await scrivi("eventi_app", "POST", {
id,
tipo: "allenamento",
titolo: `${PREFISSO} ${id}`,
data: new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando),
ora: new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(quando),
convocati: [G4],
});
};
const creaNotifica = async (
giocatoreId: string,
tipo: string,
eventoId: string | null = null,
): Promise<string> => {
contatore += 1;
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
giocatore_id: giocatoreId,
tipo,
titolo: `${PREFISSO} titolo ${contatore}`,
corpo: `${PREFISSO} corpo`,
...(eventoId ? { evento_id: eventoId } : {}),
}),
});
if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`);
return ((await res.json()) as { id: string }[])[0]!.id;
};
/** Evento nuovo e un promemoria per g4: (giocatore, evento, tipo) è UNIQUE, ogni prova ha il suo. */
const eventiCreati: string[] = [];
const fresco = async (tipo = "evento_promemoria_24h"): Promise<string> => {
contatore += 1;
const id = `${PREFISSO}-x${contatore}`;
await evento(id, 40);
eventiCreati.push(id);
return creaNotifica(G4, tipo, id);
};
const coda = async (id: string): Promise<Coda | undefined> =>
(await leggi<Coda>(`notifiche_push_coda?notifica_id=eq.${id}&select=*`))[0];
const codaEmail = async (id: string) =>
(await leggi<{ stato: string }>(`notifiche_email_coda?notifica_id=eq.${id}&select=stato`))[0];
const prendi = async (mie: string[], max = 100): Promise<Preso[]> => {
const r = await rpc<Preso[]>("prendi_push_promemoria", { p_max: max });
assert.ok(r.ok, "prendi_push_promemoria");
return r.dati.filter((p) => mie.includes(p.id_notifica));
};
const esito = async (
id: string,
tipo: string,
errore: string | null = null,
prossimo: string | null = null,
) => {
const r = await rpc("esito_push_promemoria", {
p_id: id,
p_esito: tipo,
p_errore: errore,
p_prossimo: prossimo,
});
assert.ok(r.ok, `esito_push_promemoria ${tipo}`);
};
const iscrivi = (giocatoreId: string, n: number) =>
scrivi("push_subscriptions", "POST", {
giocatore_id: giocatoreId,
endpoint: ENDPOINT(n),
p256dh: `k${n}`,
auth: `a${n}`,
});
const idUtenti: string[] = [];
try {
const utente = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(utente.id);
for (const id of EVENTI) await evento(id);
// --- 1. Trigger ---------------------------------------------------------------------
await prova("solo i promemoria a 24 e 3 ore finiscono nella coda push", async () => {
const promemoria = [
await creaNotifica(G4, "evento_promemoria_24h", EVENTI[0]),
await creaNotifica(G4, "evento_promemoria_3h", EVENTI[0]),
];
const altre = [
await creaNotifica(G4, "admin"),
await creaNotifica(G4, "turno_palloni", EVENTI[0]),
await creaNotifica(G4, "sollecita_presenze", EVENTI[0]),
await creaNotifica(G4, "sondaggio_cacche", EVENTI[0]),
];
for (const id of promemoria) {
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
}
for (const id of altre) {
assert.equal(await coda(id), undefined, "la push di queste parte già dall'app");
}
});
await prova(
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
async () => {
const id = await creaNotifica(G4, "sondaggio_cacche", EVENTI[1]);
assert.equal((await codaEmail(id))?.stato, "in_coda");
assert.equal(await coda(id), undefined);
},
);
await prova("un tipo sconosciuto è ancora rifiutato dal vincolo", async () => {
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
body: JSON.stringify({ giocatore_id: G4, tipo: "boh", titolo: "x", corpo: "y" }),
});
assert.ok(!res.ok);
});
// --- 2. prendi_push_promemoria ------------------------------------------------------
await prova(
"prendi restituisce testo e iscrizioni del giocatore e marca la riga in_invio",
async () => {
await iscrivi(G4, 1);
await iscrivi(G4, 2);
const id = await fresco("evento_promemoria_3h");
const [p] = await prendi([id]);
assert.ok(p, "la riga è stata presa");
assert.match(p.oggetto, new RegExp(PREFISSO));
assert.equal(p.tentativi_fatti, 0);
assert.deepEqual(p.iscrizioni.map((i) => i.endpoint).sort(), [ENDPOINT(1), ENDPOINT(2)]);
assert.equal(p.iscrizioni[0]?.p256dh.startsWith("k"), true);
assert.equal((await coda(id))?.stato, "in_invio");
},
);
await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => {
const id = await fresco();
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0);
});
await prova("due chiamate concorrenti si dividono le righe senza doppioni", async () => {
const ids = await Promise.all(Array.from({ length: 5 }, () => fresco()));
const [a, b] = await Promise.all([prendi(ids, 3), prendi(ids, 3)]);
const tutti = [...a, ...b].map((p) => p.id_notifica);
assert.equal(tutti.length, new Set(tutti).size, "nessuna riga presa due volte");
const resto = (await prendi(ids)).map((p) => p.id_notifica);
assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort());
});
await prova("giocatore senza dispositivi: 'saltata', mai presa", async () => {
const id = await creaNotifica(G5, "evento_promemoria_24h", EVENTI[0]);
assert.equal((await prendi([id])).length, 0);
const riga = await coda(id);
assert.equal(riga?.stato, "saltata");
assert.equal(riga?.errore, null);
});
await prova("promemoria creato da più di 3 ore: 'saltata', la push non serve più", async () => {
const id = await fresco("evento_promemoria_3h");
const vecchia = new Date(Date.now() - 4 * 3_600_000).toISOString();
await scrivi(`notifiche_utente?id=eq.${id}`, "PATCH", { creato_il: vecchia });
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
});
await prova(
"la riga rimasta in invio per un worker morto viene ripresa dopo 10 minuti",
async () => {
const id = await fresco();
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0, "ancora in invio");
await scrivi(`notifiche_push_coda?notifica_id=eq.${id}`, "PATCH", {
aggiornata_il: new Date(Date.now() - 11 * 60_000).toISOString(),
});
assert.equal((await prendi([id])).length, 1, "recuperata");
},
);
// --- 3. esito_push_promemoria -------------------------------------------------------
const nuovaInvio = async () => {
const id = await fresco();
await prendi([id]);
return id;
};
await prova("'inviata': stato e orario, errore azzerato", async () => {
const id = await nuovaInvio();
await esito(id, "inviata");
const riga = await coda(id);
assert.equal(riga?.stato, "inviata");
assert.ok(riga?.inviata_il);
assert.equal(riga?.errore, null);
});
await prova(
"'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata",
async () => {
const id = await nuovaInvio();
await esito(id, "riprova", "503", new Date(Date.now() + 3_600_000).toISOString());
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 1);
assert.equal(riga?.errore, "503");
assert.equal((await prendi([id])).length, 0);
await scrivi(`notifiche_push_coda?notifica_id=eq.${id}`, "PATCH", {
prossimo_tentativo: new Date(Date.now() - 1000).toISOString(),
});
const [p] = await prendi([id]);
assert.equal(p?.tentativi_fatti, 1);
},
);
await prova("'fallita' e 'saltata' sono definitive", async () => {
const a = await nuovaInvio();
await esito(a, "fallita", "403");
assert.equal((await coda(a))?.stato, "fallita");
assert.equal((await coda(a))?.tentativi, 1);
const b = await nuovaInvio();
await esito(b, "saltata", "410");
assert.equal((await coda(b))?.stato, "saltata");
assert.equal((await prendi([a, b])).length, 0);
});
await prova(
"un esito su una riga non 'in_invio' non cambia nulla, uno sconosciuto è rifiutato",
async () => {
const id = await nuovaInvio();
await esito(id, "inviata");
await esito(id, "fallita", "x");
assert.equal((await coda(id))?.stato, "inviata", "l'esito già deciso non si sovrascrive");
const r = await rpc("esito_push_promemoria", { p_id: id, p_esito: "boh" });
assert.ok(!r.ok);
},
);
await prova("cancellare la notifica toglie anche la riga della coda push", async () => {
const id = await fresco("evento_promemoria_3h");
assert.ok(await coda(id));
await scrivi(`notifiche_utente?id=eq.${id}`, "DELETE");
assert.equal(await coda(id), undefined);
});
await prova(
"il job dei promemoria accoda la push una volta sola, anche se cancelli la notifica",
async () => {
await scrivi(`eventi_app?id=eq.${EVENTI[2]}`, "PATCH", { convocati: [G4] });
const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" };
await rpc("genera_promemoria_eventi", args);
const [n] = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTI[2]}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.ok(n, "il job ha creato il promemoria");
assert.equal((await coda(n.id))?.stato, "in_coda");
await scrivi(`notifiche_utente?id=eq.${n.id}`, "DELETE");
assert.equal(await coda(n.id), undefined, "la coda sparisce con la notifica");
await rpc("genera_promemoria_eventi", args);
const dopo = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTI[2]}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.equal(
dopo.length,
0,
"M23: il promemoria eliminato non torna, quindi nessuna nuova push",
);
},
);
// --- 4. Permessi --------------------------------------------------------------------
await prova("un utente autenticato non vede né scrive la coda push", async () => {
const id = await nuovaInvio();
const lettura = await rest("notifiche_push_coda?select=*", utente.token);
const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : [];
assert.equal(righe.length, 0);
const scrittura = await rest(`notifiche_push_coda?notifica_id=eq.${id}`, utente.token, {
method: "PATCH",
body: JSON.stringify({ stato: "in_coda" }),
});
assert.ok(!scrittura.ok || (await scrittura.text()) === "");
assert.equal((await coda(id))?.stato, "in_invio");
const anon = await rest("notifiche_push_coda?select=*", ANON);
assert.ok(!anon.ok, "anon non può leggere la coda");
});
await prova("le funzioni push del worker sono chiamabili solo dalla service role", async () => {
const id = await nuovaInvio();
for (const token of [utente.token, ANON]) {
const a = await rpc("prendi_push_promemoria", { p_max: 1 }, token);
assert.ok(!a.ok, "prendi_push_promemoria");
const b = await rpc("esito_push_promemoria", { p_id: id, p_esito: "fallita" }, token);
assert.ok(!b.ok, "esito_push_promemoria");
}
assert.equal((await coda(id))?.stato, "in_invio");
});
} finally {
await rest(
`push_subscriptions?endpoint=like.${encodeURIComponent(`https://push.${PREFISSO}.example/*`)}`,
SERVIZIO,
{
method: "DELETE",
},
);
await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
for (const id of [...EVENTI, ...eventiCreati]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
for (const id of idUtenti) {
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("push-promemoria");
}
}
@@ -0,0 +1,250 @@
/**
* Sondaggio pre-partita in-app ed email (M24, DD-038): `bun test/integration/sondaggio-notifiche.test.ts`.
*
* La route `apri-sondaggio` mandava solo la push. Ora scrive anche `notifiche_utente` (tipo
* `sondaggio_cacche`) per i giocatori attivi, allenatori esclusi: compare nel centro notifiche e
* la mail parte dal trigger di M22. Qui si verifica quella scrittura chiamando la route vera.
*
* Gira solo sullo stack locale (`npx supabase start`) e avvia il server di sviluppo puntato al
* database locale (o usa quello indicato da `BASE_URL`). Usa gli slot `g5` (reso non attivo) e
* `g6` (reso allenatore) della rosa seed, ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { avviaServer } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("sondaggio in-app", "stack locale non attivo (npx supabase start)");
riepilogo("sondaggio-notifiche");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`sondaggio in-app su ${URL_BASE}`);
// Il server di sviluppo eredita queste: senza, le route punterebbero al progetto cloud.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const PREFISSO = "test-sondaggio-notifiche";
const PARTITA = `${PREFISSO}-partita`;
const INATTIVO = "g5";
const ALLENATORE = "g6";
const PASSWORD = "prova-sondaggio-notifiche-123";
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, token: string, metodo: string, corpo?: unknown) {
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 creaUtente(email: string): Promise<{ id: string; token: string }> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
type Notifica = {
id: string;
giocatore_id: string;
letta: boolean;
titolo: string;
creato_il: string;
};
const notifiche = () =>
leggi<Notifica>(
`notifiche_utente?evento_id=eq.${PARTITA}&tipo=eq.sondaggio_cacche&select=id,giocatore_id,letta,titolo,creato_il&order=giocatore_id`,
);
const idUtenti: string[] = [];
let server: { baseUrl: string; stop: () => void } | null = null;
let tokenAdmin = "";
const originali = new Map<string, { attivo: boolean; tipo: string }>();
try {
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
const giocatore = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(admin.id, giocatore.id);
await scrivi("user_roles", SERVIZIO, "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [INATTIVO, ALLENATORE]) {
const [riga] = await leggi<{ attivo: boolean; tipo: string }>(
`giocatori_squadra?id=eq.${id}&select=attivo,tipo`,
);
originali.set(id, riga ?? { attivo: true, tipo: "giocatore" });
}
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, tokenAdmin, "PATCH", { attivo: false });
await scrivi(`giocatori_squadra?id=eq.${ALLENATORE}`, tokenAdmin, "PATCH", {
tipo: "allenatore",
});
await scrivi("eventi_app", SERVIZIO, "POST", {
id: PARTITA,
tipo: "partita",
titolo: `${PREFISSO} partita`,
data: "2030-01-01",
ora: "21:00",
convocati: [],
});
const attesi = (
await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&tipo=eq.giocatore&select=id")
)
.map((g) => g.id)
.sort();
assert.ok(attesi.length > 3, "la rosa seed ha più giocatori attivi");
assert.ok(!attesi.includes(INATTIVO) && !attesi.includes(ALLENATORE));
server = await avviaServer();
console.log(`route apri-sondaggio su ${server.baseUrl}`);
const chiama = (token: string) =>
fetch(`${server!.baseUrl}/api/public/apri-sondaggio`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ eventoId: PARTITA }),
});
await prova("chi non è admin non può aprire il sondaggio e non scrive nulla", async () => {
const res = await chiama(giocatore.token);
assert.ok(!res.ok, `stato ${res.status}`);
assert.equal((await notifiche()).length, 0);
});
await prova("l'admin apre il sondaggio: una notifica per ogni giocatore attivo", async () => {
const res = await chiama(tokenAdmin);
assert.equal(res.status, 200);
const righe = await notifiche();
assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), attesi);
assert.ok(righe.every((r) => r.titolo === "💩 Sondaggio pre-partita aperto"));
assert.ok(righe.every((r) => !r.letta));
});
await prova("l'allenatore e chi non è attivo non ricevono l'avviso in-app", async () => {
const righe = await notifiche();
assert.ok(!righe.some((r) => r.giocatore_id === ALLENATORE));
assert.ok(!righe.some((r) => r.giocatore_id === INATTIVO));
});
await prova(
"l'avviso arriva anche a chi non ha nessun dispositivo iscritto alla push",
async () => {
const iscritti = await leggi<{ giocatore_id: string }>(
"push_subscriptions?select=giocatore_id",
);
const conPush = new Set(iscritti.map((i) => i.giocatore_id));
const senza = attesi.filter((id) => !conPush.has(id));
assert.ok(senza.length > 0, "nel database di prova nessuno ha la push");
const righe = await notifiche();
for (const id of senza)
assert.ok(
righe.some((r) => r.giocatore_id === id),
id,
);
},
);
await prova(
"ogni notifica del sondaggio ha la sua mail in coda e nessuna riga push",
async () => {
const righe = await notifiche();
const ids = righe.map((r) => r.id).join(",");
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids})&select=notifica_id`,
);
assert.equal(email.length, righe.length, "una mail per notifica");
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=in.(${ids})&select=notifica_id`,
);
assert.equal(push.length, 0, "la push del sondaggio parte già dalla route");
},
);
await prova(
"ripremere il pulsante aggiorna le notifiche, riporta a non letta e rimanda la mail",
async () => {
const prima = await notifiche();
const [scelta] = prima;
assert.ok(scelta);
await scrivi(`notifiche_utente?id=eq.${scelta.id}`, SERVIZIO, "PATCH", { letta: true });
await scrivi(`notifiche_email_coda?notifica_id=eq.${scelta.id}`, SERVIZIO, "PATCH", {
stato: "inviata",
});
const res = await chiama(tokenAdmin);
assert.equal(res.status, 200);
const dopo = await notifiche();
assert.equal(
dopo.length,
prima.length,
"nessun doppione: upsert su giocatore, evento e tipo",
);
const aggiornata = dopo.find((r) => r.id === scelta.id);
assert.equal(aggiornata?.letta, false);
assert.ok(aggiornata!.creato_il > scelta.creato_il, "creato_il rinnovato");
const [coda] = await leggi<{ stato: string }>(
`notifiche_email_coda?notifica_id=eq.${scelta.id}&select=stato`,
);
assert.equal(coda?.stato, "in_coda", "la mail riparte");
},
);
} finally {
server?.stop();
await rest(`notifiche_utente?evento_id=eq.${PARTITA}`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=eq.${PARTITA}`, 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("sondaggio-notifiche");
}
}
+22
View File
@@ -1,6 +1,8 @@
/** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */ /** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
avvisoSondaggio,
destinatariSondaggio,
mediaPartita, mediaPartita,
mediaStagione, mediaStagione,
recordStagione, recordStagione,
@@ -118,4 +120,24 @@ assert.equal(
"partita passata: terminato", "partita passata: terminato",
); );
// --- Avviso del sondaggio (DD-038) ---------------------------------------------
assert.deepEqual(avvisoSondaggio({ titolo: "Juve - CRAP", ora: "21:00" }), {
titolo: "💩 Sondaggio pre-partita aperto",
testo: "Juve - CRAP · ore 21:00. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.",
});
// Destinatari in-app ed email: giocatori attivi, mai gli allenatori né chi non è più attivo,
// a prescindere dai dispositivi iscritti alla push.
assert.deepEqual(
destinatariSondaggio([
{ id: "g1", attivo: true, tipo: "giocatore" },
{ id: "g2", attivo: false, tipo: "giocatore" },
{ id: "g3", attivo: true, tipo: "allenatore" },
{ id: "g4", attivo: true, tipo: "giocatore" },
]),
["g1", "g4"],
"solo i giocatori attivi",
);
assert.deepEqual(destinatariSondaggio([]), [], "rosa vuota: nessun destinatario");
console.log("cacche: ok"); console.log("cacche: ok");
+226
View File
@@ -3,9 +3,11 @@ import assert from "node:assert/strict";
import { import {
MAX_TENTATIVI, MAX_TENTATIVI,
classificaErroreSmtp, classificaErroreSmtp,
classificaStatoPush,
componiMail, componiMail,
descriviErrore, descriviErrore,
elaboraCiclo, elaboraCiclo,
elaboraCicloPush,
escapeHtml, escapeHtml,
invioConsentiti, invioConsentiti,
leggiConfig, leggiConfig,
@@ -15,9 +17,13 @@ import {
ritardoRiprova, ritardoRiprova,
staSano, staSano,
type Dipendenze, type Dipendenze,
type DipendenzePush,
type EsitoInvio, type EsitoInvio,
type EsitoPush,
type IscrizionePush,
type MailComposta, type MailComposta,
type NotificaEmail, type NotificaEmail,
type PushDaInviare,
} from "../../mailer/mailer-core.ts"; } from "../../mailer/mailer-core.ts";
import { prova, riepilogo } from "../helpers/prova"; import { prova, riepilogo } from "../helpers/prova";
@@ -403,4 +409,224 @@ await prova("ciclo: i log non contengono indirizzi né testi", async () => {
assert.ok(!tutto.includes("Allenamento")); assert.ok(!tutto.includes("Allenamento"));
}); });
// --- Configurazione VAPID (M24) -----------------------------------------------
await prova("senza chiavi VAPID il worker parte e manda solo le email", () => {
const r = leggiConfig(ENV_BASE);
assert.ok("config" in r);
assert.equal(r.config.vapid, null);
});
await prova("con le chiavi VAPID la push dei promemoria è attiva", () => {
const r = leggiConfig({
...ENV_BASE,
VAPID_PUBLIC_KEY: "pub",
VAPID_PRIVATE_KEY: "priv",
VAPID_SUBJECT: "mailto:a@b.it",
});
assert.ok("config" in r);
assert.deepEqual(r.config.vapid, {
publicKey: "pub",
privateKey: "priv",
subject: "mailto:a@b.it",
});
});
await prova("le chiavi VAPID vanno impostate insieme", () => {
for (const env of [{ VAPID_PUBLIC_KEY: "pub" }, { VAPID_PRIVATE_KEY: "priv" }]) {
const r = leggiConfig({ ...ENV_BASE, ...env });
assert.ok("errori" in r);
assert.deepEqual(r.errori, ["VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme"]);
}
});
// --- classificaStatoPush ------------------------------------------------------
await prova("stato HTTP del servizio push: accettata, scaduta, temporanea, permanente", () => {
for (const s of [200, 201, 202]) assert.equal(classificaStatoPush(s), "ok", String(s));
for (const s of [404, 410]) assert.equal(classificaStatoPush(s), "scaduta", String(s));
for (const s of [429, 500, 502, 503])
assert.equal(classificaStatoPush(s), "temporaneo", String(s));
for (const s of [400, 401, 403, 413])
assert.equal(classificaStatoPush(s), "permanente", String(s));
});
// --- elaboraCicloPush ---------------------------------------------------------
const disp = (n: number): IscrizionePush[] =>
Array.from({ length: n }, (_, i) => ({
endpoint: `https://push.example/${i}`,
p256dh: "k",
auth: "a",
}));
const pushDa = (extra: Partial<PushDaInviare> = {}): PushDaInviare => ({
id_notifica: "p-1",
oggetto: "Promemoria: Allenamento",
testo: "30/09/2026 alle 20:30",
tentativi_fatti: 0,
iscrizioni: disp(1),
...extra,
});
type ChiamataPush = { id: string; esito: EsitoPush; errore?: string; prossimo?: Date };
function ambientePush(opzioni: {
lotto?: PushDaInviare[];
risposta?: (iscrizione: IscrizionePush) => Promise<{ stato: number }>;
elimina?: (endpoint: string) => Promise<void>;
}) {
const chiamate: ChiamataPush[] = [];
const inviate: Array<{ endpoint: string; titolo: string; testo: string }> = [];
const eliminate: string[] = [];
const log: string[] = [];
const ADESSO = new Date("2026-09-29T10:00:00Z");
const dip: DipendenzePush = {
prendi: async () => opzioni.lotto ?? [],
segna: async (id, esito, errore, prossimo) => {
chiamate.push({
id,
esito,
...(errore !== undefined ? { errore } : {}),
...(prossimo ? { prossimo } : {}),
});
},
invia: async (iscrizione, titolo, testo) => {
inviate.push({ endpoint: iscrizione.endpoint, titolo, testo });
return opzioni.risposta ? opzioni.risposta(iscrizione) : { stato: 201 };
},
elimina:
opzioni.elimina ??
(async (endpoint) => {
eliminate.push(endpoint);
}),
adesso: () => ADESSO,
log: (m) => log.push(m),
};
return { dip, chiamate, inviate, eliminate, log, ADESSO };
}
await prova("push: manda titolo e testo a tutti i dispositivi e segna 'inviata'", async () => {
const a = ambientePush({ lotto: [pushDa({ iscrizioni: disp(2) })] });
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(a.inviate.length, 2);
assert.equal(a.inviate[0]?.titolo, "Promemoria: Allenamento");
assert.equal(a.inviate[0]?.testo, "30/09/2026 alle 20:30");
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[["p-1", "inviata"]],
);
});
await prova("push: basta un dispositivo che accetta, quello scaduto viene eliminato", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 201 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(r.iscrizioniRimosse, 1);
assert.deepEqual(a.eliminate, ["https://push.example/0"]);
assert.equal(a.chiamate[0]?.esito, "inviata");
});
await prova("push: tutte le iscrizioni scadute vuol dire 'saltata', non un errore", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async () => ({ stato: 404 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.saltate, 1);
assert.equal(r.iscrizioniRimosse, 2);
assert.equal(a.chiamate[0]?.esito, "saltata");
});
await prova("push: nessuna iscrizione nella riga, 'saltata' senza chiamate", async () => {
const a = ambientePush({ lotto: [pushDa({ iscrizioni: [] })] });
await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(a.inviate.length, 0);
assert.equal(a.chiamate[0]?.esito, "saltata");
});
await prova("push: errore temporaneo, riprova con backoff sul numero di tentativi", async () => {
const a = ambientePush({
lotto: [pushDa({ tentativi_fatti: 1 })],
risposta: async () => ({ stato: 503 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.riprovate, 1);
assert.equal(a.chiamate[0]?.esito, "riprova");
assert.equal(a.chiamate[0]?.errore, "503");
assert.equal(a.chiamate[0]?.prossimo?.getTime(), a.ADESSO.getTime() + 5 * 60_000);
});
await prova("push: rete giù, riprova; all'ultimo tentativo 'fallita'", async () => {
const giu = async () => {
throw new Error("rete");
};
const a = ambientePush({ lotto: [pushDa({ tentativi_fatti: 0 })], risposta: giu });
await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(a.chiamate[0]?.esito, "riprova");
assert.equal(a.chiamate[0]?.errore, "rete");
const b = ambientePush({ lotto: [pushDa({ tentativi_fatti: 4 })], risposta: giu });
const r = await elaboraCicloPush(b.dip, { lotto: 10 });
assert.equal(r.fallite, 1);
assert.equal(b.chiamate[0]?.esito, "fallita");
});
await prova("push: rifiuto permanente (chiavi VAPID sbagliate) è subito 'fallita'", async () => {
const a = ambientePush({ lotto: [pushDa()], risposta: async () => ({ stato: 403 }) });
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.fallite, 1);
assert.equal(a.chiamate[0]?.esito, "fallita");
assert.equal(a.chiamate[0]?.errore, "403");
});
await prova("push: una notifica che fallisce non ferma le successive", async () => {
const a = ambientePush({
lotto: [pushDa({ id_notifica: "p-1" }), pushDa({ id_notifica: "p-2", iscrizioni: disp(1) })],
risposta: async () => ({ stato: 201 }),
});
a.dip.invia = async (_i, _t, _x) => {
if (a.inviate.length === 0) {
a.inviate.push({ endpoint: "x", titolo: "", testo: "" });
throw new Error("rete");
}
return { stato: 201 };
};
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.riprovate, 1);
assert.equal(r.inviate, 1);
});
await prova("push: se l'eliminazione dell'iscrizione fallisce, il resto prosegue", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 201 }),
elimina: async () => {
throw new Error("db giù");
},
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(r.iscrizioniRimosse, 0);
});
await prova("push: la registrazione dell'esito che fallisce interrompe il ciclo", async () => {
const a = ambientePush({ lotto: [pushDa()] });
a.dip.segna = async () => {
throw new Error("rete giù");
};
await assert.rejects(elaboraCicloPush(a.dip, { lotto: 10 }), /rete giù/);
});
await prova("push: i log non contengono endpoint né testi dei promemoria", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) }), pushDa({ id_notifica: "p-2" })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 503 }),
});
await elaboraCicloPush(a.dip, { lotto: 10 });
const tutto = a.log.join("\n");
assert.ok(!tutto.includes("push.example"));
assert.ok(!tutto.includes("Allenamento"));
assert.ok(!tutto.includes("20:30"));
});
riepilogo("mailer-core"); riepilogo("mailer-core");