diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6aab49e..177ae23 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 27/09/2026 +Ultimo aggiornamento: 29/09/2026 ## Stato generale @@ -10,8 +10,9 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`. Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati -reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0 -con il ruolo allenatore, è pronta ma non ancora pubblicata (vedi `docs/CHANGELOG.md`). +reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0, ha +portato il ruolo allenatore. La versione corrente è la 1.2.0: notifiche su tre canali (push, in-app +ed email) e promemoria che non si ripetono (vedi `docs/CHANGELOG.md`). Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). @@ -24,8 +25,14 @@ 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` -- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte - applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026) +- 36 migration in `supabase/migrations/`, fino a `m25_promemoria_solo_giocatori` (29/09/2026). + Le prime 33 (fino a `m22_notifiche_email`) sono applicate in produzione, verificato con + `npx supabase migration list` il 29/09/2026; **`m23`, `m24` e `m25` sono verificate solo in locale + e non ancora applicate in produzione** +- 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 è + su `develop`, non ancora su `main` - Sviluppo locale verificato con il nuovo Supabase --- @@ -55,6 +62,28 @@ 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 `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()` + include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi + (li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con + `npx supabase db reset` e i test di integrazione, **non ancora in produzione** +- 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 + esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e + una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal + 29/09/2026 (`supabase db push`) con il worker in funzione - Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in @@ -178,8 +207,8 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin. ## Prossimo sviluppo -Niente di assegnato: l'avviso certificati in scadenza (DD-035) è implementato e versione -1.1.0 (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in +Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0) e le notifiche su tre +canali (DD-036–DD-039, 1.2.0) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 639fed3..cca31d9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,57 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] +## [1.2.0] - 2026-09-29 + +Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più. +Le migration `m22`–`m25` sono già in produzione dal 29/09/2026 e il worker delle email è in funzione; +questa versione porta il codice dell'app che le usa. + +### Aggiunto + +- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, promemoria + a 24 e 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita) arriva anche per email + all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul + router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration + `m22_notifiche_email`) e invia tramite un account Gmail dedicato, con nuovi tentativi in caso di + errore e un tetto giornaliero sotto il limite di Gmail. In Profilo → Opzioni c'è l'interruttore + «Email», acceso di default, per account (tabella `preferenze_utente`; DD-036). +- **Push dei promemoria** — i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email, + partono ora anche come push a tutti i dispositivi iscritti del giocatore. Nascono nel database, + che non può firmare una push, quindi la manda lo stesso worker con lo stesso modulo dell'app + (migration `m24_push_promemoria_e_sondaggio`, DD-038). Le iscrizioni scadute vengono eliminate e + una push rimasta in coda oltre 3 ore si scarta. Il worker va ricostruito e configurato con le chiavi + VAPID dell'app (vedi [WORKER_EMAIL.md](WORKER_EMAIL.md)); senza, manda solo le email. +- **Sondaggio pre-partita nel centro notifiche e per email** — prima era solo una push. Ora compare + anche tra le notifiche in-app e arriva per email ai giocatori attivi, allenatori esclusi (DD-038). +- **Documentazione e test** — nuovo [WORKER_EMAIL.md](WORKER_EMAIL.md) (installazione, gestione, + diagnosi, prova in locale, spostamento su un altro host); catalogo delle notifiche in + [notifiche.md](modules/notifiche.md), con cosa le fa partire, a chi arrivano e su quali canali; + tabella delle notifiche dell'allenatore in [allenatore.md](modules/allenatore.md); decisioni + DD-036, DD-037, DD-038 e DD-039. Nuovi test di integrazione per la coda delle email, il registro + dei promemoria, la coda push, il sondaggio e i destinatari per ruolo (promemoria, messaggio dello + staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), più i test unitari del + worker. + +### Modificato + +- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non + c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di + squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration + `m25_promemoria_solo_giocatori`. +- **Messaggio del pulsante «Sondaggio»** — se nessun dispositivo ha le notifiche attive dice che + l'avviso è stato inviato in app e per email, invece di «Nessun dispositivo con le notifiche + attive». +- **`ops/` fuori da git** — il playbook Ansible locale per il backup cifrato non è più tracciato. + +### 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. + ## [1.1.1] - 2026-09-28 ### Aggiunto diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 67056d0..5f02c4d 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -19,6 +19,10 @@ nessuna di esse da M4 (DD-011). | `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | | `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | | `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) | @@ -71,12 +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. | +| Tabella | Scopo | Note | +| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | +| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. | +| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). | +| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. | +| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. | +| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. | ## Funzioni speciali diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 4a7bea2..39d99e7 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -51,6 +51,10 @@ Serve a rispondere a domande del tipo: | [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario | | [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 | +| [DD-039](#dd-039--lallenatore-non-riceve-i-promemoria-degli-eventi) | Niente promemoria all'allenatore | **In valutazione** @@ -1446,7 +1450,8 @@ collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando immediato e non aspetta la query dei ruoli. - L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra. - In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`; - riceve i promemoria degli eventi e può sollecitare le presenze, non il turno palloni. + riceve i promemoria degli eventi (dal 29/09/2026 non più: DD-039) e può sollecitare le presenze, + non il turno palloni. - Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata. **Riesame** @@ -1510,3 +1515,243 @@ certificato viene rinnovato. Specifica completa in Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati), valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7 giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile. + +### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa + +**Data:** 29 settembre 2026 +**Stato:** Accettata + +**Contesto** +Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background, +vedi i limiti in `docs/modules/notifiche.md`) e nel centro notifiche in-app, che si vede solo +aprendo l'app. Chi non ha attivato la push o ha un telefono aggressivo sul risparmio +energetico non riceve nulla ad app chiusa. Serve un canale che arrivi comunque, **gratis**, ai +giocatori registrati, che sono tutti su Gmail perché l'accesso è con Google. L'app gira su +Vercel, che non esegue container né processi sempre attivi. + +**Decisione** +Le email partono da un **worker Docker sull'host di casa** che legge una coda su Supabase e +invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per app. + +- La coda è `notifiche_email_coda`, riempita da un trigger su `notifiche_utente`: ogni + notifica già esistente, di qualunque tipo e per qualunque destinatario (admin compresi, + con la stessa logica degli altri), diventa anche una mail. Le sorgenti di M17 non cambiano, + non ne nascono di nuove e l'app non conosce le email. Il certificato in scadenza resta + fuori: è una card in Home, non una notifica (DD-035). +- Il worker fa solo polling **in uscita** con la service role: nessuna porta aperta sull'host. +- La coda è una tabella a parte, con RLS senza policy, perché la policy `UPDATE` di + `notifiche_utente` lascerebbe a un client la possibilità di rimettere in coda una mail. +- Canale **opt-out**: acceso di default per tutti, con un interruttore per account in + Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga + per account (non per slot); l'assenza di riga vale «acceso». +- Specifica completa in [notifiche-email.md](modules/notifiche-email.md); installazione e + gestione in [WORKER_EMAIL.md](WORKER_EMAIL.md). + +**Alternative scartate** + +- Inviare direttamente dal proprio IP con un MTA (Postfix) → scartata: porta 25 bloccata dai + provider residenziali, IP nelle blocklist, niente reverse DNS; le mail finirebbero in spam o + sarebbero rifiutate. +- Relay Brevo o Resend → scartati per ora: il piano gratuito funziona ma per una buona + consegna richiede un dominio con SPF/DKIM/DMARC, che oggi non c'è. Restano l'alternativa + se servirà un mittente della squadra. +- Supabase Edge Function con `pg_cron` e un relay → scartata su richiesta esplicita di un + servizio dockerizzato sull'host; resta la strada senza host acceso, ma dipende comunque da un + relay esterno. +- Vercel Cron → scartato: sul piano gratuito gira al massimo una volta al giorno, troppo poco + per i promemoria a 24h e 3h. +- Chiamare l'host dall'app (webhook verso casa) → scartato: richiederebbe un IP pubblico o un + tunnel e una superficie d'attacco in più; il polling in uscita non ne ha. +- Colonne di stato sulla riga di `notifiche_utente` → scartate per la RLS spiegata sopra. + +**Conseguenze** + +- Costo zero, ma il canale email dipende da un host acceso: se è spento le mail arrivano in + ritardo (mai perse). Consegna _almeno una volta_: in caso di crash tra invio e + registrazione può arrivare un doppione. +- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite + reale: con molte squadre o riepiloghi frequenti andrebbe rivisto. +- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola + dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host). + L'app non guadagna nessuna dipendenza e le route di M17 non cambiano. +- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non + passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte. +- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default, + sotto i circa 500 di Gmail). +- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e + l'indicazione in ogni mail su come disattivarlo. +- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le + preferenze per tipo di notifica senza toccare `giocatori_squadra`. +- In produzione dal 29/09/2026 (migration e worker su un Raspberry Pi). Dalla prova reale: la + prima mail, da un account Gmail nuovo, è arrivata ma nella cartella spam, e la password per app + non era disponibile finché l'account aveva le sole passkey come secondo passaggio (dettagli in + [WORKER_EMAIL.md](WORKER_EMAIL.md#account-gmail-e-password-per-app)). + +**Riesame** +Se serve un mittente col dominio della squadra, se le mail finiscono in spam anche dopo qualche +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. + +### DD-039 — L'allenatore non riceve i promemoria degli eventi + +**Data:** 29 settembre 2026 +**Stato:** Accettata + +**Contesto** +DD-034 stabilì che l'allenatore riceve i promemoria a 24 e 3 ore prima di un evento, sempre, convocato +o no (`giocatori_destinatari_evento()` riscritta da M21). Ma un promemoria serve a ricordare di +esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i +partecipanti (DD-034). L'incoerenza si vede negli eventi extra-campo, come una cena di squadra: +riceveva l'avviso (in-app, per email e, dopo M24, come push) senza poter dire se ci sarebbe stato né +comparire nell'elenco. + +**Decisione** +I destinatari dei promemoria sono i **giocatori attivi**: i convocati, o tutta la rosa se +`convocati` è vuoto. L'allenatore non li riceve, per nessun tipo di evento. Cambia solo la funzione +SQL (migration `m25_promemoria_solo_giocatori`, `g.tipo = 'giocatore'` al posto di «o allenatore +sempre»); cron, criteri di selezione, registro dei promemoria (DD-037), code email e push e testi +restano invariati. + +**Alternative scartate** + +- Toglierli solo per gli eventi extra-campo → scartata su indicazione esplicita di toglierli per + gli eventi in generale: anche per un allenamento o una partita l'allenatore non è tra i + partecipanti. +- Farlo rispondere agli eventi extra-campo, così da tenere il promemoria → scartata: servono + risposte presenze e regole del database per una categoria nuova, e il conteggio dei presenti. + +**Conseguenze** + +- Le notifiche che l'allenatore riceve dal server sono i **messaggi dello staff** e nient'altro + (sollecito, turno palloni, sondaggio e notifiche smart non lo riguardano già). +- I promemoria già generati per un allenatore prima della migration non si toccano. +- Un allenatore non è avvisato dell'inizio di un evento che gestisce: sa quando è perché lo ha + creato lui o lo vede in Calendario. +- Sostituisce la parte di DD-034 sui promemoria; il resto di DD-034 resta valido. + +**Riesame** +Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme +alla sua possibilità di rispondere. diff --git a/docs/PORTABILITA.md b/docs/PORTABILITA.md index 9b89f3f..594bdcc 100644 --- a/docs/PORTABILITA.md +++ b/docs/PORTABILITA.md @@ -5,15 +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. | -| `@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/README.md b/docs/README.md index ab11f09..8658b50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi | [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta | | [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) | | [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push | +| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email | | [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando | | [modules/](modules/) | Specifica funzionale di ogni modulo, una per file | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2f329a1..c1f1fd5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,6 +41,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio ([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035) +- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati, + da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo + ([specifica](modules/notifiche-email.md), DD-036) ## Prossimo diff --git a/docs/WORKER_EMAIL.md b/docs/WORKER_EMAIL.md new file mode 100644 index 0000000..14aa663 --- /dev/null +++ b/docs/WORKER_EMAIL.md @@ -0,0 +1,246 @@ +# Worker email: installazione e gestione + +Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche +per email e la push dei promemoria a 24 e 3 ore. Cosa fa il canale email sta in +[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 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 + +| Requisito | Note | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. | +| 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). 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 + +Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**. + +1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo. +2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso). +3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu. +4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di + 16 caratteri, a gruppi di quattro: copiala. +5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come + script di shell, si rompe. Non va mai incollata in chat né nel repository. + +Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account. + +**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:** + +| Causa | Cosa fare | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). | +| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. | +| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. | + +Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un +dominio proprio (vedi DD-036). + +## Installazione + +1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: non modifica nulla + di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le + nuove notifiche si accodano: **avvia il worker subito**, nella stessa sessione. Le notifiche + accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie + (il 29/09/2026 sono partiti così due messaggi di prova dell'admin). + +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) | + | `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 + worker con l'elenco degli errori. Il `.env` è ignorato da git. + + **`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch + (`…-git--….vercel.app`). L'indirizzo di un singolo deploy (`…--….vercel.app`) + cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la + Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a + Vercel. + +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» 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. + +## Gestione + +| Cosa | Comando (dentro `mailer/`) | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vedere il log in diretta | `docker compose logs -f` | +| Ultime righe del log | `docker compose logs --tail 50` | +| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) | +| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) | +| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` | +| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) | +| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. | + +Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`, +`ciclo fermato`), mai indirizzi né testi. + +## Vedere cosa è partito + +- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il + riscontro più sicuro. +- **SQL Editor di Supabase**, per stato e contenuto: + + ```sql + select c.stato, c.inviata_il, c.tentativi, c.errore, + g.nome, g.cognome, n.tipo, n.titolo, n.corpo + from notifiche_email_coda c + join notifiche_utente n on n.id = c.notifica_id + join giocatori_squadra g on g.id = n.giocatore_id + order by n.creato_il desc + limit 50; + ``` + + 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 + +Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in +Supabase. Cambiare host non fa perdere né duplicare mail. + +1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera. + L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare. +2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella + `mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola. +3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da + chat né da git: contiene la service role e la password per app. +4. **Spegni il vecchio:** `docker compose down` sul vecchio host. +5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP + raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`. + +Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un +po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa +`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log. +Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del +nuovo worker. + +Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La +password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`, +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. | +| 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 + +`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`), +con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può +lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`. + +1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con + `npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si + usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale. + Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la + porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si + omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa. +2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con + `email`. In locale: + + ```bash + . <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=') + UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \ + -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H 'content-type: application/json' \ + -d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') + docker exec supabase_db_ psql -U postgres -c \ + "set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='';" + ``` + + `session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la + service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella + tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed, + si usa quello slot invece di impostarlo su un secondo. + +3. **Una notifica di prova:** + + ```bash + docker exec supabase_db_ psql -U postgres -c \ + "insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('','admin','Prova CrAPP','Messaggio di prova');" + ``` + + Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella). + +4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito. + +Per provare anche l'immagine Docker, il container si collega alla rete di Supabase +(`--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. 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. 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/allenatore.md b/docs/modules/allenatore.md index 608c154..1d70b40 100644 --- a/docs/modules/allenatore.md +++ b/docs/modules/allenatore.md @@ -122,47 +122,65 @@ risposte presenze altrui, gestione dei ruoli. ## Notifiche -L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche -in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()` -lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio -Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che -riguardano chi gioca. +Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in +[notifiche.md](notifiche.md#catalogo-delle-notifiche)): + +| Notifica | L'allenatore la riceve? | Note | +| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). | +| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. | +| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito. | +| Turno palloni | no | I turni riguardano solo i giocatori. | +| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. | +| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. | + +Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha +spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal +proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app +funziona come per i giocatori, con i soli messaggi dello staff. ## Esclusione dalla rosa L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario -(`useAnagraficaRosa({ conAllenatori: true })`), i destinatari dei promemoria evento e dei -messaggi admin, che lo includono. +(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo +includono. -| Livello | Dove si esclude l'allenatore | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial` | -| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni | -| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` | -| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore | -| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) | +| Livello | Dove si esclude l'allenatore | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) | +| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni | +| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` | +| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore | +| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) | Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un giocatore). ## Permessi (riepilogo) -| Azione | Giocatore | Allenatore | Admin | -| ----------------------------------------------- | ---------- | ------------------ | ------------------ | -| Modificare nome e cognome | no | sì, i propri | tutti | -| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) | -| Documento, certificato, foto tessera | i propri | — | lettura/download | -| Avviso certificati in Home | il proprio | no | tutta la rosa | -| Creare/modificare/eliminare eventi, convocati | no | sì | sì | -| Sollecitare le presenze | no | sì | sì | -| Promemoria palloni, notifica personalizzata | no | no | sì | -| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) | -| Tab Stagione e Badge del profilo | sì | no | sì | -| Tab Obiettivi e Badge di Squadra | sì | no | sì | -| Badge e classifiche/dati sulle cacche | sì | no | sì | -| Dashboard `/admin` | no | no | sì | +| Azione | Giocatore | Allenatore | Admin | +| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- | +| Modificare nome e cognome | no | sì, i propri | tutti | +| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) | +| Documento, certificato, foto tessera | i propri | — | lettura/download | +| Avviso certificati in Home | il proprio | no | tutta la rosa | +| Creare/modificare/eliminare eventi, convocati | no | sì | sì | +| Sollecitare le presenze | no | sì | sì | +| Promemoria palloni, notifica personalizzata | no | no | sì | +| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot | +| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot | +| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa | +| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa | +| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) | +| Tab Stagione e Badge del profilo | sì | no | sì | +| Tab Obiettivi e Badge di Squadra | sì | no | sì | +| Badge e classifiche/dati sulle cacche | sì | no | sì | +| Dashboard `/admin` | no | no | sì | + +Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve +secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot. ## Implementazione @@ -174,7 +192,7 @@ giocatore). del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger `sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o - allenatore; presenze, cacche e voti chiusi all'allenatore; promemoria evento aperti. + allenatore; presenze, cacche e voti chiusi all'allenatore. - **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato. @@ -183,7 +201,8 @@ giocatore). ## Scelte confermate -- Riceve i promemoria degli eventi: sì. +- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i + messaggi dello staff. - Sollecita le presenze: sì. Promemoria palloni: no. - Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio. - In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md index 11adfc9..488244f 100644 --- a/docs/modules/collegamento-csi.md +++ b/docs/modules/collegamento-csi.md @@ -31,14 +31,14 @@ un `project_id`), nessun codice le chiama direttamente. ### Identificativi (stagione 2025/26) | Cosa | Valore | -| ---------------------- | ---------------------------------------- | -| Campionato | PVM - Campionato Open Misto Eccellenza | -| `project_id` (girone) | `767` | -| Coppa | PVM Coppa CSI Misto Silver | -| `project_id` (coppa) | `848` | -| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | -| `team_id` | `3359` | -| Girone | B | +| --------------------- | -------------------------------------- | +| Campionato | PVM - Campionato Open Misto Eccellenza | +| `project_id` (girone) | `767` | +| Coppa | PVM Coppa CSI Misto Silver | +| `project_id` (coppa) | `848` | +| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | +| `team_id` | `3359` | +| Girone | B | `project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in `src/lib/csi-core.ts`. @@ -61,11 +61,11 @@ verificato chiamando l'endpoint direttamente, che oggi restituisce sia ### Endpoint usati dall'app -| Endpoint | Formato | Uso | Pagina "umana" corrispondente | -| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ | -| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") | -| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") | -| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) | +| Endpoint | Formato | Uso | Pagina "umana" corrispondente | +| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") | +| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") | +| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) | **Formato di `project-sheets.php`** — tabella HTML per girone (una per ``, `parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra). @@ -104,11 +104,11 @@ Il referto di ogni gara sul portale (`match_details.php?id=`, dove `mat stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via `assets/js/match.js`: -| Endpoint | Formato | Uso | -| ----------------------------------------------- | ------- | --------------------------------------------------------- | -| `components/match-main.php?match_id=` | HTML | Giornata e una nota libera sotto l'impianto | -| `components/match-players.php?match_id=` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre | -| `components/match-stats.php?match_id=` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI | +| Endpoint | Formato | Uso | +| -------------------------------------------- | ------- | ------------------------------------------------------------------- | +| `components/match-main.php?match_id=` | HTML | Giornata e una nota libera sotto l'impianto | +| `components/match-players.php?match_id=` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre | +| `components/match-stats.php?match_id=` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI | Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro @@ -262,7 +262,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project` distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico - partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il +partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il filtro va aggiunto su `evento.project` in `partiteDaEventi()`. **La classifica della Coppa, invece, è mostrata** (sopra quella del girone in `/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa @@ -289,7 +289,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali. **Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un - errore solo se *sia* la classifica *sia* le partite sono vuote insieme + errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme (`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200` con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di @@ -312,7 +312,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio errore SQL del loro backend in chiaro al posto del JSON (`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`, verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/ - csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde +csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde `503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di `test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md new file mode 100644 index 0000000..5517e45 --- /dev/null +++ b/docs/modules/notifiche-email.md @@ -0,0 +1,242 @@ +# Modulo — Notifiche email + +**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`) +**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`, +`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`, +`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx` +(interruttore «Email») + +Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si +diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in +[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa). + +## Obiettivo + +Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è +registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche in-app +([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda per email +**ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per qualunque +destinatario. + +## Architettura + +``` +App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase) + ▲ + polling in uscita │ RPC con service role + │ + Container Docker sull'host di casa (mailer) + │ SMTP 587 (STARTTLS) + ▼ + smtp.gmail.com ──▶ casella Gmail del giocatore +``` + +- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**: + interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico, + nessun tunnel. +- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come prima: + le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e + partono alla ripartenza: consegna in ritardo, mai persa. +- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron `pg_cron` + compresi. +- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una + sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST, + senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli + dell'app, che non guadagna nessuna dipendenza. +- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato + nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta + entrambe (verificato sul progetto di produzione). + +## Quali notifiche e a chi + +**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a +3 ore, turno palloni, sollecito presenze, sondaggio pre-partita. Un tipo nuovo che in futuro finisse in quella tabella +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 non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio +slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il +sollecito, il turno palloni e il sondaggio (DD-034, DD-038). + +Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home +(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di +`notifiche_utente`, da decidere a parte. + +**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore +(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui +account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore. + +## Mittente + +Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con +una **password per app**. Come crearla e i problemi noti stanno in +[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app). + +Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail +parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né +configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome +visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account +gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai +direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS +le mail finiscono in spam o vengono rifiutate. + +## Dati + +Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro +funzionale. + +**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e +chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie +anche la mail non ancora partita). + +| Colonna | Note | +| -------------------- | ----------------------------------------------------------------------- | +| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` | +| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) | +| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) | +| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail | +| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore | +| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto | + +È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per +il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La +coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la +legge e la scrive solo la service role, cioè il worker. + +**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora +con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la +riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo +la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può +modificare solo in parte (DD-016). + +**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role: + +| Funzione | Cosa fa | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. | +| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. | +| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. | + +## Flusso + +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, 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 + ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una. + Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme + `in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario + o con le email spente. +4. Ogni invio registra l'esito con `esito_notifica_email`. + +La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore +ferma anche le mail già in coda. + +## Affidabilità + +Il worker classifica l'errore SMTP (`classificaErroreSmtp`): + +| Tipo | Esempi | Cosa succede | +| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. | +| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. | +| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. | +| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). | + +Altre situazioni: + +| Situazione | Comportamento | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. | +| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. | +| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. | +| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. | + +`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai +messaggi che l'account dedicato potesse mandare per altre vie. + +**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo +riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza +all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato +dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps` +mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto +giornaliero è normale e resta sano. + +## Contenuto delle mail + +Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo +della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante +«Apri CrAPP» verso `/evento/` quando la notifica riguarda un evento e verso la home +altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email). +Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene +sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica. + +## Interfaccia + +Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta) +che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche» +(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account. +Nessuna schermata nuova. + +## Privacy e sicurezza + +- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo + l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa. +- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da + Vercel. +- La password per app si può revocare da Google senza toccare l'account. +- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli + errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB. +- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come + per la push (nessuna preferenza per tipo). + +## Test + +- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione, + classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape, + oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su + tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali). + [test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default + «acceso» senza riga. +- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts), + sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte, + cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate + concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate), + preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un + giocatore o un admin non vedono la coda né chiamano le funzioni del worker). +- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari. + L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti). + +## Limiti noti + +- Il canale email dipende da un host acceso: non c'è alta disponibilità. +- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole + una. +- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un + account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla + configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora + segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail + va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni). +- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato + che `notifiche_utente` ha una riga per `giocatore_id`. +- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già + `inviata` non si toccano. +- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe + spariscono quando la notifica viene eliminata. +- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia + dell'app è in produzione, le mail partono ma non si possono spegnere dall'app. + +## Evoluzioni possibili + +- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail + restano in spam o si supera il tetto di Gmail. +- Riepilogo settimanale unico al posto di una mail per notifica. +- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push. +- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`). diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 9778260..1229f0c 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,90 @@ 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; mai gli allenatori (DD-039). | sì (worker) | sì | sì | +| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì | +| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì | +| 4 | **Turno palloni** — «Tocca a te prendere i palloni» / «Porta i palloni» | Un admin, con il pulsante nella pagina dell'evento. Solo eventi di tipo allenamento o partita. Route `promemoria-palloni`, tipo `turno_palloni`. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | sì | +| 5 | **Sollecito presenze** — «Manca la tua risposta» | Un admin o un allenatore, con il pulsante nella pagina dell'evento. Route `sollecita-presenze`, tipo `sollecita_presenze`. | I giocatori attivi (mai gli allenatori) che non hanno ancora risposto o hanno risposto «forse». | sì | sì | sì | +| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | +| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | + +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 riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche). + +### 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. + +### Test che verificano il catalogo + +| Cosa | Test | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` | +| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` | +| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` | +| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` | +| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` | +| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` | +| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) | + +Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la +consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la +catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del +codice; l'unico pezzo puro, `inRosa`, è coperto. + +--- + ## Dati `push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e @@ -62,9 +147,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 @@ -117,6 +203,13 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non ripetersi — deduplica puramente locale al dispositivo, non sincronizzata. +**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**: +`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore +letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude +gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e +`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la +notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche). + --- ## Centro notifiche in-app (M17) @@ -131,23 +224,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,6 +261,38 @@ 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 sorgenti scrivono `notifiche_utente` come prima. Il certificato in +scadenza resta una card in Home e non genera mail (DD-035). + ## Limiti noti - Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index ae1a917..248f3bd 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -24,7 +24,7 @@ ordina mettendo i completati in coda e gli altri per progresso decrescente. Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia -esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle +esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la cancellazione: risolto a livello database con un trigger (migration `m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi. @@ -37,18 +37,18 @@ corrente dinamico (vedi sotto). ## Obiettivi definiti -| id | Obiettivo | Calcolo | Target | Fonte | -| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ | -| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | -| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | -| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` | -| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | -| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | -| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | -| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) | -| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI | -| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI | -| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` | +| id | Obiettivo | Calcolo | Target | Fonte | +| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- | +| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | +| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | +| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` | +| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | +| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | +| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | +| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) | +| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI | +| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI | +| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` | Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica @@ -104,7 +104,7 @@ regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocat errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un -errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo +errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200` con `partite: []`. @@ -144,13 +144,13 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti da un backend reale, non solo funzione pura con contesto costruito a mano). -| Obiettivi | Unit test | Integration test | -| ------------ | -------------------------------- | ------------------------------------------------------------ | -| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) | +| Obiettivi | Unit test | Integration test | +| ---------- | ----------------------------- | -------------------------------------------------------- | +| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) | - **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/ `risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una diff --git a/mailer/.env.example b/mailer/.env.example new file mode 100644 index 0000000..39deaee --- /dev/null +++ b/mailer/.env.example @@ -0,0 +1,28 @@ +# Copia in .env (permessi 600) e compila. Il .env non va mai committato. + +# Progetto Supabase e chiave service role (salta la RLS: trattala come una password) +SUPABASE_URL=https://.supabase.co +SUPABASE_SERVICE_ROLE_KEY= + +# Account Gmail dedicato con verifica in due passaggi e una password per app +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:// + +# Facoltative (valori di default) +# MAIL_FROM_NAME=CrAPP +# MAIL_FROM= # default: SMTP_USER +# POLL_SECONDI=30 +# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail +# MAIL_LOTTO=10 +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_SECURE=false # true solo per la porta 465 diff --git a/mailer/Dockerfile b/mailer/Dockerfile new file mode 100644 index 0000000..8ae6f0c --- /dev/null +++ b/mailer/Dockerfile @@ -0,0 +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/mailer +COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./ +RUN bun install --frozen-lockfile --production +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/bun.lock b/mailer/bun.lock new file mode 100644 index 0000000..a8ddbf2 --- /dev/null +++ b/mailer/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "crapp-mailer", + "dependencies": { + "nodemailer": "^10.0.11", + }, + }, + }, + "packages": { + "nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="], + } +} diff --git a/mailer/bunfig.toml b/mailer/bunfig.toml new file mode 100644 index 0000000..830f3c5 --- /dev/null +++ b/mailer/bunfig.toml @@ -0,0 +1,7 @@ +[install] +saveTextLockfile = true +# 24h supply-chain guard: skip package versions published less than a day ago. +minimumReleaseAge = 86400 +# Each entry bypasses the 24h guard for one package — confirm with the user +# before adding any. +minimumReleaseAgeExcludes = [] diff --git a/mailer/docker-compose.yml b/mailer/docker-compose.yml new file mode 100644 index 0000000..ffe09fd --- /dev/null +++ b/mailer/docker-compose.yml @@ -0,0 +1,18 @@ +services: + mailer: + build: + context: .. + dockerfile: mailer/Dockerfile + restart: unless-stopped + env_file: .env + healthcheck: + test: ["CMD", "bun", "healthcheck.mjs"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" diff --git a/mailer/healthcheck.mjs b/mailer/healthcheck.mjs new file mode 100644 index 0000000..b6d6897 --- /dev/null +++ b/mailer/healthcheck.mjs @@ -0,0 +1,11 @@ +// Healthcheck del container: sano se l'ultimo ciclo riuscito è recente (vedi mailer.mjs). +import { readFile } from "node:fs/promises"; +import { staSano } from "./mailer-core.ts"; + +const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok"; +const MAX_MS = 5 * 60_000; + +const ultimo = await readFile(FILE_SALUTE, "utf8") + .then((t) => Number(t)) + .catch(() => null); +process.exit(staSano(Number.isFinite(ultimo) ? ultimo : null, Date.now(), MAX_MS) ? 0 : 1); diff --git a/mailer/mailer-core.ts b/mailer/mailer-core.ts new file mode 100644 index 0000000..a63f95f --- /dev/null +++ b/mailer/mailer-core.ts @@ -0,0 +1,481 @@ +/** + * Logica pura del worker email (M22, DD-036), separata da rete e SMTP per poterla testare: + * configurazione, classificazione degli errori SMTP, backoff, composizione della mail e il + * ciclo di invio con le dipendenze iniettate. `mailer.mjs` fa solo da colla. + */ + +/** Riga restituita da `prendi_notifiche_email()`. */ +export type NotificaEmail = { + id_notifica: string; + destinatario: string; + oggetto: string; + testo: string; + id_evento: string | null; + tipo_notifica: string; + tentativi_fatti: number; +}; + +export type EsitoInvio = "inviata" | "riprova" | "differita" | "fallita"; + +export type MailComposta = { + to: string; + subject: string; + text: string; + html: string; + messageId: string; +}; + +// --- Configurazione --------------------------------------------------------------------------- + +export type Config = { + supabaseUrl: string; + serviceRoleKey: string; + smtp: { + host: string; + port: number; + secure: boolean; + auth: { user: string; pass: string } | null; + }; + 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; +}; + +export type LetturaConfig = { config: Config } | { errori: string[] }; + +/** + * Legge la configurazione dalle variabili d'ambiente. Torna tutti gli errori insieme (mancanti + * e non validi) per correggere il `.env` in un solo giro. `SMTP_USER`/`SMTP_PASS` vanno o + * entrambi o nessuno dei due: senza servono solo per un server di prova locale (Mailpit). + */ +export function leggiConfig(env: Record): LetturaConfig { + const errori: string[] = []; + const testo = (nome: string): string => (env[nome] ?? "").trim(); + const obbligatoria = (nome: string): string => { + const valore = testo(nome); + if (!valore) errori.push(`${nome} mancante`); + return valore; + }; + const intero = (nome: string, predefinito: number): number => { + const grezzo = testo(nome); + if (!grezzo) return predefinito; + const n = Number(grezzo); + if (!Number.isInteger(n) || n <= 0) { + errori.push(`${nome} deve essere un intero positivo`); + return predefinito; + } + return n; + }; + + const supabaseUrl = obbligatoria("SUPABASE_URL").replace(/\/+$/, ""); + const serviceRoleKey = obbligatoria("SUPABASE_SERVICE_ROLE_KEY"); + const appUrl = obbligatoria("APP_URL").replace(/\/+$/, ""); + const user = testo("SMTP_USER"); + const pass = testo("SMTP_PASS"); + 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); + const lotto = intero("MAIL_LOTTO", 10); + + if (errori.length > 0) return { errori }; + return { + config: { + supabaseUrl, + serviceRoleKey, + smtp: { + host: testo("SMTP_HOST") || "smtp.gmail.com", + port: porta, + secure: testo("SMTP_SECURE") === "true", + auth: user ? { user, pass } : null, + }, + from, + fromName: testo("MAIL_FROM_NAME") || "CrAPP", + appUrl, + vapid: + vapidPubblica && vapidPrivata + ? { + publicKey: vapidPubblica, + privateKey: vapidPrivata, + subject: testo("VAPID_SUBJECT") || null, + } + : null, + pollSecondi, + limiteGiorno, + lotto, + }, + }; +} + +// --- Errori SMTP e backoff -------------------------------------------------------------------- + +export type TipoErrore = "temporaneo" | "permanente" | "configurazione" | "limite"; + +type ErroreSmtp = { code?: unknown; responseCode?: unknown; response?: unknown; message?: unknown }; + +/** + * Distingue cosa fare dopo un invio fallito: + * - `limite`: tetto giornaliero di Gmail (`550 5.4.5 Daily user sending quota exceeded`, che + * sarebbe un 5xx "permanente" ma non dipende dalla mail): si rimanda senza contare un tentativo; + * - `configurazione`: credenziali SMTP rifiutate; stessa cosa, e il worker si ferma; + * - `permanente`: 5xx sul destinatario o sul messaggio, inutile riprovare; + * - `temporaneo`: 4xx e errori di rete, si riprova con backoff. + */ +export function classificaErroreSmtp(errore: unknown): TipoErrore { + const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp; + const codice = typeof e.code === "string" ? e.code : ""; + const risposta = typeof e.responseCode === "number" ? e.responseCode : 0; + const testo = `${typeof e.response === "string" ? e.response : ""} ${ + typeof e.message === "string" ? e.message : "" + }`.toLowerCase(); + + if (/5\.4\.5|quota exceeded|sending limit/.test(testo)) return "limite"; + if (codice === "EAUTH" || [530, 534, 535].includes(risposta)) return "configurazione"; + if (codice === "EENVELOPE" || risposta >= 500) return "permanente"; + return "temporaneo"; +} + +const RITARDI_MINUTI = [1, 5, 30, 120] as const; + +/** Numero massimo di invii per notifica: il primo più un tentativo per ogni ritardo. */ +export const MAX_TENTATIVI = RITARDI_MINUTI.length + 1; + +/** + * Minuti da attendere dopo l'invio fallito numero `tentativiFatti + 1`; `null` quando i + * tentativi sono finiti e la mail va segnata come fallita. + */ +export function ritardoRiprova(tentativiFatti: number): number | null { + return RITARDI_MINUTI[tentativiFatti] ?? null; +} + +/** Quante mail si possono ancora spedire rispetto al tetto delle ultime 24 ore. */ +export function invioConsentiti(limiteGiorno: number, inviate24h: number, lotto: number): number { + return Math.max(0, Math.min(lotto, limiteGiorno - inviate24h)); +} + +/** Descrizione breve dell'errore, senza indirizzi né testi delle mail: va nei log. */ +export function descriviErrore(errore: unknown): string { + const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp; + const parti = [ + typeof e.code === "string" ? e.code : "", + typeof e.responseCode === "number" ? String(e.responseCode) : "", + ].filter(Boolean); + return parti.length > 0 ? parti.join(" ") : "errore sconosciuto"; +} + +// --- Composizione della mail ------------------------------------------------------------------ + +export function escapeHtml(testo: string): string { + return testo + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */ +export function pulisciOggetto(titolo: string): string { + const pulito = titolo + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return (pulito || "Notifica CrAPP").slice(0, 200); +} + +/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */ +export function linkNotifica(appUrl: string, idEvento: string | null): string { + const base = appUrl.replace(/\/+$/, ""); + return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base; +} + +/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */ +export function messageId(idNotifica: string, dominio: string): string { + return `<${idNotifica}@${dominio}>`; +} + +const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email."; + +export function componiMail( + n: NotificaEmail, + opzioni: { appUrl: string; dominioMessageId: string }, +): MailComposta { + const link = linkNotifica(opzioni.appUrl, n.id_evento); + const subject = pulisciOggetto(n.oggetto); + const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE] + .filter(Boolean) + .join("\n\n"); + const corpoHtml = n.testo.trim() + ? `

