From e9013b703b38a00de08941d4d81dffe7803c844e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 29 Sep 2026 14:19:54 +0200 Subject: [PATCH] Promemoria senza ritorni e tre canali per ogni notifica (DD-037, DD-038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT_STATE.md | 18 +- docs/CHANGELOG.md | 16 + docs/DATABASE.md | 20 +- docs/DESIGN_DECISIONS.md | 124 +++++ docs/PORTABILITA.md | 20 +- docs/WORKER_EMAIL.md | 99 ++-- docs/modules/notifiche-email.md | 8 +- docs/modules/notifiche.md | 115 ++++- mailer/.dockerignore | 5 - mailer/.env.example | 6 + mailer/Dockerfile | 10 +- mailer/Dockerfile.dockerignore | 11 + mailer/docker-compose.yml | 4 +- mailer/mailer-core.ts | 145 ++++++ mailer/mailer.mjs | 54 ++- src/components/crapp/SondaggioCacche.tsx | 2 +- src/lib/cacche.ts | 26 ++ src/lib/notifiche-utente.ts | 5 +- src/routes/api/public/apri-sondaggio.ts | 28 +- ...0929140000_m23_promemoria_gia_generati.sql | 72 +++ ...160000_m24_push_promemoria_e_sondaggio.sql | 197 ++++++++ test/README.md | 26 +- test/integration/promemoria-eventi.test.ts | 225 +++++++++ test/integration/push-promemoria.test.ts | 434 ++++++++++++++++++ test/integration/sondaggio-notifiche.test.ts | 250 ++++++++++ test/unit/cacche.test.ts | 22 + test/unit/mailer-core.test.ts | 226 +++++++++ 27 files changed, 2076 insertions(+), 92 deletions(-) delete mode 100644 mailer/.dockerignore create mode 100644 mailer/Dockerfile.dockerignore create mode 100644 supabase/migrations/20260929140000_m23_promemoria_gia_generati.sql create mode 100644 supabase/migrations/20260929160000_m24_push_promemoria_e_sondaggio.sql create mode 100644 test/integration/promemoria-eventi.test.ts create mode 100644 test/integration/push-promemoria.test.ts create mode 100644 test/integration/sondaggio-notifiche.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index bc2ea16..8694ee2 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -24,8 +24,10 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). - Cursor e Claude Code come ambienti di sviluppo - Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production) - Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes` -- 33 migration in `supabase/migrations/`, fino a `m22_notifiche_email` (29/09/2026), tutte - applicate in produzione (verificato con `npx supabase migration list` il 29/09/2026) +- 35 migration in `supabase/migrations/`, fino a `m24_push_promemoria_e_sondaggio` (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 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 è @@ -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 backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata 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 `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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ad8f21f..b64070d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,22 @@ versioni `0.y.z` erano pre-release. ## [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 - **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, diff --git a/docs/DATABASE.md b/docs/DATABASE.md index ddc511b..ed2a1fa 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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_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) | +| `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) | | `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) | @@ -73,14 +75,16 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa ## Turni e notifiche -| Tabella | Scopo | Note | -| ---------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | -| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | -| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | -| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. 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_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. | +| Tabella | Scopo | Note | +| ---------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | +| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. 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). | +| `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 diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 33c8170..d71361a 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -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-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-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** @@ -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 affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron` 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. diff --git a/docs/PORTABILITA.md b/docs/PORTABILITA.md index eed5945..594bdcc 100644 --- a/docs/PORTABILITA.md +++ b/docs/PORTABILITA.md @@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud. ## Stato attuale -| Componente | Portabile? | Note | -| ------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| 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. | -| 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`). | -| 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. | -| 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)). | -| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. | +| Componente | Portabile? | Note | +| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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. | +| 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`). | +| 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. | +| 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. | ## Regole da rispettare nelle prossime modifiche diff --git a/docs/WORKER_EMAIL.md b/docs/WORKER_EMAIL.md index ef3d2a3..14aa663 100644 --- a/docs/WORKER_EMAIL.md +++ b/docs/WORKER_EMAIL.md @@ -1,12 +1,16 @@ # Worker email: installazione e gestione -Come si mette in funzione, si controlla e si diagnostica il worker che invia le notifiche per email -(`mailer/`). Cosa fa e come si comporta il canale sta in -[modules/notifiche-email.md](modules/notifiche-email.md); perché è fatto così in -[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa). +Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche +per email e la push dei promemoria a 24 e 3 ore. Cosa fa il canale email sta in +[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in +[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in +[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) +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 -Raspberry Pi con Docker. +**Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026, +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 @@ -16,8 +20,13 @@ Raspberry Pi con Docker. | 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`. | | 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 @@ -54,17 +63,23 @@ dominio proprio (vedi DD-036). 2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila: - | Variabile | Default | Contenuto | - | --------------------------------------- | ------------------------------ | ---------------------------------------------------------------- | - | `SUPABASE_URL` | — | URL del progetto | - | `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) | - | `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 | - | `POLL_SECONDI` | 30 | intervallo di polling | - | `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail | - | `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) | + | Variabile | Default | Contenuto | + | --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- | + | `SUPABASE_URL` | — | URL del progetto | + | `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) | + | `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 | + | `POLL_SECONDI` | 30 | intervallo di polling | + | `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail | + | `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) | + | `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 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 `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 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; ``` - 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`. ## Spostare il worker su un altro host @@ -144,16 +161,19 @@ controlla la posta dell'account mittente e conferma l'accesso. ## Diagnosi -| 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. | -| 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 `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. | -| 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. | -| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. | +| 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. | +| 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 `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. | +| 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. | +| 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 @@ -201,13 +221,26 @@ Per provare anche l'immagine Docker, il container si collega alla rete di Supaba (`--network supabase_network_`) e usa `http://supabase_kong_:8000` come `SUPABASE_URL` e `supabase_inbucket_` 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 -non parte, e con lui i test di integrazione che avviano il server. I test del database e del worker -non ne hanno bisogno. +non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node: +`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 - 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 dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`. diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md index 91f0418..754d1cb 100644 --- a/docs/modules/notifiche-email.md +++ b/docs/modules/notifiche-email.md @@ -50,10 +50,14 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti ## Quali notifiche e a chi **Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a -3 ore, turno palloni, sollecito presenze. 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 **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 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. @@ -121,7 +125,7 @@ modificare solo in parte (DD-016). 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 - `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` 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 diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 34aa82f..af019c4 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -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 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, 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 `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-subscribe`** — registra o rimuove l'iscrizione di un dispositivo. -- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i - dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi - [Scout Live](scout-live.md)). +- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi + iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in + `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 (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 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 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 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 - `UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda - doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta + (o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro + `promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)` + 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. 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 da `avvisiPalloniEvento()`, in parallelo alla push. 4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una 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 notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false) 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 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) 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). 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). ## Limiti noti diff --git a/mailer/.dockerignore b/mailer/.dockerignore deleted file mode 100644 index b90a5f6..0000000 --- a/mailer/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -.env -.env.* -!.env.example -README.md diff --git a/mailer/.env.example b/mailer/.env.example index 7a0d7f8..39deaee 100644 --- a/mailer/.env.example +++ b/mailer/.env.example @@ -8,6 +8,12 @@ SUPABASE_SERVICE_ROLE_KEY= SMTP_USER=crapp.notifiche@gmail.com 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 APP_URL=https:// diff --git a/mailer/Dockerfile b/mailer/Dockerfile index 78ad7d5..8ae6f0c 100644 --- a/mailer/Dockerfile +++ b/mailer/Dockerfile @@ -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 -WORKDIR /app -COPY package.json bun.lock bunfig.toml ./ +WORKDIR /app/mailer +COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./ 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 CMD ["bun", "mailer.mjs"] diff --git a/mailer/Dockerfile.dockerignore b/mailer/Dockerfile.dockerignore new file mode 100644 index 0000000..762aa23 --- /dev/null +++ b/mailer/Dockerfile.dockerignore @@ -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 diff --git a/mailer/docker-compose.yml b/mailer/docker-compose.yml index b580de1..ffe09fd 100644 --- a/mailer/docker-compose.yml +++ b/mailer/docker-compose.yml @@ -1,6 +1,8 @@ services: mailer: - build: . + build: + context: .. + dockerfile: mailer/Dockerfile restart: unless-stopped env_file: .env healthcheck: diff --git a/mailer/mailer-core.ts b/mailer/mailer-core.ts index ddc4508..a63f95f 100644 --- a/mailer/mailer-core.ts +++ b/mailer/mailer-core.ts @@ -39,6 +39,8 @@ export type Config = { from: string; fromName: 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; limiteGiorno: number; lotto: number; @@ -78,6 +80,11 @@ export function leggiConfig(env: Record): LetturaCon if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme"); const from = testo("MAIL_FROM") || 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 pollSecondi = intero("POLL_SECONDI", 30); const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400); @@ -97,6 +104,14 @@ export function leggiConfig(env: Record): LetturaCon from, fromName: testo("MAIL_FROM_NAME") || "CrAPP", appUrl, + vapid: + vapidPubblica && vapidPrivata + ? { + publicKey: vapidPubblica, + privateKey: vapidPrivata, + subject: testo("VAPID_SUBJECT") || null, + } + : null, pollSecondi, limiteGiorno, lotto, @@ -334,3 +349,133 @@ export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise= 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; + segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise; + invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>; + elimina: (endpoint: string) => Promise; + 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 { + 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; +} diff --git a/mailer/mailer.mjs b/mailer/mailer.mjs index e48663d..0e1bbb3 100644 --- a/mailer/mailer.mjs +++ b/mailer/mailer.mjs @@ -1,9 +1,11 @@ -// Worker delle notifiche email (M22, DD-036): legge la coda su Supabase e invia via SMTP. -// Solo chiamate in uscita, nessuna porta da aprire. Uso: bun mailer/mailer.mjs -// La logica sta in mailer-core.ts; qui c'è solo la colla con rete e SMTP. +// Worker delle notifiche (M22 email, M24 push dei promemoria; DD-036, DD-038): legge le code su +// Supabase e invia via SMTP e via Web Push. Solo chiamate in uscita, nessuna porta da aprire. +// 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 { 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"; @@ -32,6 +34,22 @@ async function rpc(nome, corpo) { 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({ host: config.smtp.host, port: config.smtp.port, @@ -64,6 +82,21 @@ const dipendenze = { 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 = { limiteGiorno: config.limiteGiorno, 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)); 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( () => log("SMTP raggiungibile e credenziali accettate"), (errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`), @@ -90,6 +128,14 @@ while (!fermo) { } catch (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); } log("arrestato"); diff --git a/src/components/crapp/SondaggioCacche.tsx b/src/components/crapp/SondaggioCacche.tsx index 37956fb..59a0213 100644 --- a/src/components/crapp/SondaggioCacche.tsx +++ b/src/components/crapp/SondaggioCacche.tsx @@ -69,7 +69,7 @@ export function SondaggioCacche({ toast.success( dati.inviate > 0 ? `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 { toast.error("Non sono riuscito a inviare la notifica"); diff --git a/src/lib/cacche.ts b/src/lib/cacche.ts index 42577d3..41a3591 100644 --- a/src/lib/cacche.ts +++ b/src/lib/cacche.ts @@ -1,7 +1,33 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; +import { inRosa, type TipoMembro } from "./giocatori-squadra"; 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. */ export const ORA_APERTURA_SONDAGGIO = 8; diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts index 5397520..c2b4b45 100644 --- a/src/lib/notifiche-utente.ts +++ b/src/lib/notifiche-utente.ts @@ -4,7 +4,7 @@ import { useGiocatoreId } from "./user-store"; /** * 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 * 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 @@ -16,7 +16,8 @@ export type TipoNotifica = | "evento_promemoria_24h" | "evento_promemoria_3h" | "turno_palloni" - | "sollecita_presenze"; + | "sollecita_presenze" + | "sondaggio_cacche"; export type NotificaUtente = { id: string; diff --git a/src/routes/api/public/apri-sondaggio.ts b/src/routes/api/public/apri-sondaggio.ts index 1bd2148..58b8389 100644 --- a/src/routes/api/public/apri-sondaggio.ts +++ b/src/routes/api/public/apri-sondaggio.ts @@ -1,6 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { richiediAdmin } from "@/lib/auth-route.server"; +import { avvisoSondaggio, destinatariSondaggio } from "@/lib/cacche"; import { leggiEventi } from "@/lib/eventi.server"; import { inviaPush } from "@/lib/webpush.server"; import { isAllenatore } from "@/lib/giocatori-squadra"; @@ -27,14 +29,30 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({ const { data: tutte } = await supabaseAdmin .from("push_subscriptions") .select("endpoint, p256dh, auth, giocatore_id"); + const squadra = await leggiGiocatoriSquadra(); // Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034). - const allenatori = new Set( - (await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id), - ); + const allenatori = new Set(squadra.filter(isAllenatore).map((g) => g.id)); const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id)); - const titolo = "💩 Sondaggio pre-partita aperto"; - const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`; + const { titolo, testo } = avvisoSondaggio(partita); + + // 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; for (const iscrizione of iscrizioni) { diff --git a/supabase/migrations/20260929140000_m23_promemoria_gia_generati.sql b/supabase/migrations/20260929140000_m23_promemoria_gia_generati.sql new file mode 100644 index 0000000..77a4ddc --- /dev/null +++ b/supabase/migrations/20260929140000_m23_promemoria_gia_generati.sql @@ -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; +$$; diff --git a/supabase/migrations/20260929160000_m24_push_promemoria_e_sondaggio.sql b/supabase/migrations/20260929160000_m24_push_promemoria_e_sondaggio.sql new file mode 100644 index 0000000..0e8c958 --- /dev/null +++ b/supabase/migrations/20260929160000_m24_push_promemoria_e_sondaggio.sql @@ -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; diff --git a/test/README.md b/test/README.md index 2160a81..7c93f9f 100644 --- a/test/README.md +++ b/test/README.md @@ -24,12 +24,26 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push ## Struttura -| Cartella | Cosa verifica | Serve rete? | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker email in `mailer/` e preferenza «Email») | 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ì | -| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | -| `helpers/` | Avvio del server di test e mini-harness condiviso | — | +| Cartella | Cosa verifica | Serve rete? | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24) 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ì | +| `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 diff --git a/test/integration/promemoria-eventi.test.ts b/test/integration/promemoria-eventi.test.ts new file mode 100644 index 0000000..2346661 --- /dev/null +++ b/test/integration/promemoria-eventi.test.ts @@ -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(percorso: string): Promise { + 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"); + } +} diff --git a/test/integration/push-promemoria.test.ts b/test/integration/push-promemoria.test.ts new file mode 100644 index 0000000..19894b0 --- /dev/null +++ b/test/integration/push-promemoria.test.ts @@ -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(percorso: string, token = SERVIZIO): Promise { + 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(nome: string, corpo: Record, 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 => { + 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 => { + 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 => + (await leggi(`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 => { + const r = await rpc("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"); + } +} diff --git a/test/integration/sondaggio-notifiche.test.ts b/test/integration/sondaggio-notifiche.test.ts new file mode 100644 index 0000000..2ad8c8f --- /dev/null +++ b/test/integration/sondaggio-notifiche.test.ts @@ -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(percorso: string): Promise { + 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( + `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(); + + 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"); + } +} diff --git a/test/unit/cacche.test.ts b/test/unit/cacche.test.ts index bf769aa..a19fa62 100644 --- a/test/unit/cacche.test.ts +++ b/test/unit/cacche.test.ts @@ -1,6 +1,8 @@ /** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */ import assert from "node:assert/strict"; import { + avvisoSondaggio, + destinatariSondaggio, mediaPartita, mediaStagione, recordStagione, @@ -118,4 +120,24 @@ assert.equal( "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"); diff --git a/test/unit/mailer-core.test.ts b/test/unit/mailer-core.test.ts index 0e32e80..9efe9b8 100644 --- a/test/unit/mailer-core.test.ts +++ b/test/unit/mailer-core.test.ts @@ -3,9 +3,11 @@ import assert from "node:assert/strict"; import { MAX_TENTATIVI, classificaErroreSmtp, + classificaStatoPush, componiMail, descriviErrore, elaboraCiclo, + elaboraCicloPush, escapeHtml, invioConsentiti, leggiConfig, @@ -15,9 +17,13 @@ import { ritardoRiprova, staSano, type Dipendenze, + type DipendenzePush, type EsitoInvio, + type EsitoPush, + type IscrizionePush, type MailComposta, type NotificaEmail, + type PushDaInviare, } from "../../mailer/mailer-core.ts"; 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")); }); +// --- 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 => ({ + 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; +}) { + 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");