Fa arrivare le push ad app chiusa e rende diagnosticabile quando non arrivano.

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>
This commit is contained in:
2026-09-06 14:24:07 +02:00
co-authored by Claude Opus 5
parent 2b8ba340a6
commit d8f334007b
26 changed files with 782 additions and 306 deletions
+9
View File
@@ -6,6 +6,15 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
## Versione attuale — agosto 2026
### Le notifiche push arrivano anche ad app chiusa
- Il testo della notifica viaggia ora cifrato **dentro** la push (`aes128gcm`, RFC 8291)
invece di essere recuperato dal service worker con una fetch al risveglio: era quella fetch
a non arrivare mai in tempo a telefono bloccato, e la notifica non compariva affatto
(DD-026).
- Spariscono la route `/api/public/push-messaggio`, la coda `promemoria_push` e
`messaggioPalloniOggi()`: esistevano solo per rimediare al payload vuoto.
### Lo Scout Live si apre dalla pagina della partita
- Tolto dalla home, era rimasto senza nessun link: `/scout` si raggiungeva solo scrivendo
+1 -1
View File
@@ -73,7 +73,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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` | Storico dei promemoria inviati. | |
| `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. |
## Funzioni speciali
+57 -10
View File
@@ -41,6 +41,7 @@ Serve a rispondere a domande del tipo:
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
**In valutazione**
@@ -843,8 +844,9 @@ i chiamanti sono diversi (`src/lib/auth-route.server.ts`):
- `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito
subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025).
`csi`, `push-config`, `push-subscribe` e `push-messaggio` restano aperte: le chiamano il
browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubblico.
`csi`, `push-config` e `push-subscribe` restano aperte: le chiama il browser prima del login,
dove qualsiasi segreto sarebbe pubblico. (`push-messaggio` esisteva per lo stesso motivo ed è
sparita con DD-026.)
**Alternative scartate**
@@ -894,10 +896,9 @@ riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i
corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e
`richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono.
Il testo dell'avviso viaggia in coda su `promemoria_push`: la push parte vuota e il service
worker chiede a `push-messaggio` cosa mostrare, ma quella route sa raccontare solo la giornata
corrente. Senza la coda, un avviso mandato il martedì per il sabato arriverebbe con il testo
generico.
Il testo dell'avviso viaggia dentro la push, cifrato nel payload (DD-026): il service worker
lo mostra così com'è, quindi un avviso mandato il martedì per il sabato arriva col testo
giusto.
**Alternative scartate**
@@ -907,19 +908,65 @@ generico.
- Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in
ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che
non esiste.
- Calcolare il testo al volo come fa `messaggioPalloniOggi` → funziona solo se l'avviso parte
il giorno stesso, cioè proprio il vincolo da cui volevamo uscire.
- Calcolare il testo al volo sulla giornata corrente → funziona solo se l'avviso parte il
giorno stesso, cioè proprio il vincolo da cui volevamo uscire.
**Conseguenze**
- Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme
il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo
avanti rispetto alla realtà, dove non partiva mai.
- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`,
perché `push-messaggio` continua a costruire il testo della giornata per le push senza coda.
- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`.
- Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun
ambiente.
**Riesame**
Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron
torna utile, e con lui il segreto: DD-024 descrive già come farlo.
---
### DD-026 — Il testo della notifica viaggia dentro la push
**Stato:** accettata · **Data:** settembre 2026
**Contesto**
Le push partivano senza payload: il service worker, appena svegliato, chiedeva a
`/api/public/push-messaggio` che cosa mostrare. Ad app aperta funzionava; ad app chiusa e
telefono bloccato non arrivava niente. È lo scenario per cui il browser dà al worker pochi
secondi di vita: una fetch verso un endpoint che interroga Supabase è esattamente ciò che non
riesce a chiudersi in tempo, e senza `showNotification` non compare nulla. `Urgency: high` e
un timeout di 3 secondi sul recupero del testo avevano attenuato il sintomo, non la causa.
**Decisione**
Titolo e testo viaggiano cifrati nel corpo della push (`aes128gcm`, RFC 8188/8291) con le
chiavi `p256dh` e `auth` già salvate in `push_subscriptions`. Il service worker legge
`event.data.json()` e mostra la notifica: zero rete, zero attesa.
Cadono di conseguenza la route `push-messaggio`, la coda `promemoria_push` (con la sua
scadenza a 12 ore), `messaggioPalloniOggi()` e il timeout nel service worker: tutti pezzi che
esistevano solo per rimediare al payload vuoto. Tutti e tre i mittenti
(`apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`) avevano già il testo pronto
prima di inviare.
**Alternative scartate**
- Usare la libreria `web-push` → dipende da `node:crypto`, e il build nitro ha come target
Cloudflare. La cifratura sta in ~40 righe di Web Crypto, già disponibile ovunque.
- Allungare ancora il timeout della fetch → allunga anche il tempo in cui il worker può
morire prima di mostrare qualcosa. Il problema era la fetch, non la sua durata.
**Conseguenze**
- La notifica non dipende più dalla rete al momento del risveglio, e nemmeno da una funzione
serverless che risponda in fretta a freddo.
- Sparisce l'unico punto in cui chiunque conoscesse un endpoint push poteva leggere il
messaggio destinato a quel dispositivo.
- Il payload sta sotto i 4 KB: i testi attuali sono ampiamente dentro, ma un messaggio molto
lungo andrebbe accorciato.
- La tabella `promemoria_push` resta nel database, ora inutilizzata: va rimossa con una
migrazione quando si tocca lo schema.
**Riesame**
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
+3
View File
@@ -5,6 +5,9 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ
## In corso
- Correzione push Android pronta in locale: aggiornamento del worker nelle sessioni della
webapp e test del canale cifrato verificati. Resta la verifica su Motorola installato,
ad app chiusa e schermo bloccato; procedura e limiti in [Notifiche](modules/notifiche.md).
- Documentazione tecnica del progetto.
- Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il
login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle
+60 -33
View File
@@ -3,7 +3,7 @@
**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-messaggio.ts`, `src/routes/api/public/push-subscribe.ts`,
`src/routes/api/public/push-subscribe.ts`, `src/routes/api/public/push-prova.ts`,
`public/push-sw.js`
---
@@ -21,9 +21,9 @@ indipendenti:
## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`), `promemoria_push` (coda
"consuma e cancella" del testo da mostrare — nonostante il nome, **non** è uno storico
persistente: la riga viene eliminata non appena letta dal service worker).
`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).
---
@@ -40,24 +40,45 @@ smart in app, che usano lo stesso service worker.
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.
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: mette in coda su
`promemoria_push` l'avviso di apertura del sondaggio pre-partita per **tutti** i dispositivi
iscritti e manda la push (vedi [Scout Live](scout-live.md)).
- **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della
ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per
sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push`
(scritto da `sollecita-presenze`, vedi [Presenze](presenze.md)), altrimenti il messaggio
calcolato al volo sul turno palloni (vedi [Palloni](palloni.md)).
- **`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) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
`sollecita-presenze.ts` e `promemoria-palloni.ts`.
(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)
@@ -66,10 +87,10 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
è 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-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno |
| 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 |
---
@@ -87,34 +108,40 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si
autocancella alla lettura: non conserva nulla. Un messaggio in coda **scade dopo 12 ore**
(`ORE_VALIDITA_PROMEMORIA`): la riga si cancella comunque alla prima lettura, ma se è
vecchia il testo non viene mostrato e si ripiega su quello calcolato. Serve perché la coda
si svuota solo quando il dispositivo legge, e se la push non arriva mai la riga resterebbe
a dirottare la notifica successiva, di qualunque tipo, giorni dopo.
- 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".
- Nessuna verifica di autenticazione su `push-messaggio`: chiunque conosca un endpoint push
valido può leggerne il messaggio. Non è chiudibile con un segreto, perché a chiamarla è il
service worker, dove qualsiasi segreto sarebbe pubblico; di fatto la protegge il dover
conoscere l'endpoint, che è un URL segreto per dispositivo. `promemoria-palloni` invece è
chiusa da DD-024.
- 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.
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.
- Payload push sempre vuoto: ogni notifica richiede una fetch aggiuntiva (`push-messaggio`)
per ottenere il testo, quindi serve rete disponibile anche solo per mostrare il messaggio.
- 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.
- Aggiungere autenticazione alle route pubbliche coinvolte.
- Eliminare `promemoria_push` con una migrazione.
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
+3 -6
View File
@@ -48,10 +48,8 @@ e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve
palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
per ciascuno.
Il testo va messo in coda su `promemoria_push` prima di inviare la push, perché la push parte
"vuota" e il service worker chiede a `/api/public/push-messaggio` cosa mostrare — e quella
route da sola sa raccontare solo la giornata corrente, quindi un avviso mandato con giorni di
anticipo arriverebbe con il testo generico. Stesso meccanismo di `apri-sondaggio` (vedi
Titolo e testo viaggiano cifrati dentro la push, quindi il service worker li mostra senza
nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
[Notifiche](notifiche.md)).
---
@@ -60,8 +58,7 @@ anticipo arriverebbe con il testo generico. Stesso meccanismo di `apri-sondaggio
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
turno oggi" — resta in `palloni-core.ts` perché la usa `push-messaggio` per il testo
calcolato al volo, ma nessuno scheduler la interroga.
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
+1 -1
View File
@@ -54,7 +54,7 @@ Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date
├─ calcola i destinatari: giocatori attivi senza risposta o con "forse"
├─ per ciascuno registra il messaggio in promemoria_push e invia una push
├─ per ciascuno invia una push col testo cifrato nel payload
│ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410)
```