${escapeHtml(n.testo.trim()).replace(/\n/g, "
")}

` + : ""; + const html = [ + `
`, + `

${escapeHtml(subject)}

`, + corpoHtml, + `

Apri CrAPP

`, + `

${escapeHtml(AVVISO_DISATTIVAZIONE)}

`, + `
`, + ].join(""); + return { + to: n.destinatario, + subject, + text: testo, + html, + messageId: messageId(n.id_notifica, opzioni.dominioMessageId), + }; +} + +// --- Salute del container --------------------------------------------------------------------- + +/** Il worker è sano se l'ultimo ciclo riuscito non è più vecchio di `maxMs`. */ +export function staSano(ultimoCicloOk: number | null, adesso: number, maxMs: number): boolean { + return ultimoCicloOk !== null && adesso - ultimoCicloOk <= maxMs; +} + +// --- Ciclo di invio --------------------------------------------------------------------------- + +export type Dipendenze = { + prendi: (max: number) => Promise; + segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise; + invia: (mail: MailComposta) => Promise; + inviateUltime24h: () => Promise; + adesso: () => Date; + log: (messaggio: string) => void; +}; + +export type OpzioniCiclo = { + limiteGiorno: number; + lotto: number; + appUrl: string; + dominioMessageId: string; +}; + +export type RisultatoCiclo = { + inviate: number; + riprovate: number; + fallite: number; + differite: number; + /** Perché il ciclo si è interrotto prima di finire il lotto, se è successo. */ + fermato: "limite" | "configurazione" | null; +}; + +const ATTESA_LIMITE_MINUTI = 60; +const ATTESA_CONFIGURAZIONE_MINUTI = 15; + +const dopoMinuti = (adesso: Date, minuti: number) => new Date(adesso.getTime() + minuti * 60_000); + +/** + * Un giro: prende fino a un lotto di mail entro il tetto giornaliero e le spedisce una a una. + * I log riportano solo id, tipo ed esito: mai indirizzi né testi. + */ +export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise { + const risultato: RisultatoCiclo = { + inviate: 0, + riprovate: 0, + fallite: 0, + differite: 0, + fermato: null, + }; + + const consentite = invioConsentiti(o.limiteGiorno, await d.inviateUltime24h(), o.lotto); + if (consentite === 0) { + risultato.fermato = "limite"; + return risultato; + } + + const lotto = await d.prendi(consentite); + for (const [indice, notifica] of lotto.entries()) { + const mail = componiMail(notifica, o); + // Solo `invia` sta nel try: se fallisse la registrazione dell'esito non è un errore SMTP e + // non deve far ripartire una mail già consegnata (la riga resta 'in_invio' e viene + // recuperata dalla funzione SQL dopo 10 minuti: al peggio un doppione, mai una perdita). + let errore: unknown; + let consegnata = false; + try { + await d.invia(mail); + consegnata = true; + } catch (e) { + errore = e; + } + + if (consegnata) { + await d.segna(notifica.id_notifica, "inviata"); + risultato.inviate += 1; + d.log(`inviata ${notifica.id_notifica} (${notifica.tipo_notifica})`); + continue; + } + + const tipo = classificaErroreSmtp(errore); + const motivo = descriviErrore(errore); + + if (tipo === "limite" || tipo === "configurazione") { + // Non è colpa della mail: si rimanda questa e tutte le successive del lotto senza + // consumare tentativi, e il ciclo si ferma. + const prossimo = dopoMinuti( + d.adesso(), + tipo === "limite" ? ATTESA_LIMITE_MINUTI : ATTESA_CONFIGURAZIONE_MINUTI, + ); + for (const restante of lotto.slice(indice)) { + await d.segna(restante.id_notifica, "differita", motivo, prossimo); + risultato.differite += 1; + } + risultato.fermato = tipo; + d.log(`ciclo fermato (${tipo}, ${motivo}): ${risultato.differite} mail rimandate`); + return risultato; + } + + const ritardo = tipo === "temporaneo" ? ritardoRiprova(notifica.tentativi_fatti) : null; + if (ritardo === null) { + await d.segna(notifica.id_notifica, "fallita", motivo); + risultato.fallite += 1; + d.log(`fallita ${notifica.id_notifica} (${notifica.tipo_notifica}, ${tipo}, ${motivo})`); + } else { + await d.segna(notifica.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo)); + risultato.riprovate += 1; + d.log(`da riprovare ${notifica.id_notifica} tra ${ritardo} min (${motivo})`); + } + } + return risultato; +} + +// --- Push dei promemoria (M24) ---------------------------------------------------------------- + +/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */ +export type IscrizionePush = { endpoint: string; p256dh: string; auth: string }; + +/** Riga restituita da `prendi_push_promemoria()`. */ +export type PushDaInviare = { + id_notifica: string; + oggetto: string; + testo: string; + tentativi_fatti: number; + iscrizioni: IscrizionePush[]; +}; + +export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata"; + +export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente"; + +/** + * Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410 + * iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403, + * 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido. + */ +export function classificaStatoPush(stato: number): EsitoStatoPush { + if (stato >= 200 && stato < 300) return "ok"; + if (stato === 404 || stato === 410) return "scaduta"; + if (stato === 429 || stato >= 500) return "temporaneo"; + return "permanente"; +} + +export type DipendenzePush = { + prendi: (max: number) => Promise; + 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 new file mode 100644 index 0000000..0e1bbb3 --- /dev/null +++ b/mailer/mailer.mjs @@ -0,0 +1,141 @@ +// 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 { inviaPush } from "../src/lib/webpush.server.ts"; +import { elaboraCiclo, elaboraCicloPush, leggiConfig } from "./mailer-core.ts"; + +const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok"; + +const lettura = leggiConfig(process.env); +if ("errori" in lettura) { + console.error(`Configurazione non valida:\n- ${lettura.errori.join("\n- ")}`); + process.exit(1); +} +const { config } = lettura; + +const log = (messaggio) => console.log(`${new Date().toISOString()} ${messaggio}`); + +async function rpc(nome, corpo) { + const risposta = await fetch(`${config.supabaseUrl}/rest/v1/rpc/${nome}`, { + method: "POST", + headers: { + apikey: config.serviceRoleKey, + Authorization: `Bearer ${config.serviceRoleKey}`, + "content-type": "application/json", + }, + body: JSON.stringify(corpo), + signal: AbortSignal.timeout(15_000), + }); + if (!risposta.ok) throw new Error(`rpc ${nome}: HTTP ${risposta.status}`); + const testo = await risposta.text(); + 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, + secure: config.smtp.secure, + ...(config.smtp.auth ? { auth: config.smtp.auth } : {}), +}); + +const dominioMessageId = config.from.split("@")[1] ?? "crapp.local"; + +const dipendenze = { + prendi: (max) => rpc("prendi_notifiche_email", { p_max: max }), + segna: (id, esito, errore, prossimo) => + rpc("esito_notifica_email", { + p_id: id, + p_esito: esito, + p_errore: errore ?? null, + p_prossimo: prossimo ? prossimo.toISOString() : null, + }), + invia: (mail) => + trasporto.sendMail({ + from: { name: config.fromName, address: config.from }, + to: mail.to, + subject: mail.subject, + text: mail.text, + html: mail.html, + messageId: mail.messageId, + }), + inviateUltime24h: () => rpc("email_inviate_ultime_24h", {}), + adesso: () => new Date(), + 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, + appUrl: config.appUrl, + dominioMessageId, +}; + +let fermo = false; +for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo = true)); +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`), +); + +while (!fermo) { + try { + const esito = await elaboraCiclo(dipendenze, opzioni); + // Un ciclo fermato dalle credenziali SMTP non segna il container come sano: dopo qualche + // minuto `docker ps` lo mostra "unhealthy" invece di lasciar credere che tutto funzioni. + if (esito.fermato !== "configurazione") await writeFile(FILE_SALUTE, String(Date.now())); + } 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/mailer/package.json b/mailer/package.json new file mode 100644 index 0000000..8fe16c7 --- /dev/null +++ b/mailer/package.json @@ -0,0 +1,12 @@ +{ + "name": "crapp-mailer", + "private": true, + "type": "module", + "description": "Worker delle notifiche email di CrAPP (M22, DD-036)", + "scripts": { + "start": "bun mailer.mjs" + }, + "dependencies": { + "nodemailer": "^10.0.11" + } +} diff --git a/package.json b/package.json index c35fc96..505bffc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crapp", - "version": "1.1.1", + "version": "1.2.0", "private": true, "sideEffects": false, "type": "module", 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/lib/preferenze-utente.ts b/src/lib/preferenze-utente.ts new file mode 100644 index 0000000..31e9199 --- /dev/null +++ b/src/lib/preferenze-utente.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; +import { useSessione } from "./auth"; + +/** + * Preferenze del singolo account (M22), non dello slot giocatore: oggi solo l'interruttore + * «Email» di Profilo → Opzioni (DD-036). Nessuna riga in `preferenze_utente` vuol dire + * «mail attive»: la riga nasce solo quando il giocatore tocca l'interruttore. Lettura e + * scrittura passano dalla RLS (ognuno vede e scrive solo la propria), senza route API. + */ +export type RigaPreferenze = { email_notifiche: boolean }; + +/** Assenza di riga = valori di default: le email sono attive. */ +export function emailAttive(riga: RigaPreferenze | null | undefined): boolean { + return riga?.email_notifiche ?? true; +} + +const chiavePreferenze = (utenteId: string | null) => ["preferenze-utente", utenteId] as const; + +/** Stato dell'interruttore «Email» dell'account e mutazione per cambiarlo. */ +export function useEmailNotifiche() { + const { utenteId } = useSessione(); + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: chiavePreferenze(utenteId), + queryFn: async (): Promise => { + const { data, error } = await supabaseNuoveTabelle + .from("preferenze_utente") + .select("email_notifiche") + .eq("auth_user_id", utenteId) + .maybeSingle(); + if (error) throw error; + return (data as RigaPreferenze | null) ?? null; + }, + enabled: !!utenteId, + staleTime: 5 * 60_000, + }); + + const imposta = useMutation({ + mutationFn: async (attive: boolean) => { + const { error } = await supabaseNuoveTabelle.from("preferenze_utente").upsert( + { + auth_user_id: utenteId, + email_notifiche: attive, + aggiornata_il: new Date().toISOString(), + }, + { onConflict: "auth_user_id" }, + ); + if (error) throw error; + return attive; + }, + onSuccess: (attive) => { + const riga: RigaPreferenze = { email_notifiche: attive }; + qc.setQueryData(chiavePreferenze(utenteId), riga); + }, + }); + + return { attive: emailAttive(query.data), pronta: !query.isPending, imposta }; +} 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/src/routes/profilo.tsx b/src/routes/profilo.tsx index 020417c..9d4ce5b 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { Flame, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react"; +import { Flame, Trash2, Bell, Mail, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react"; import { cn } from "@/lib/utils"; import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; @@ -34,6 +34,7 @@ import { } from "@/lib/giocatori-squadra"; import { esci } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; +import { useEmailNotifiche } from "@/lib/preferenze-utente"; import { Reveal } from "@/components/motion/Reveal"; import { version as APP_VERSION } from "../../package.json"; @@ -255,6 +256,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { const [notifiche, setNotifiche] = useState(false); const [inCorso, setInCorso] = useState(false); const [supportate, setSupportate] = useState(true); + const email = useEmailNotifiche(); useEffect(() => { setSupportate(pushSupportato()); @@ -283,6 +285,17 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { } } + async function cambiaEmail() { + if (email.imposta.isPending) return; + const attive = !email.attive; + try { + await email.imposta.mutateAsync(attive); + toast.success(attive ? "Email attive" : "Email disattivate"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Impossibile salvare la preferenza"); + } + } + async function logout() { try { await esci(); @@ -322,6 +335,31 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { + {admin ? ( now() - interval '24 hours'; +$$; + +REVOKE ALL ON FUNCTION public.accoda_notifica_email() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.prendi_notifiche_email(integer) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.esito_notifica_email(uuid, text, text, timestamptz) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.email_inviate_ultime_24h() FROM PUBLIC, anon, authenticated; + +GRANT EXECUTE ON FUNCTION public.prendi_notifiche_email(integer) TO service_role; +GRANT EXECUTE ON FUNCTION public.esito_notifica_email(uuid, text, text, timestamptz) + TO service_role; +GRANT EXECUTE ON FUNCTION public.email_inviate_ultime_24h() TO service_role; 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/supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql b/supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql new file mode 100644 index 0000000..434c808 --- /dev/null +++ b/supabase/migrations/20260929180000_m25_promemoria_solo_giocatori.sql @@ -0,0 +1,25 @@ +-- M25 — L'allenatore non riceve i promemoria degli eventi (DD-039) +-- +-- La migration M21 (DD-034) aggiunse `g.tipo = 'allenatore'` ai destinatari dei promemoria a 24 e +-- 3 ore: l'allenatore attivo li riceveva sempre, convocato o no. Ma un promemoria chiede di +-- esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i +-- partecipanti: per un evento come la cena di squadra riceveva un avviso su cui non poteva fare +-- nulla. Da ora i destinatari sono solo i giocatori attivi: i convocati, o tutta la rosa se +-- `convocati` è vuoto. +-- +-- Cambia solo questa funzione: `genera_promemoria_eventi()` (M17, M23), i job pg_cron, le code +-- email e push e il resto delle notifiche restano invariati. I promemoria già generati per un +-- allenatore prima della migration non vengono toccati. I messaggi dello staff continuano ad +-- arrivargli: non dipendono da questa funzione. + +CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text) +RETURNS SETOF text +LANGUAGE sql STABLE AS $$ + SELECT g.id + FROM public.eventi_app e + JOIN public.giocatori_squadra g + ON g.attivo = true + AND g.tipo = 'giocatore' + AND (cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati)) + WHERE e.id = p_evento_id; +$$; diff --git a/test/README.md b/test/README.md index 1601c86..5618be0 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) | 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 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), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | +| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | +| `helpers/` | Avvio del server di test e mini-harness condiviso | — | + +## Server di sviluppo con Node vecchio + +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/destinatari-notifiche.test.ts b/test/integration/destinatari-notifiche.test.ts new file mode 100644 index 0000000..92d1540 --- /dev/null +++ b/test/integration/destinatari-notifiche.test.ts @@ -0,0 +1,457 @@ +/** + * Destinatari delle notifiche per ruolo: `bun test/integration/destinatari-notifiche.test.ts`. + * + * Verifica, chiamando le route vere, le regole del catalogo delle notifiche + * (docs/modules/notifiche.md) che finora erano coperte solo dalla lettura del codice: + * + * - i promemoria a 24 e 3 ore arrivano ai convocati (a tutti i giocatori attivi se `convocati` + * è vuoto), mai all'allenatore (DD-039); + * - la push del sondaggio pre-partita non arriva ai dispositivi dell'allenatore, quella del + * messaggio dello staff sì (DD-034, DD-038); + * - il sollecito presenze arriva a chi non ha risposto o ha risposto «forse», mai all'allenatore, + * ed è permesso ad allenatore e admin ma non a un giocatore; + * - il turno palloni arriva all'incaricato e a chi li aveva prima, solo per allenamenti e partite, + * ed è riservato agli admin. + * + * Le push vanno a un servizio push finto in locale: nessuna richiesta esce dalla macchina. + * Gira solo sullo stack locale (`npx supabase start`). Il server di sviluppo che riceve le + * chiamate deve avere chiavi VAPID (il test le imposta se avvia lui il server; con `BASE_URL` + * valgono quelle di quel server) e usa gli slot `g1`, `g2`, `g4`, `g5` e `g6` della rosa seed, + * ripristinati alla fine. + */ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { statoLocale } from "../helpers/locale"; +import { avviaServer } from "../helpers/server"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("destinatari notifiche", "stack locale non attivo (npx supabase start)"); + riepilogo("destinatari-notifiche"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`destinatari notifiche 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 b64u = (dati: ArrayBuffer | Uint8Array) => + Buffer.from(dati as ArrayBuffer).toString("base64url"); + + // Chiavi VAPID di prova (P-256), nel formato di `webpush.server.ts`: la chiave privata reale + // non deve mai servire ai test. + const vapid = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + ]); + const jwk = await crypto.subtle.exportKey("jwk", vapid.privateKey); + process.env["VAPID_PUBLIC_KEY"] = b64u( + new Uint8Array([ + 4, + ...Buffer.from(jwk.x ?? "", "base64url"), + ...Buffer.from(jwk.y ?? "", "base64url"), + ]), + ); + process.env["VAPID_PRIVATE_KEY"] = jwk.d ?? ""; + + async function chiaviDispositivo() { + const k = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [ + "deriveBits", + ]); + return { + p256dh: b64u(await crypto.subtle.exportKey("raw", k.publicKey)), + auth: b64u(crypto.getRandomValues(new Uint8Array(16))), + }; + } + + const PREFISSO = "test-destinatari-notifiche"; + const PASSWORD = "prova-destinatari-notifiche-123"; + const G_UNO = "g1"; + const G_DUE = "g2"; + const G_QUATTRO = "g4"; + const G_CINQUE = "g5"; + const ALLENATORE = "g6"; + const PARTITA = `${PREFISSO}-partita`; + const PRESENZE = `${PREFISSO}-presenze`; + const PALLONI_1 = `${PREFISSO}-palloni-1`; + const PALLONI_2 = `${PREFISSO}-palloni-2`; + const GENERICO = `${PREFISSO}-generico`; + const PROMEMORIA_CONVOCATI = `${PREFISSO}-promemoria-convocati`; + const PROMEMORIA_TUTTI = `${PREFISSO}-promemoria-tutti`; + const TITOLO_STAFF = `${PREFISSO} messaggio ${Date.now()}`; + + 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 }; + } + + // --- Servizio push finto: registra a quale dispositivo arriva ogni push ------------------ + const ricevute: string[] = []; + const servizioPush: Server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + ricevute.push((req.url ?? "").replace(/^\//, "")); + res.statusCode = 201; + res.end(); + }); + }); + await new Promise((ok) => servizioPush.listen(0, "127.0.0.1", ok)); + const porta = (servizioPush.address() as AddressInfo).port; + const endpoint = (slot: string) => `http://127.0.0.1:${porta}/${slot}`; + const svuota = () => { + ricevute.length = 0; + }; + /** A quali slot è arrivata una push dall'ultimo `svuota()`, senza ripetizioni e in ordine. */ + const destinatariPush = () => [...new Set(ricevute)].sort(); + + type Notifica = { giocatore_id: string; titolo: string; tipo: string }; + const notifiche = (filtro: string) => + leggi( + `notifiche_utente?${filtro}&select=giocatore_id,titolo,tipo&order=giocatore_id`, + ); + const slot = (righe: Notifica[]) => righe.map((r) => r.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 allenatore = await creaUtente(`${PREFISSO}-allenatore-${Date.now()}@example.test`); + const giocatore = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`); + idUtenti.push(admin.id, allenatore.id, giocatore.id); + await scrivi("user_roles", SERVIZIO, "POST", { user_id: admin.id, role: "admin" }); + tokenAdmin = admin.token; + + const [g6] = await leggi<{ tipo: string; auth_user_id: string | null }>( + `giocatori_squadra?id=eq.${ALLENATORE}&select=tipo,auth_user_id`, + ); + originali.set(ALLENATORE, g6 ?? { tipo: "giocatore", auth_user_id: null }); + // Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin. Il + // trigger di M21 assegna il ruolo `allenatore` all'account collegato a uno slot allenatore. + await scrivi(`giocatori_squadra?id=eq.${ALLENATORE}`, tokenAdmin, "PATCH", { + tipo: "allenatore", + auth_user_id: allenatore.id, + }); + + for (const s of [G_QUATTRO, G_CINQUE, ALLENATORE]) { + await scrivi("push_subscriptions", SERVIZIO, "POST", { + giocatore_id: s, + endpoint: endpoint(s), + ...(await chiaviDispositivo()), + }); + } + for (const [id, tipo, data] of [ + [PARTITA, "partita", "2031-04-01"], + [PRESENZE, "allenamento", "2031-04-02"], + [PALLONI_1, "allenamento", "2031-05-01"], + [PALLONI_2, "allenamento", "2031-05-08"], + [GENERICO, "evento", "2031-05-09"], + ] as const) { + await scrivi("eventi_app", SERVIZIO, "POST", { + id, + tipo, + titolo: `${PREFISSO} ${id}`, + data, + ora: "20:00", + convocati: [], + }); + } + + // Promemoria: eventi che iniziano tra 2 ore, dentro le finestre dei job a 24 e a 3 ore. + const tra2Ore = new Date(Date.now() + 2 * 3_600_000); + const dataRoma = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(tra2Ore); + const oraRoma = new Intl.DateTimeFormat("it-IT", { + timeZone: "Europe/Rome", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(tra2Ore); + for (const [id, convocati] of [ + [PROMEMORIA_CONVOCATI, [G_QUATTRO]], + [PROMEMORIA_TUTTI, []], + ] as const) { + await scrivi("eventi_app", SERVIZIO, "POST", { + id, + tipo: "allenamento", + titolo: `${PREFISSO} ${id}`, + data: dataRoma, + ora: oraRoma, + convocati: [...convocati], + }); + } + + server = await avviaServer(); + console.log(`route su ${server.baseUrl}`); + const chiama = (percorso: string, token: string, corpo: unknown) => + fetch(`${server!.baseUrl}/api/public/${percorso}`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify(corpo), + }); + + const attivi = ( + await leggi<{ id: string; tipo: string }>("giocatori_squadra?attivo=eq.true&select=id,tipo") + ).sort((a, b) => a.id.localeCompare(b.id)); + const giocatoriAttivi = attivi.filter((g) => g.tipo === "giocatore").map((g) => g.id); + assert.ok(attivi.some((g) => g.id === ALLENATORE && g.tipo === "allenatore")); + + // --- 0. Promemoria a 24 e 3 ore (generati dal database) -------------------------------- + await prova( + "il promemoria arriva ai soli convocati: l'allenatore non lo riceve (DD-039)", + async () => { + for (const [tipo, finestra] of [ + ["evento_promemoria_24h", "24 hours"], + ["evento_promemoria_3h", "3 hours"], + ] as const) { + 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: finestra }), + }); + assert.ok(res.ok, `genera_promemoria_eventi ${tipo}`); + const righe = await notifiche(`evento_id=eq.${PROMEMORIA_CONVOCATI}&tipo=eq.${tipo}`); + assert.deepEqual(slot(righe), [G_QUATTRO], tipo); + } + }, + ); + + await prova( + "con convocati vuoto il promemoria arriva a tutti i giocatori attivi, mai all'allenatore", + async () => { + for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h"]) { + const righe = await notifiche(`evento_id=eq.${PROMEMORIA_TUTTI}&tipo=eq.${tipo}`); + assert.deepEqual(slot(righe), giocatoriAttivi, tipo); + } + }, + ); + + // --- 1. Messaggio dello staff --------------------------------------------------------- + await prova( + "controllo: la push arriva al servizio finto (il server ha le chiavi VAPID)", + async () => { + svuota(); + const res = await chiama("notifica-personalizzata", tokenAdmin, { + messaggio: `${TITOLO_STAFF} controllo`, + giocatoreId: G_QUATTRO, + }); + assert.equal(res.status, 200); + assert.deepEqual( + destinatariPush(), + [G_QUATTRO], + "il server di prova non manda push: servono VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY, oppure non usare BASE_URL", + ); + }, + ); + + await prova( + "il messaggio dello staff a tutti raggiunge anche i dispositivi dell'allenatore", + async () => { + svuota(); + const res = await chiama("notifica-personalizzata", tokenAdmin, { + messaggio: TITOLO_STAFF, + }); + assert.equal(res.status, 200); + assert.deepEqual(destinatariPush(), [ALLENATORE, G_QUATTRO, G_CINQUE].sort()); + const righe = await notifiche(`corpo=eq.${encodeURIComponent(TITOLO_STAFF)}&tipo=eq.admin`); + assert.deepEqual( + slot(righe), + attivi.map((g) => g.id), + "in-app: tutta la rosa attiva, allenatore compreso", + ); + }, + ); + + await prova("il messaggio a un solo giocatore non arriva agli altri dispositivi", async () => { + svuota(); + await chiama("notifica-personalizzata", tokenAdmin, { + messaggio: `${TITOLO_STAFF} solo g5`, + giocatoreId: G_CINQUE, + }); + assert.deepEqual(destinatariPush(), [G_CINQUE]); + }); + + await prova( + "un allenatore o un giocatore non possono mandare il messaggio dello staff", + async () => { + for (const token of [allenatore.token, giocatore.token]) { + const res = await chiama("notifica-personalizzata", token, { messaggio: "no" }); + assert.ok(!res.ok, `stato ${res.status}`); + } + }, + ); + + // --- 2. Sondaggio pre-partita --------------------------------------------------------- + await prova("il sondaggio non manda la push ai dispositivi dell'allenatore", async () => { + svuota(); + const res = await chiama("apri-sondaggio", tokenAdmin, { eventoId: PARTITA }); + assert.equal(res.status, 200); + assert.deepEqual(destinatariPush(), [G_QUATTRO, G_CINQUE].sort()); + }); + + await prova("il sondaggio non crea l'avviso in-app per l'allenatore", async () => { + const righe = await notifiche(`evento_id=eq.${PARTITA}&tipo=eq.sondaggio_cacche`); + assert.deepEqual(slot(righe), giocatoriAttivi); + }); + + await prova("un allenatore non può aprire il sondaggio", async () => { + const res = await chiama("apri-sondaggio", allenatore.token, { eventoId: PARTITA }); + assert.ok(!res.ok, `stato ${res.status}`); + }); + + // --- 3. Sollecito presenze ------------------------------------------------------------ + await scrivi("risposte_presenze", SERVIZIO, "POST", [ + { evento_id: PRESENZE, giocatore_id: G_QUATTRO, stato: "presente" }, + { evento_id: PRESENZE, giocatore_id: G_CINQUE, stato: "forse" }, + ]); + + await prova( + "l'allenatore può sollecitare: arriva a chi non ha risposto e a chi ha detto «forse»", + async () => { + svuota(); + const res = await chiama("sollecita-presenze", allenatore.token, { eventoId: PRESENZE }); + assert.equal(res.status, 200); + const righe = await notifiche(`evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze`); + assert.deepEqual( + slot(righe), + giocatoriAttivi.filter((id) => id !== G_QUATTRO), + "esclusi chi ha già risposto e l'allenatore, incluso chi ha risposto «forse»", + ); + assert.ok(slot(righe).includes(G_CINQUE)); + assert.deepEqual(destinatariPush(), [G_CINQUE], "push solo ai dispositivi dei sollecitati"); + }, + ); + + await prova("un giocatore non può sollecitare le presenze", async () => { + const res = await chiama("sollecita-presenze", giocatore.token, { eventoId: PRESENZE }); + assert.ok(!res.ok, `stato ${res.status}`); + }); + + // --- 4. Turno palloni ----------------------------------------------------------------- + await scrivi("turni_palloni", SERVIZIO, "POST", [ + { evento_id: PALLONI_1, giocatore_id: G_UNO }, + { evento_id: PALLONI_2, giocatore_id: G_DUE }, + ]); + + await prova("il turno palloni arriva all'incaricato e a chi li aveva prima", async () => { + const res = await chiama("promemoria-palloni", tokenAdmin, { eventoId: PALLONI_2 }); + assert.equal(res.status, 200); + const righe = await notifiche(`evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni`); + assert.deepEqual(slot(righe), [G_UNO, G_DUE]); + const per = new Map(righe.map((r) => [r.giocatore_id, r.titolo])); + assert.equal(per.get(G_DUE), "Tocca a te prendere i palloni"); + assert.equal(per.get(G_UNO), "Porta i palloni"); + }); + + await prova( + "per un evento che non è allenamento né partita non parte nessun avviso", + async () => { + await scrivi("turni_palloni", SERVIZIO, "POST", { + evento_id: GENERICO, + giocatore_id: G_QUATTRO, + }); + const res = await chiama("promemoria-palloni", tokenAdmin, { eventoId: GENERICO }); + assert.equal(res.status, 200); + assert.equal((await notifiche(`evento_id=eq.${GENERICO}&tipo=eq.turno_palloni`)).length, 0); + }, + ); + + await prova("un allenatore non può far partire il turno palloni", async () => { + const res = await chiama("promemoria-palloni", allenatore.token, { eventoId: PALLONI_2 }); + assert.ok(!res.ok, `stato ${res.status}`); + }); + } finally { + server?.stop(); + servizioPush.close(); + await rest( + `push_subscriptions?endpoint=like.${encodeURIComponent(`http://127.0.0.1:${porta}/*`)}`, + SERVIZIO, + { + method: "DELETE", + }, + ); + await rest(`notifiche_utente?corpo=like.${encodeURIComponent(`${PREFISSO}*`)}`, SERVIZIO, { + method: "DELETE", + }); + for (const id of [ + PARTITA, + PRESENZE, + PALLONI_1, + PALLONI_2, + GENERICO, + PROMEMORIA_CONVOCATI, + PROMEMORIA_TUTTI, + ]) { + await rest(`notifiche_utente?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await rest(`turni_palloni?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + } + if (tokenAdmin) { + for (const [id, valori] of originali) { + await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify(valori), + }); + } + } + for (const id of idUtenti) { + await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, { + method: "DELETE", + headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` }, + }); + } + riepilogo("destinatari-notifiche"); + } +} diff --git a/test/integration/notifiche-email.test.ts b/test/integration/notifiche-email.test.ts new file mode 100644 index 0000000..8a7861a --- /dev/null +++ b/test/integration/notifiche-email.test.ts @@ -0,0 +1,571 @@ +/** + * Notifiche email (M22, DD-036): `bun test/integration/notifiche-email.test.ts`. + * + * Copre il lato database del canale email: il trigger che accoda ogni notifica di + * `notifiche_utente`, le tre funzioni RPC del worker (`prendi_notifiche_email`, + * `esito_notifica_email`, `email_inviate_ultime_24h`), la preferenza per account e la RLS che + * tiene la coda lontana dai client. Il worker vero e la logica di invio sono coperti da + * `unit/mailer-core.test.ts`; qui non parte nessuna mail. + * + * Gira solo sullo stack locale (`npx supabase start`): usa gli slot `g4` (collegato a un + * account di prova) e `g5` (senza account) della rosa seed, con l'email impostata per la + * durata del test e ripristinata 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("notifiche email", "stack locale non attivo (npx supabase start)"); + riepilogo("notifiche-email"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`notifiche email su ${URL_BASE}`); + + const PREFISSO = "test-notifiche-email"; + // Un evento per test che ne ha bisogno: (giocatore, evento, tipo) è UNIQUE. + const EVENTO_TIPI = `${PREFISSO}-tipi`; + const EVENTO_UPSERT = `${PREFISSO}-upsert`; + const EVENTO_PRESA = `${PREFISSO}-presa`; + const EVENTO_CRON = `${PREFISSO}-cron`; + const SLOT_COLLEGATO = "g4"; + const SLOT_LIBERO = "g5"; + const EMAIL_COLLEGATO = `${PREFISSO}-g4@example.test`; + const EMAIL_LIBERO = `${PREFISSO}-g5@example.test`; + const PASSWORD = "prova-notifiche-email-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, token: string, metodo: string, corpo: unknown) { + const res = await rest(percorso, token, { method: metodo, body: 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, stato: res.status, dati: (testo ? JSON.parse(testo) : null) as T }; + } + + async function creaUtente(email: string): Promise { + 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)}`); + return corpo.id; + } + + async function accedi(email: string): Promise { + const res = 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 corpo = (await res.json()) as { access_token?: string }; + if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`); + return corpo.access_token; + } + + const eliminaUtente = (id: string) => + fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, { + method: "DELETE", + headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` }, + }); + + type Coda = { + notifica_id: string; + stato: string; + tentativi: number; + prossimo_tentativo: string; + errore: string | null; + inviata_il: string | null; + }; + type Preso = { + id_notifica: string; + destinatario: string; + oggetto: string; + testo: string; + id_evento: string | null; + tipo_notifica: string; + tentativi_fatti: number; + }; + + const creaNotifica = async ( + giocatoreId: string, + extra: Record = {}, + ): Promise => { + const res = await rest("notifiche_utente", SERVIZIO, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + giocatore_id: giocatoreId, + tipo: "admin", + titolo: `${PREFISSO} titolo`, + corpo: `${PREFISSO} corpo`, + ...extra, + }), + }); + if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`); + return ((await res.json()) as { id: string }[])[0]!.id; + }; + + const coda = async (id: string): Promise => + (await leggi(`notifiche_email_coda?notifica_id=eq.${id}&select=*`))[0]; + + /** Prende il lotto e tiene solo le righe di questo test: la coda locale può avere altro. */ + const prendi = async (mie: string[], max = 100): Promise => { + const r = await rpc("prendi_notifiche_email", { p_max: max }); + assert.ok(r.ok, `prendi_notifiche_email: ${r.stato}`); + 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_notifica_email", { + p_id: id, + p_esito: tipo, + p_errore: errore, + p_prossimo: prossimo, + }); + assert.ok(r.ok, `esito_notifica_email: ${r.stato}`); + }; + + const idUtenti: string[] = []; + const creati: string[] = []; + let tokenAdmin = ""; + const originali = new Map(); + + try { + // --- preparazione: un admin, un giocatore collegato a g4, g5 senza account ----------- + const emailAdmin = `${PREFISSO}-admin-${Date.now()}@example.test`; + const emailGiocatore = `${PREFISSO}-giocatore-${Date.now()}@example.test`; + const idAdmin = await creaUtente(emailAdmin); + const idGiocatore = await creaUtente(emailGiocatore); + idUtenti.push(idAdmin, idGiocatore); + await scrivi("user_roles", SERVIZIO, "POST", { user_id: idAdmin, role: "admin" }); + tokenAdmin = await accedi(emailAdmin); + const tokenGiocatore = await accedi(emailGiocatore); + + for (const id of [SLOT_COLLEGATO, SLOT_LIBERO]) { + const [riga] = await leggi<{ email: string | null; auth_user_id: string | null }>( + `giocatori_squadra?id=eq.${id}&select=email,auth_user_id`, + ); + originali.set(id, riga ?? { email: null, auth_user_id: null }); + } + // Il trigger `enforce_giocatori_squadra_update` rifiuta la service key: serve l'admin. + await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", { + email: EMAIL_COLLEGATO, + auth_user_id: idGiocatore, + }); + await scrivi(`giocatori_squadra?id=eq.${SLOT_LIBERO}`, tokenAdmin, "PATCH", { + email: EMAIL_LIBERO, + }); + for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA]) { + await scrivi("eventi_app", SERVIZIO, "POST", { + id, + tipo: "allenamento", + titolo: `${PREFISSO} evento`, + data: "2030-01-01", + ora: "20:00", + convocati: [], + }); + } + + // --- 1. Trigger --------------------------------------------------------------------- + await prova("una notifica nuova finisce in coda, in_coda e con zero tentativi", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 0); + assert.equal(riga?.inviata_il, null); + }); + + await prova("ogni tipo di notifica esistente viene accodato", async () => { + for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) { + const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI }); + creati.push(id); + assert.equal((await coda(id))?.stato, "in_coda", tipo); + } + const id = await creaNotifica(SLOT_COLLEGATO, { + tipo: "sollecita_presenze", + evento_id: EVENTO_TIPI, + }); + creati.push(id); + assert.equal((await coda(id))?.stato, "in_coda", "sollecita_presenze"); + }); + + await prova("segnare come letta non rimette in coda una mail già inviata", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const [presa] = await prendi([id]); + assert.ok(presa); + await esito(id, "inviata"); + await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "PATCH", { letta: true }); + assert.equal((await coda(id))?.stato, "inviata"); + }); + + await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => { + const id = await creaNotifica(SLOT_COLLEGATO, { + tipo: "turno_palloni", + evento_id: EVENTO_UPSERT, + }); + creati.push(id); + await prendi([id]); + await esito(id, "inviata"); + assert.equal((await coda(id))?.stato, "inviata"); + + // Come `promemoria-palloni.ts` e `sollecita-presenze.ts`: stesso (giocatore, evento, tipo). + const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, + body: JSON.stringify({ + giocatore_id: SLOT_COLLEGATO, + tipo: "turno_palloni", + titolo: `${PREFISSO} aggiornato`, + corpo: "nuovo testo", + evento_id: EVENTO_UPSERT, + letta: false, + creato_il: new Date().toISOString(), + }), + }); + assert.ok(res.ok, `upsert: ${res.status}`); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 0); + assert.equal(riga?.inviata_il, null); + }); + + await prova( + "il promemoria del cron accoda una volta sola, anche se il cron rigira", + async () => { + const adesso = new Date(Date.now() + 20 * 60 * 60 * 1000); + const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(adesso); + const ora = new Intl.DateTimeFormat("it-IT", { + timeZone: "Europe/Rome", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(adesso); + await scrivi("eventi_app", SERVIZIO, "POST", { + id: EVENTO_CRON, + tipo: "allenamento", + titolo: `${PREFISSO} cron`, + data, + ora, + convocati: [SLOT_COLLEGATO], + }); + const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" }; + await rpc("genera_promemoria_eventi", args); + const [notifica] = await leggi<{ id: string }>( + `notifiche_utente?evento_id=eq.${EVENTO_CRON}&tipo=eq.evento_promemoria_24h&select=id`, + ); + assert.ok(notifica, "il cron ha creato la notifica"); + assert.equal((await coda(notifica.id))?.stato, "in_coda"); + + await prendi([notifica.id]); + await esito(notifica.id, "inviata"); + await rpc("genera_promemoria_eventi", args); + assert.equal( + (await coda(notifica.id))?.stato, + "inviata", + "ON CONFLICT DO NOTHING: nessun rinvio", + ); + }, + ); + + await prova("cancellare la notifica (swipe) toglie anche la riga in coda", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + assert.ok(await coda(id)); + await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "DELETE", undefined); + assert.equal(await coda(id), undefined); + }); + + // --- 2. prendi_notifiche_email ------------------------------------------------------ + await prova("prendi restituisce destinatario e testo e marca la riga in_invio", async () => { + const id = await creaNotifica(SLOT_COLLEGATO, { + titolo: `${PREFISSO} oggetto`, + corpo: `${PREFISSO} testo`, + evento_id: EVENTO_PRESA, + tipo: "sollecita_presenze", + }); + creati.push(id); + const [p] = await prendi([id]); + assert.ok(p, "la riga è stata presa"); + assert.equal(p.destinatario, EMAIL_COLLEGATO); + assert.equal(p.id_evento, EVENTO_PRESA); + assert.equal(p.tipo_notifica, "sollecita_presenze"); + assert.equal((await coda(id))?.stato, "in_invio"); + }); + + await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + 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: 6 }, () => creaNotifica(SLOT_COLLEGATO))); + creati.push(...ids); + 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"); + assert.ok(tutti.length >= 3 && tutti.length <= 6); + // Le righe rimaste tornano prese al giro dopo, una sola volta ciascuna. + const resto = (await prendi(ids)).map((p) => p.id_notifica); + assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort()); + }); + + await prova("slot senza account collegato: mail 'saltata', mai presa", async () => { + const id = await creaNotifica(SLOT_LIBERO); + creati.push(id); + assert.equal((await prendi([id])).length, 0); + const riga = await coda(id); + assert.equal(riga?.stato, "saltata"); + assert.equal(riga?.errore, null); + }); + + await prova("slot collegato ma senza email: 'saltata'", async () => { + await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", { + email: null, + }); + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 0); + assert.equal((await coda(id))?.stato, "saltata"); + await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", { + email: EMAIL_COLLEGATO, + }); + }); + + await prova("la mail in coda viene ripresa dopo 10 minuti se il worker è morto", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + assert.equal((await prendi([id])).length, 0, "ancora in invio"); + const vecchia = new Date(Date.now() - 11 * 60_000).toISOString(); + await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", { + aggiornata_il: vecchia, + }); + assert.equal((await prendi([id])).length, 1, "recuperata"); + }); + + // --- 3. Preferenza per account ------------------------------------------------------ + await prova("nessuna riga in preferenze_utente vuol dire mail attive", async () => { + const righe = await leggi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`); + assert.equal(righe.length, 0); + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + }); + + await prova("il giocatore spegne le email dal proprio account: 'saltata'", async () => { + const res = await rest("preferenze_utente?on_conflict=auth_user_id", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, + body: JSON.stringify({ auth_user_id: idGiocatore, email_notifiche: false }), + }); + assert.ok(res.ok, `upsert preferenza: ${res.status}`); + + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 0); + assert.equal((await coda(id))?.stato, "saltata"); + }); + + await prova("riacceso l'interruttore le mail nuove ripartono", async () => { + await scrivi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`, tokenGiocatore, "PATCH", { + email_notifiche: true, + }); + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + }); + + await prova("un giocatore non scrive né legge le preferenze di un altro account", async () => { + const res = await rest("preferenze_utente", tokenGiocatore, { + method: "POST", + body: JSON.stringify({ auth_user_id: idAdmin, email_notifiche: false }), + }); + assert.ok(!res.ok, "insert per un altro account rifiutato dalla RLS"); + await scrivi("preferenze_utente", SERVIZIO, "POST", { + auth_user_id: idAdmin, + email_notifiche: false, + }); + const viste = await leggi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore); + assert.equal(viste.length, 0, "la riga altrui è invisibile"); + await scrivi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore, "PATCH", { + email_notifiche: true, + }); + const [altrui] = await leggi<{ email_notifiche: boolean }>( + `preferenze_utente?auth_user_id=eq.${idAdmin}`, + ); + assert.equal(altrui?.email_notifiche, false, "l'update altrui non ha toccato nulla"); + }); + + // --- 4. esito_notifica_email -------------------------------------------------------- + await prova( + "'inviata': stato, orario, e la riga entra nel conteggio delle 24 ore", + async () => { + const prima = (await rpc("email_inviate_ultime_24h", {})).dati; + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + await esito(id, "inviata"); + const riga = await coda(id); + assert.equal(riga?.stato, "inviata"); + assert.ok(riga?.inviata_il); + assert.equal((await rpc("email_inviate_ultime_24h", {})).dati, prima + 1); + }, + ); + + await prova( + "'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata", + async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + const tra1h = new Date(Date.now() + 60 * 60_000).toISOString(); + await esito(id, "riprova", "ETIMEDOUT", tra1h); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 1); + assert.equal(riga?.errore, "ETIMEDOUT"); + assert.equal((await prendi([id])).length, 0, "non ancora scaduto il ritardo"); + + const passato = new Date(Date.now() - 1000).toISOString(); + await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", { + prossimo_tentativo: passato, + }); + const [p] = await prendi([id]); + assert.equal(p?.tentativi_fatti, 1, "il worker vede quanti tentativi sono già stati fatti"); + }, + ); + + await prova("'differita': torna in coda senza consumare un tentativo", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + await esito(id, "differita", "550", new Date(Date.now() + 60 * 60_000).toISOString()); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 0); + }); + + await prova("'fallita' è definitiva e non viene più presa", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + await esito(id, "fallita", "550"); + assert.equal((await coda(id))?.stato, "fallita"); + assert.equal((await prendi([id])).length, 0); + }); + + await prova("un esito su una riga non 'in_invio' non cambia nulla", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await esito(id, "inviata"); // ancora in_coda: nessuno l'ha presa + assert.equal((await coda(id))?.stato, "in_coda"); + }); + + await prova("un esito sconosciuto è rifiutato", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const r = await rpc("esito_notifica_email", { p_id: id, p_esito: "boh" }); + assert.ok(!r.ok); + }); + + // --- 5. Permessi -------------------------------------------------------------------- + await prova("un giocatore non vede né scrive la coda", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const lettura = await rest("notifiche_email_coda?select=*", tokenGiocatore); + const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : []; + assert.equal(righe.length, 0); + const scrittura = await rest(`notifiche_email_coda?notifica_id=eq.${id}`, tokenGiocatore, { + method: "PATCH", + body: JSON.stringify({ stato: "in_coda", tentativi: 0 }), + }); + assert.ok(!scrittura.ok || (await scrittura.text()) === ""); + const inserimento = await rest("notifiche_email_coda", tokenGiocatore, { + method: "POST", + body: JSON.stringify({ notifica_id: id }), + }); + assert.ok(!inserimento.ok); + assert.equal((await coda(id))?.stato, "in_coda"); + }); + + await prova("un admin non può usare le funzioni del worker: solo la service role", async () => { + for (const [nome, corpo] of [ + ["prendi_notifiche_email", { p_max: 1 }], + ["esito_notifica_email", { p_id: creati[0], p_esito: "fallita" }], + ["email_inviate_ultime_24h", {}], + ] as const) { + for (const token of [tokenAdmin, tokenGiocatore]) { + const r = await rpc(nome, { ...corpo }, token); + assert.ok(!r.ok, `${nome} non deve essere chiamabile da un utente`); + } + } + }); + } finally { + await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + await rest( + `notifiche_utente?evento_id=in.(${[EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON].join(",")})`, + SERVIZIO, + { method: "DELETE" }, + ); + for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON]) { + await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + } + if (tokenAdmin) { + for (const [id, valori] of originali) { + await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify(valori), + }); + } + } + for (const id of idUtenti) { + await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await eliminaUtente(id); + } + riepilogo("notifiche-email"); + } +} diff --git a/test/integration/permessi-allenatore.test.ts b/test/integration/permessi-allenatore.test.ts index 051f791..4f12c69 100644 --- a/test/integration/permessi-allenatore.test.ts +++ b/test/integration/permessi-allenatore.test.ts @@ -284,21 +284,37 @@ if (!locale) { assert.ok(!votato.ok, `l'allenatore non si vota (${votato.status})`); }); - await prova("i promemoria evento arrivano anche all'allenatore non convocato", async () => { - await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { - method: "PATCH", - body: JSON.stringify({ convocati: [GIOCATORE] }), - }); - const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, { - method: "POST", - body: JSON.stringify({ p_evento_id: EVENTO }), - }); - assert.equal(res.status, 200); - const destinatari = ((await res.json()) as Array>).map((d) => - typeof d === "string" ? d : Object.values(d)[0], - ); - assert.deepEqual([...destinatari].sort(), [GIOCATORE, SLOT].sort()); - }); + await prova( + "i promemoria evento non arrivano all'allenatore, convocato o no (DD-039)", + async () => { + const destinatari = async () => { + const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, { + method: "POST", + body: JSON.stringify({ p_evento_id: EVENTO }), + }); + assert.equal(res.status, 200); + return ((await res.json()) as Array>).map((d) => + typeof d === "string" ? d : Object.values(d)[0], + ); + }; + + // Con i convocati: solo quelli, allenatore escluso anche se attivo. + await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify({ convocati: [GIOCATORE] }), + }); + assert.deepEqual(await destinatari(), [GIOCATORE]); + + // Senza convocati (tutta la rosa): i giocatori attivi, mai l'allenatore. + await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify({ convocati: [] }), + }); + const tutti = await destinatari(); + assert.ok(tutti.includes(GIOCATORE), "il giocatore attivo è tra i destinatari"); + assert.ok(!tutti.includes(SLOT), "l'allenatore non è tra i destinatari"); + }, + ); await prova("scollegare l'account toglie il ruolo", async () => { const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAdmin, { 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 new file mode 100644 index 0000000..9efe9b8 --- /dev/null +++ b/test/unit/mailer-core.test.ts @@ -0,0 +1,632 @@ +/** Logica pura del worker email (M22): `bun test/unit/mailer-core.test.ts`. Nessuna rete né SMTP. */ +import assert from "node:assert/strict"; +import { + MAX_TENTATIVI, + classificaErroreSmtp, + classificaStatoPush, + componiMail, + descriviErrore, + elaboraCiclo, + elaboraCicloPush, + escapeHtml, + invioConsentiti, + leggiConfig, + linkNotifica, + messageId, + pulisciOggetto, + 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"; + +const ENV_BASE = { + SUPABASE_URL: "https://x.supabase.co/", + SUPABASE_SERVICE_ROLE_KEY: "chiave", + SMTP_USER: "crapp@gmail.com", + SMTP_PASS: "password-per-app", + APP_URL: "https://crapp.example/", +}; + +// --- leggiConfig -------------------------------------------------------------- +await prova("configurazione completa: default, slash finali tolti, From = SMTP_USER", () => { + const r = leggiConfig(ENV_BASE); + assert.ok("config" in r); + const c = r.config; + assert.equal(c.supabaseUrl, "https://x.supabase.co"); + assert.equal(c.appUrl, "https://crapp.example"); + assert.equal(c.from, "crapp@gmail.com"); + assert.equal(c.fromName, "CrAPP"); + assert.deepEqual(c.smtp, { + host: "smtp.gmail.com", + port: 587, + secure: false, + auth: { user: "crapp@gmail.com", pass: "password-per-app" }, + }); + assert.equal(c.pollSecondi, 30); + assert.equal(c.limiteGiorno, 400); + assert.equal(c.lotto, 10); +}); + +await prova("elenca insieme tutte le variabili mancanti", () => { + const r = leggiConfig({}); + assert.ok("errori" in r); + assert.deepEqual(r.errori, [ + "SUPABASE_URL mancante", + "SUPABASE_SERVICE_ROLE_KEY mancante", + "APP_URL mancante", + "MAIL_FROM mancante (o SMTP_USER)", + ]); +}); + +await prova("SMTP_USER e SMTP_PASS vanno insieme", () => { + const r = leggiConfig({ ...ENV_BASE, SMTP_PASS: "" }); + assert.ok("errori" in r); + assert.ok(r.errori.includes("SMTP_USER e SMTP_PASS vanno impostati insieme")); +}); + +await prova("senza credenziali SMTP (server di prova) serve MAIL_FROM e non c'è auth", () => { + const { SMTP_USER: _u, SMTP_PASS: _p, ...senza } = ENV_BASE; + assert.ok("errori" in leggiConfig(senza)); + const r = leggiConfig({ ...senza, MAIL_FROM: "prova@crapp.local", SMTP_PORT: "1025" }); + assert.ok("config" in r); + assert.equal(r.config.smtp.auth, null); + assert.equal(r.config.smtp.port, 1025); +}); + +await prova("un numero non valido è un errore, non un default silenzioso", () => { + for (const valore of ["0", "-5", "abc", "1.5"]) { + const r = leggiConfig({ ...ENV_BASE, POLL_SECONDI: valore }); + assert.ok("errori" in r, valore); + assert.deepEqual(r.errori, ["POLL_SECONDI deve essere un intero positivo"]); + } +}); + +// --- classificaErroreSmtp ----------------------------------------------------- +await prova("il tetto giornaliero di Gmail non è un errore permanente", () => { + const errore = { + responseCode: 550, + response: "550-5.4.5 Daily user sending quota exceeded.", + }; + assert.equal(classificaErroreSmtp(errore), "limite"); +}); + +await prova("credenziali rifiutate: configurazione, non colpa della mail", () => { + assert.equal(classificaErroreSmtp({ code: "EAUTH", responseCode: 535 }), "configurazione"); + assert.equal(classificaErroreSmtp({ responseCode: 534 }), "configurazione"); +}); + +await prova("5xx sul destinatario e indirizzo non valido sono permanenti", () => { + assert.equal( + classificaErroreSmtp({ responseCode: 550, response: "550 no such user" }), + "permanente", + ); + assert.equal(classificaErroreSmtp({ code: "EENVELOPE" }), "permanente"); +}); + +await prova("4xx e errori di rete sono temporanei", () => { + assert.equal(classificaErroreSmtp({ responseCode: 421 }), "temporaneo"); + assert.equal(classificaErroreSmtp({ code: "ETIMEDOUT" }), "temporaneo"); + assert.equal(classificaErroreSmtp({ code: "ECONNECTION" }), "temporaneo"); + assert.equal(classificaErroreSmtp(new Error("boh")), "temporaneo"); + assert.equal(classificaErroreSmtp(null), "temporaneo"); +}); + +await prova("descriviErrore riporta solo codici, mai il testo della risposta", () => { + const errore = { code: "EMESSAGE", responseCode: 550, response: "550 nome@gmail.com non esiste" }; + assert.equal(descriviErrore(errore), "EMESSAGE 550"); + assert.equal(descriviErrore(new Error("mario@gmail.com")), "errore sconosciuto"); +}); + +// --- backoff e tetto ---------------------------------------------------------- +await prova("backoff 1 · 5 · 30 · 120 minuti, poi nessun altro tentativo", () => { + assert.deepEqual([0, 1, 2, 3].map(ritardoRiprova), [1, 5, 30, 120]); + assert.equal(ritardoRiprova(4), null); + assert.equal(MAX_TENTATIVI, 5); +}); + +await prova("invioConsentiti non supera né il lotto né il tetto delle 24 ore", () => { + assert.equal(invioConsentiti(400, 0, 10), 10); + assert.equal(invioConsentiti(400, 395, 10), 5); + assert.equal(invioConsentiti(400, 400, 10), 0); + assert.equal(invioConsentiti(400, 450, 10), 0); +}); + +await prova("staSano: sano solo con un ciclo riuscito abbastanza recente", () => { + assert.equal(staSano(null, 1_000_000, 300_000), false); + assert.equal(staSano(900_000, 1_000_000, 300_000), true); + assert.equal(staSano(600_000, 1_000_000, 300_000), false); +}); + +// --- composizione della mail -------------------------------------------------- +const notifica = (extra: Partial = {}): NotificaEmail => ({ + id_notifica: "n-1", + destinatario: "mario@gmail.com", + oggetto: "Promemoria: Allenamento", + testo: "30/09/2026 alle 20:30", + id_evento: "ev-1", + tipo_notifica: "evento_promemoria_24h", + tentativi_fatti: 0, + ...extra, +}); +const OPZ = { appUrl: "https://crapp.example", dominioMessageId: "gmail.com" }; + +await prova("link: scheda evento se c'è, altrimenti la home; id codificato", () => { + assert.equal(linkNotifica("https://a.it/", "ev-1"), "https://a.it/evento/ev-1"); + assert.equal(linkNotifica("https://a.it", null), "https://a.it"); + assert.equal(linkNotifica("https://a.it", "a b/c"), "https://a.it/evento/a%20b%2Fc"); +}); + +await prova("Message-ID deterministico per notifica", () => { + assert.equal(messageId("abc", "gmail.com"), ""); + assert.equal(componiMail(notifica(), OPZ).messageId, componiMail(notifica(), OPZ).messageId); +}); + +await prova("oggetto su una riga sola, con un tetto e un ripiego", () => { + assert.equal(pulisciOggetto("Ciao\r\nBcc: x@y.it"), "Ciao Bcc: x@y.it"); + assert.equal(pulisciOggetto(" "), "Notifica CrAPP"); + assert.equal(pulisciOggetto("a".repeat(500)).length, 200); +}); + +await prova("la mail ha testo, HTML, link all'evento e come disattivarle", () => { + const m = componiMail(notifica(), OPZ); + assert.equal(m.to, "mario@gmail.com"); + assert.equal(m.subject, "Promemoria: Allenamento"); + assert.match(m.text, /30\/09\/2026 alle 20:30/); + assert.match(m.text, /https:\/\/crapp\.example\/evento\/ev-1/); + assert.match(m.text, /Profilo → Opzioni → Email/); + assert.match(m.html, /href="https:\/\/crapp\.example\/evento\/ev-1"/); + assert.match(m.html, /Apri CrAPP/); +}); + +await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () => { + const m = componiMail( + notifica({ testo: `\nriga "2"`, id_evento: null }), + OPZ, + ); + assert.ok(!m.html.includes("riga "2"/); + assert.match(m.html, /href="https:\/\/crapp\.example"/); + assert.equal(escapeHtml(`<&>"'`), "<&>"'"); +}); + +await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => { + const m = componiMail(notifica({ oggetto: "Ciao" }), OPZ); + assert.ok(!m.html.includes("")); +}); + +await prova("corpo vuoto: niente paragrafo vuoto", () => { + const m = componiMail(notifica({ testo: "" }), OPZ); + assert.ok(!m.html.includes('

')); + assert.ok(!m.text.startsWith("\n")); +}); + +// --- elaboraCiclo ------------------------------------------------------------- +type Chiamata = { id: string; esito: EsitoInvio; errore?: string; prossimo?: Date }; + +function ambiente(opzioni: { + lotto?: NotificaEmail[]; + inviate24h?: number; + invia?: (mail: MailComposta) => Promise; + segna?: Dipendenze["segna"]; +}) { + const chiamate: Chiamata[] = []; + const inviate: MailComposta[] = []; + const log: string[] = []; + let richiesto = -1; + const ADESSO = new Date("2026-09-29T10:00:00Z"); + const dip: Dipendenze = { + prendi: async (max) => { + richiesto = max; + return opzioni.lotto ?? []; + }, + segna: + opzioni.segna ?? + (async (id, esito, errore, prossimo) => { + chiamate.push({ + id, + esito, + ...(errore !== undefined ? { errore } : {}), + ...(prossimo ? { prossimo } : {}), + }); + }), + invia: + opzioni.invia ?? + (async (mail) => { + inviate.push(mail); + }), + inviateUltime24h: async () => opzioni.inviate24h ?? 0, + adesso: () => ADESSO, + log: (m) => log.push(m), + }; + return { dip, chiamate, inviate, log, ADESSO, richiesto: () => richiesto }; +} +const OPZ_CICLO = { limiteGiorno: 400, lotto: 10, ...OPZ }; + +await prova("ciclo: invia ogni mail e segna 'inviata'", async () => { + const a = ambiente({ lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })] }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.inviate, 2); + assert.deepEqual( + a.chiamate.map((c) => [c.id, c.esito]), + [ + ["a", "inviata"], + ["b", "inviata"], + ], + ); + assert.equal(a.inviate.length, 2); +}); + +await prova("ciclo: chiede al massimo quanto consente il tetto giornaliero", async () => { + const a = ambiente({ inviate24h: 396 }); + await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(a.richiesto(), 4); +}); + +await prova("ciclo: tetto raggiunto, non prende nulla dalla coda", async () => { + const a = ambiente({ inviate24h: 400, lotto: [notifica()] }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fermato, "limite"); + assert.equal(a.richiesto(), -1); + assert.equal(a.chiamate.length, 0); +}); + +await prova("ciclo: errore temporaneo, riprova con backoff sul numero di tentativi", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a", tentativi_fatti: 2 })], + invia: async () => { + throw { code: "ETIMEDOUT" }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.riprovate, 1); + const [c] = a.chiamate; + assert.equal(c?.esito, "riprova"); + assert.equal(c?.prossimo?.getTime(), a.ADESSO.getTime() + 30 * 60_000); +}); + +await prova("ciclo: all'ultimo tentativo l'errore temporaneo diventa 'fallita'", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a", tentativi_fatti: 4 })], + invia: async () => { + throw { responseCode: 421 }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fallite, 1); + assert.equal(a.chiamate[0]?.esito, "fallita"); +}); + +await prova("ciclo: errore permanente, subito 'fallita' senza riprovare", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" })], + invia: async () => { + throw { responseCode: 550, response: "550 no such user" }; + }, + }); + await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(a.chiamate[0]?.esito, "fallita"); + assert.equal(a.chiamate[0]?.errore, "550"); +}); + +await prova("ciclo: una mail fallita non ferma le successive", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })], + invia: async (mail) => { + if (mail.messageId.startsWith(" { + let n = 0; + const a = ambiente({ + lotto: [ + notifica({ id_notifica: "a" }), + notifica({ id_notifica: "b" }), + notifica({ id_notifica: "c" }), + ], + invia: async () => { + n += 1; + if (n === 2) + throw { responseCode: 550, response: "550-5.4.5 Daily user sending quota exceeded" }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fermato, "limite"); + assert.equal(r.inviate, 1); + assert.equal(r.differite, 2); + assert.deepEqual( + a.chiamate.map((c) => [c.id, c.esito]), + [ + ["a", "inviata"], + ["b", "differita"], + ["c", "differita"], + ], + ); + assert.equal(a.chiamate[1]?.prossimo?.getTime(), a.ADESSO.getTime() + 60 * 60_000); + }, +); + +await prova( + "ciclo: credenziali SMTP rifiutate, tutto differito senza consumare tentativi", + async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })], + invia: async () => { + throw { code: "EAUTH", responseCode: 535 }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fermato, "configurazione"); + assert.deepEqual( + a.chiamate.map((c) => c.esito), + ["differita", "differita"], + ); + assert.equal(r.fallite, 0); + }, +); + +await prova( + "ciclo: se la registrazione dell'esito fallisce non si riprova la mail già inviata", + async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" })], + segna: async () => { + throw new Error("rete giù"); + }, + }); + await assert.rejects(elaboraCiclo(a.dip, OPZ_CICLO), /rete giù/); + assert.equal(a.inviate.length, 1); + }, +); + +await prova("ciclo: i log non contengono indirizzi né testi", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })], + invia: async (mail) => { + if (mail.messageId.startsWith(" { + 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"); diff --git a/test/unit/preferenze-utente.test.ts b/test/unit/preferenze-utente.test.ts new file mode 100644 index 0000000..6afab8a --- /dev/null +++ b/test/unit/preferenze-utente.test.ts @@ -0,0 +1,16 @@ +/** Preferenze dell'account (M22): `bun test/unit/preferenze-utente.test.ts`. */ +import assert from "node:assert/strict"; +import { emailAttive } from "@/lib/preferenze-utente"; +import { prova, riepilogo } from "../helpers/prova"; + +await prova("senza riga le email sono attive (default DD-036)", () => { + assert.equal(emailAttive(null), true); + assert.equal(emailAttive(undefined), true); +}); + +await prova("con la riga vale il valore scelto dal giocatore", () => { + assert.equal(emailAttive({ email_notifiche: true }), true); + assert.equal(emailAttive({ email_notifiche: false }), false); +}); + +riepilogo("preferenze-utente");