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:
1 parent
58eba642f7
commit
e9013b703b
27 files changed
+2076
-92
No files matched your search
+16
-2
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
README.md
|
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
Reference in new issue
Block a user