Il testo della notifica viaggia ora cifrato dentro la push (aes128gcm, RFC
8188/8291) invece di essere recuperato dal service worker con una fetch al
risveglio. Era quella fetch a non chiudersi in tempo: ad app chiusa il browser
tiene vivo il worker pochi secondi, showNotification non veniva mai chiamata e
non compariva niente, mentre ad app aperta con la rete calda sembrava tutto a
posto. La POST porta anche Urgency: high, che chiede la consegna immediata
invece di far accumulare i messaggi fino al risveglio del dispositivo.
Cadono i pezzi che esistevano solo per rimediare al payload vuoto: la route
push-messaggio, la coda promemoria_push con la sua scadenza a 12 ore,
messaggioPalloniOggi() e il timeout nel worker. Tutti e tre i mittenti avevano
gia il testo pronto prima di inviare.
Il worker si aggiorna da solo all'avvio e a ogni ritorno in primo piano
(mantieniWorkerPushAggiornato): nella webapp installata quello vecchio puo
sopravvivere a lungo, e senza questo un dispositivo resterebbe fermo alla
versione che va a cercare il testo in rete.
Profilo -> Opzioni ha "Mandami una notifica di prova", visibile solo a notifiche
attive: manda una push a questo dispositivo e riporta stato HTTP, corpo della
risposta e se l'endpoint risulta davvero in push_subscriptions. Senza, "non
arriva" era cieco: ogni prova richiedeva un admin, un evento nello stato giusto
e una seconda persona, e la risposta del servizio push veniva buttata via.
inviaPush torna { stato, corpo } e logga il corpo sui rifiuti.
Dalle prove sul campo: a parita di server, iPhone installato da Home riceve ad
app chiusa. Su Android installato come webapp resta da verificare: il WebAPK e
un'app Android a se, con permesso notifiche (Android 13+) e voce batteria
distinti da quelli di Chrome. Annotato nei limiti noti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
8.6 KiB
Markdown
148 lines
8.6 KiB
Markdown
# Modulo — Notifiche
|
||
|
||
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
|
||
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
|
||
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
|
||
`src/routes/api/public/push-subscribe.ts`, `src/routes/api/public/push-prova.ts`,
|
||
`public/push-sw.js`
|
||
|
||
---
|
||
|
||
## Obiettivo
|
||
|
||
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).
|
||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||
|
||
---
|
||
|
||
## Dati
|
||
|
||
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
|
||
`auth` con cui si cifra il payload per quel dispositivo). La tabella `promemoria_push` non è
|
||
più usata da nessuno: serviva da coda del testo quando la push partiva vuota (DD-026).
|
||
|
||
---
|
||
|
||
## Iscrizione alle notifiche push
|
||
|
||
In Profilo → Opzioni c’è **un solo interruttore** («Notifiche»). Non esistono preferenze
|
||
separate per tipo di messaggio: l’iscrizione registra il dispositivo e lo rende destinatario
|
||
di **tutte** le push (promemoria palloni, solleciti presenze) e abilita anche le notifiche
|
||
smart in app, che usano lo stesso service worker.
|
||
|
||
1. Il giocatore attiva «Notifiche» in `/profilo` → richiesta permesso browser.
|
||
2. `GET /api/public/push-config` restituisce solo la chiave pubblica VAPID.
|
||
3. Registrazione del service worker `public/push-sw.js` e `pushManager.subscribe()`.
|
||
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
|
||
(upsert).
|
||
|
||
All'avvio e quando l'app torna visibile viene richiesto l'aggiornamento della registrazione
|
||
push esistente con `ServiceWorkerRegistration.update()`. Non si chiede un nuovo permesso,
|
||
non si ricrea la sottoscrizione e non si cambia l'endpoint: anche chi ha già attivato le
|
||
notifiche deve ricevere le correzioni del worker senza spegnere e riaccendere l'interruttore.
|
||
Gli aggiornamenti contemporanei sono accorpati; un errore di rete non blocca l'app e si
|
||
riprova al ritorno in primo piano. Il worker attende `skipWaiting()` durante l'installazione.
|
||
Il browser controlla anche autonomamente gli aggiornamenti: questa richiesta esplicita
|
||
copre in particolare le sessioni lunghe della webapp (vedi il
|
||
[ciclo di vita del service worker](https://web.dev/articles/service-worker-lifecycle)).
|
||
|
||
---
|
||
|
||
## Ruolo delle tre route pubbliche
|
||
|
||
- **`push-config`** — espone la sola chiave pubblica VAPID.
|
||
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
|
||
- **`push-prova`** — manda una push al dispositivo che la chiede e riporta stato e corpo
|
||
della risposta del servizio push, più se l'endpoint risulta in `push_subscriptions`. Serve
|
||
a rendere osservabile un "non arriva": senza, ogni prova richiede un admin, un evento nello
|
||
stato giusto e una seconda persona. Il pulsante sta in Profilo → Opzioni, sotto
|
||
l'interruttore, e compare solo a notifiche attive.
|
||
- **`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)).
|
||
|
||
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
|
||
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
|
||
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
|
||
|
||
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
|
||
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
|
||
rete. È il punto decisivo per la consegna ad app chiusa — il browser sveglia il worker per
|
||
pochi secondi, e una fetch per recuperare il testo lo faceva morire prima di
|
||
`showNotification` (DD-026).
|
||
|
||
La POST porta `Urgency: high`. Con l'urgenza predefinita ("normal") un telefono in risparmio
|
||
energetico accumula i messaggi fino al risveglio: la notifica arriva solo quando il
|
||
dispositivo è già attivo — cioè, nella pratica, solo con l'app aperta.
|
||
|
||
### Chi può farle partire (DD-024, DD-025)
|
||
|
||
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
|
||
route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo
|
||
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
|
||
variabile d'ambiente.
|
||
|
||
| Route | Controllo | Chi la chiama |
|
||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
|
||
| `csi`, `push-config`, `push-subscribe`, `push-prova` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||
|
||
---
|
||
|
||
## Notifiche smart
|
||
|
||
`calcolaNotifiche()` (`notifiche-smart.ts`) genera un evento solo quando "c'è qualcosa di
|
||
reale": badge appena sbloccato, "sei a un passo" da un traguardo, serie che raggiunge un
|
||
traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. Ogni notifica
|
||
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
|
||
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||
|
||
---
|
||
|
||
## Limiti noti
|
||
|
||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
||
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
|
||
- La tabella `promemoria_push` è rimasta nel database ma non la usa più nessuno (DD-026): va
|
||
eliminata con una migrazione alla prossima occasione.
|
||
- **Un 2xx dal server push non significa consegnato.** FCM accetta con 201 anche verso
|
||
registrazioni scadute e poi butta via il messaggio, senza il 404/410 che farebbe pulire
|
||
`push_subscriptions`. Il conteggio "inviate a N dispositivi" va letto come "accettate da N
|
||
server push", non come "arrivate a N telefoni".
|
||
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
|
||
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
|
||
esplicitamente. È il primo sospetto quando una notifica non arriva ad app chiusa su iPhone.
|
||
- **Android con l'app installata (WebAPK) è il caso fragile.** A parità di server — stessa
|
||
push, stesso payload — su iPhone installato da Home arriva ad app chiusa, sullo stesso
|
||
invio verso un Android installato come webapp no. Il WebAPK è un'app Android a sé: ha un
|
||
proprio permesso notifiche di sistema. Le restrizioni di sistema possono impedire la
|
||
visualizzazione o ritardare il risveglio del browser: un 201 dal servizio push non permette
|
||
di distinguerli. Controllare Impostazioni → App → **CrAPP** → Notifiche e le eventuali
|
||
restrizioni di batteria dell'app e del browser.
|
||
- Su Motorola verificare anche le restrizioni del **browser che ha installato CrAPP** e,
|
||
dove presente, Impostazioni → Batteria → Ottimizzazione standby app. Il produttore
|
||
documenta la limitazione dei processi in background
|
||
([guida Motorola](https://help.motorola.com/hc/3505/14/global/en-us/CG2007980805.html)).
|
||
È una possibile causa del sintomo, non una diagnosi verificata sul dispositivo: il
|
||
codice web non può rimuovere questi vincoli. La verifica richiede un invio da un altro
|
||
dispositivo mentre CrAPP è chiusa e lo schermo del Motorola è bloccato. Il pulsante di
|
||
prova invia subito, quindi da solo non dimostra la ricezione in background.
|
||
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
|
||
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
|
||
locale non viene mai mostrata, anche con permesso concesso.
|
||
- Il payload cifrato non può superare i ~4 KB: i testi attuali stanno larghi, ma un messaggio
|
||
molto lungo verrebbe rifiutato dal servizio push.
|
||
|
||
---
|
||
|
||
## Evoluzioni possibili
|
||
|
||
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
|
||
- Eliminare `promemoria_push` con una migrazione.
|
||
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
|