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:
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
+22
-27
@@ -1,36 +1,31 @@
|
||||
// Service worker dedicato alle notifiche push del turno palloni.
|
||||
// Service worker dedicato alle notifiche push di CrAPP.
|
||||
// Non memorizza nella cache pagine o asset dell'app.
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
|
||||
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||
|
||||
async function testoNotifica() {
|
||||
try {
|
||||
const sub = await self.registration.pushManager.getSubscription();
|
||||
if (!sub) return null;
|
||||
const res = await fetch("/api/public/push-messaggio", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
// Il testo arriva cifrato dentro la push: nessuna rete, nessuna attesa. A dispositivo
|
||||
// dormiente il browser sveglia il worker per pochi secondi e una fetch di troppo lo fa
|
||||
// terminare prima di `showNotification` — la notifica non appare affatto, mentre ad app
|
||||
// aperta, con la rete calda, sembra funzionare tutto.
|
||||
let dati = null;
|
||||
try {
|
||||
dati = event.data?.json() ?? null;
|
||||
} catch {
|
||||
dati = null;
|
||||
}
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const dati = await testoNotifica();
|
||||
await self.registration.showNotification(dati?.title ?? "CrAPP · Turno palloni", {
|
||||
body: dati?.body ?? "Controlla il turno palloni di oggi.",
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
tag: "turno-palloni",
|
||||
});
|
||||
})(),
|
||||
self.registration.showNotification(dati?.title ?? "CrAPP", {
|
||||
body: dati?.body ?? "Apri l'app per i dettagli.",
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
// Il tag fa sostituire la notifica precedente invece di accumularne una pila;
|
||||
// `renotify` fa sì che la sostituzione avvisi di nuovo, altrimenti la seconda
|
||||
// notifica comparirebbe in silenzio e sembrerebbe non essere mai arrivata.
|
||||
tag: "crapp-notifica",
|
||||
renotify: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+1
-36
@@ -107,47 +107,12 @@ export function destinatariPromemoriaPalloni(
|
||||
return [...destinatari];
|
||||
}
|
||||
|
||||
/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */
|
||||
export function messaggioPalloniOggi(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
oggi: string,
|
||||
mioId: string,
|
||||
nome: string,
|
||||
): { title: string; body: string } {
|
||||
for (const evento of eventiDelGiorno(eventi, oggi)) {
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
if (prima && turni[prima.id] === mioId) {
|
||||
return {
|
||||
title: "Porta i palloni oggi",
|
||||
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
|
||||
};
|
||||
}
|
||||
if (turni[evento.id] === mioId) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
return {
|
||||
title: "Tocca a te prendere i palloni",
|
||||
body: dopo
|
||||
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
|
||||
: `A fine ${evento.titolo} porta a casa i palloni.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: "CrAPP · Turno palloni",
|
||||
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
|
||||
*
|
||||
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
|
||||
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
|
||||
* Il testo viaggia in coda su `promemoria_push` perché la push parte vuota e il service
|
||||
* worker chiede a `push-messaggio` cosa mostrare — che da solo saprebbe raccontare solo
|
||||
* la giornata corrente.
|
||||
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
|
||||
*/
|
||||
export function avvisiPalloniEvento(
|
||||
turni: Record<string, string>,
|
||||
|
||||
@@ -21,6 +21,30 @@ export function pushSupportato() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Aggiorna il worker già installato anche nelle sessioni lunghe della webapp. */
|
||||
export function mantieniWorkerPushAggiornato(): () => void {
|
||||
if (!pushSupportato()) return () => {};
|
||||
|
||||
let inCorso = false;
|
||||
const aggiorna = async () => {
|
||||
if (document.visibilityState !== "visible" || inCorso) return;
|
||||
inCorso = true;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
|
||||
// Non registrare né iscrivere chi non ha mai attivato le notifiche.
|
||||
await reg?.update();
|
||||
} catch {
|
||||
// Offline: il worker attivo resta valido. Si riprova al prossimo ritorno nell'app.
|
||||
} finally {
|
||||
inCorso = false;
|
||||
}
|
||||
};
|
||||
|
||||
void aggiorna();
|
||||
document.addEventListener("visibilitychange", aggiorna);
|
||||
return () => document.removeEventListener("visibilitychange", aggiorna);
|
||||
}
|
||||
|
||||
export async function statoNotifiche(): Promise<boolean> {
|
||||
if (!pushSupportato()) return false;
|
||||
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
|
||||
@@ -56,6 +80,32 @@ export async function attivaNotifiche(giocatoreId: string): Promise<void> {
|
||||
if (!res.ok) throw new Error("Salvataggio iscrizione non riuscito");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manda una push di prova a questo dispositivo e racconta com'è andata.
|
||||
*
|
||||
* Esiste perché "non arriva" è un sintomo cieco: senza, ogni prova richiede un admin, un
|
||||
* evento nello stato giusto e una seconda persona. L'invio è immediato: per verificare
|
||||
* l'arrivo ad app chiusa serve invece un invio da un altro dispositivo.
|
||||
*/
|
||||
export async function notificaDiProva(): Promise<{
|
||||
nelDatabase: boolean;
|
||||
stato: number;
|
||||
corpo: string;
|
||||
}> {
|
||||
if (!pushSupportato()) throw new Error("Notifiche non supportate su questo dispositivo");
|
||||
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (!sub) throw new Error("Notifiche non attive su questo dispositivo");
|
||||
|
||||
const res = await fetch("/api/public/push-prova", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Prova non riuscita");
|
||||
return (await res.json()) as { nelDatabase: boolean; stato: number; corpo: string };
|
||||
}
|
||||
|
||||
export async function disattivaNotifiche(): Promise<void> {
|
||||
if (!pushSupportato()) return;
|
||||
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
|
||||
|
||||
+112
-21
@@ -1,4 +1,4 @@
|
||||
/** Invio di notifiche web push (senza payload) firmate con VAPID, compatibile con il runtime edge. */
|
||||
/** Invio di notifiche web push con payload cifrato (RFC 8291), firmate con VAPID, compatibile con il runtime edge. */
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||
@@ -17,6 +17,16 @@ function base64UrlEncode(bytes: Uint8Array): string {
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function concat(...parti: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0));
|
||||
let offset = 0;
|
||||
for (const parte of parti) {
|
||||
out.set(parte, offset);
|
||||
offset += parte.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
|
||||
}
|
||||
@@ -60,40 +70,121 @@ async function creaVapidJwt(
|
||||
return `${header}.${payload}.${base64UrlEncode(new Uint8Array(firma))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per quante ore un messaggio in coda su `promemoria_push` resta un promemoria.
|
||||
*
|
||||
* La coda si svuota solo quando il dispositivo legge il messaggio, e se la push non arriva
|
||||
* mai (registrazione scaduta: i server push accettano con 2xx e poi buttano via) la riga
|
||||
* resta lì. Senza una scadenza, la notifica successiva — di qualunque tipo — mostrerebbe
|
||||
* un sollecito di giorni prima, per un evento ormai passato.
|
||||
*/
|
||||
export const ORE_VALIDITA_PROMEMORIA = 12;
|
||||
/** Un dispositivo iscritto: endpoint del servizio push e chiavi con cui cifrare per lui. */
|
||||
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
|
||||
|
||||
/** Un promemoria accodato è ancora attuale? */
|
||||
export function promemoriaAncoraValido(creatoIl: string, adesso: Date = new Date()): boolean {
|
||||
const creato = Date.parse(creatoIl);
|
||||
if (Number.isNaN(creato)) return false;
|
||||
return adesso.getTime() - creato < ORE_VALIDITA_PROMEMORIA * 60 * 60 * 1000;
|
||||
async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) {
|
||||
const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource },
|
||||
chiave,
|
||||
byte * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
/** Invia una notifica "vuota": il service worker recupera poi il testo aggiornato. */
|
||||
export async function inviaPush(endpoint: string): Promise<number> {
|
||||
function etichetta(testo: string): Uint8Array {
|
||||
return concat(new TextEncoder().encode(testo), new Uint8Array([0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cifra il testo della notifica nel corpo della push, in formato `aes128gcm`.
|
||||
*
|
||||
* Esportata solo per i test: il payload viaggia dentro la push proprio perché il service
|
||||
* worker non abbia bisogno della rete per sapere cosa mostrare — a dispositivo dormiente
|
||||
* il browser lo sveglia per pochi secondi e una fetch di troppo lo fa morire prima di
|
||||
* `showNotification`, cioè niente notifica ad app chiusa.
|
||||
*/
|
||||
export async function cifraPayload(
|
||||
iscrizione: IscrizionePush,
|
||||
testoInChiaro: string,
|
||||
): Promise<Uint8Array> {
|
||||
const uaPublic = base64UrlDecode(iscrizione.p256dh);
|
||||
const authSecret = base64UrlDecode(iscrizione.auth);
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
|
||||
const effimera = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
||||
"deriveBits",
|
||||
]);
|
||||
const asPublic = new Uint8Array(await crypto.subtle.exportKey("raw", effimera.publicKey));
|
||||
const uaKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
uaPublic as BufferSource,
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const condiviso = new Uint8Array(
|
||||
await crypto.subtle.deriveBits({ name: "ECDH", public: uaKey }, effimera.privateKey, 256),
|
||||
);
|
||||
|
||||
const ikm = await hkdf(
|
||||
authSecret,
|
||||
condiviso,
|
||||
concat(etichetta("WebPush: info"), uaPublic, asPublic),
|
||||
32,
|
||||
);
|
||||
const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16);
|
||||
const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12);
|
||||
|
||||
// Il delimitatore 0x02 chiude l'ultimo (e unico) record: il testo sta sotto i 4 KB.
|
||||
const chiaro = concat(new TextEncoder().encode(testoInChiaro), new Uint8Array([2]));
|
||||
const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [
|
||||
"encrypt",
|
||||
]);
|
||||
const cifrato = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce as BufferSource },
|
||||
aes,
|
||||
chiaro as BufferSource,
|
||||
),
|
||||
);
|
||||
|
||||
// Intestazione RFC 8188: salt(16) | record size(4) | lunghezza chiave(1) | chiave(65).
|
||||
const dimensioneRecord = new Uint8Array(4);
|
||||
new DataView(dimensioneRecord.buffer).setUint32(0, 4096);
|
||||
return concat(salt, dimensioneRecord, new Uint8Array([asPublic.length]), asPublic, cifrato);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia la notifica: titolo e testo viaggiano cifrati dentro la push.
|
||||
*
|
||||
* Torna anche il corpo della risposta, non solo lo stato: un servizio push che rifiuta
|
||||
* spiega il perché lì dentro, e buttarlo via lasciava i guasti indistinguibili fra loro.
|
||||
*/
|
||||
export async function inviaPush(
|
||||
iscrizione: IscrizionePush,
|
||||
titolo: string,
|
||||
testo: string,
|
||||
): Promise<{ stato: number; corpo: string }> {
|
||||
const publicKey = process.env["VAPID_PUBLIC_KEY"];
|
||||
const privateKey = process.env["VAPID_PRIVATE_KEY"];
|
||||
const subject = process.env["VAPID_SUBJECT"] ?? "mailto:crapp@crapvolley.it";
|
||||
if (!publicKey || !privateKey) throw new Error("Chiavi VAPID non configurate");
|
||||
|
||||
const audience = new URL(endpoint).origin;
|
||||
const audience = new URL(iscrizione.endpoint).origin;
|
||||
const jwt = await creaVapidJwt(audience, subject, publicKey, privateKey);
|
||||
const corpo = await cifraPayload(iscrizione, JSON.stringify({ title: titolo, body: testo }));
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
const res = await fetch(iscrizione.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
TTL: "86400",
|
||||
// Senza `Urgency` il servizio push usa "normal", e un telefono in risparmio
|
||||
// energetico (doze) accumula i messaggi normali fino al risveglio: la notifica
|
||||
// arriva solo quando il dispositivo è già attivo, cioè quando l'app è aperta.
|
||||
// "high" chiede la consegna immediata anche a schermo spento (RFC 8030 §5.3).
|
||||
Urgency: "high",
|
||||
Authorization: `vapid t=${jwt}, k=${publicKey}`,
|
||||
"Content-Length": "0",
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
body: corpo as BodyInit,
|
||||
});
|
||||
return res.status;
|
||||
|
||||
const risposta = (await res.text().catch(() => "")).slice(0, 500);
|
||||
if (!res.ok) console.error("inviaPush", res.status, risposta);
|
||||
return { stato: res.status, corpo: risposta };
|
||||
}
|
||||
|
||||
+17
-17
@@ -24,7 +24,7 @@ import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/
|
||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||
import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio'
|
||||
import { Route as ApiPublicPushProvaRouteImport } from './routes/api/public/push-prova'
|
||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
|
||||
|
||||
@@ -104,9 +104,9 @@ const ApiPublicPushConfigRoute = ApiPublicPushConfigRouteImport.update({
|
||||
path: '/api/public/push-config',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicPushMessaggioRoute = ApiPublicPushMessaggioRouteImport.update({
|
||||
id: '/api/public/push-messaggio',
|
||||
path: '/api/public/push-messaggio',
|
||||
const ApiPublicPushProvaRoute = ApiPublicPushProvaRouteImport.update({
|
||||
id: '/api/public/push-prova',
|
||||
path: '/api/public/push-prova',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicPushSubscribeRoute = ApiPublicPushSubscribeRouteImport.update({
|
||||
@@ -137,7 +137,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
|
||||
'/api/public/push-prova': typeof ApiPublicPushProvaRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export interface FileRoutesByTo {
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
|
||||
'/api/public/push-prova': typeof ApiPublicPushProvaRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export interface FileRoutesById {
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
|
||||
'/api/public/push-prova': typeof ApiPublicPushProvaRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export interface FileRouteTypes {
|
||||
| '/api/public/csi'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-messaggio'
|
||||
| '/api/public/push-prova'
|
||||
| '/api/public/push-subscribe'
|
||||
| '/api/public/sollecita-presenze'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -220,7 +220,7 @@ export interface FileRouteTypes {
|
||||
| '/api/public/csi'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-messaggio'
|
||||
| '/api/public/push-prova'
|
||||
| '/api/public/push-subscribe'
|
||||
| '/api/public/sollecita-presenze'
|
||||
id:
|
||||
@@ -240,7 +240,7 @@ export interface FileRouteTypes {
|
||||
| '/api/public/csi'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-messaggio'
|
||||
| '/api/public/push-prova'
|
||||
| '/api/public/push-subscribe'
|
||||
| '/api/public/sollecita-presenze'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -261,7 +261,7 @@ export interface RootRouteChildren {
|
||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||
ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute
|
||||
ApiPublicPushProvaRoute: typeof ApiPublicPushProvaRoute
|
||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
@@ -373,11 +373,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiPublicPushConfigRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/push-messaggio': {
|
||||
id: '/api/public/push-messaggio'
|
||||
path: '/api/public/push-messaggio'
|
||||
fullPath: '/api/public/push-messaggio'
|
||||
preLoaderRoute: typeof ApiPublicPushMessaggioRouteImport
|
||||
'/api/public/push-prova': {
|
||||
id: '/api/public/push-prova'
|
||||
path: '/api/public/push-prova'
|
||||
fullPath: '/api/public/push-prova'
|
||||
preLoaderRoute: typeof ApiPublicPushProvaRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/push-subscribe': {
|
||||
@@ -413,7 +413,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||
ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute,
|
||||
ApiPublicPushProvaRoute: ApiPublicPushProvaRoute,
|
||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Toaster } from "../components/ui/sonner";
|
||||
import { TeamLogo } from "../components/crapp/ui-bits";
|
||||
import { useGiocatoreBase } from "../lib/user-store";
|
||||
import { useSessione } from "../lib/auth";
|
||||
import { mantieniWorkerPushAggiornato } from "../lib/push-client";
|
||||
|
||||
function NotFoundComponent() {
|
||||
return (
|
||||
@@ -175,6 +176,8 @@ function AppShell() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const isBenvenuto = location.pathname === "/benvenuto";
|
||||
|
||||
useEffect(mantieniWorkerPushAggiornato, []);
|
||||
|
||||
// Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome
|
||||
// (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori
|
||||
// chi ha già la sessione in localStorage.
|
||||
|
||||
@@ -24,7 +24,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint");
|
||||
.select("endpoint, p256dh, auth");
|
||||
|
||||
const titolo = "💩 Sondaggio pre-partita aperto";
|
||||
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
|
||||
@@ -32,10 +32,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
try {
|
||||
await supabaseAdmin
|
||||
.from("promemoria_push")
|
||||
.insert({ endpoint: iscrizione.endpoint, titolo, testo });
|
||||
const stato = await inviaPush(iscrizione.endpoint);
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
|
||||
@@ -11,8 +11,7 @@ const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
||||
|
||||
/**
|
||||
* Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina
|
||||
* dell'evento (DD-025). Il testo va in coda su `promemoria_push` perché la push parte
|
||||
* vuota e il service worker chiede a `push-messaggio` cosa mostrare.
|
||||
* dell'evento (DD-025). Il testo viaggia cifrato dentro la push.
|
||||
*/
|
||||
export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
server: {
|
||||
@@ -47,7 +46,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, giocatore_id")
|
||||
.select("endpoint, giocatore_id, p256dh, auth")
|
||||
.in(
|
||||
"giocatore_id",
|
||||
avvisi.map((a) => a.giocatoreId),
|
||||
@@ -58,12 +57,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id);
|
||||
if (!avviso) continue;
|
||||
try {
|
||||
await supabaseAdmin.from("promemoria_push").insert({
|
||||
endpoint: iscrizione.endpoint,
|
||||
titolo: avviso.titolo,
|
||||
testo: avviso.testo,
|
||||
});
|
||||
const stato = await inviaPush(iscrizione.endpoint);
|
||||
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { completaTurni, messaggioPalloniOggi, oggiISO } from "@/lib/palloni-core";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { promemoriaAncoraValido } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({ endpoint: z.string().url().max(1000) });
|
||||
|
||||
export const Route = createFileRoute("/api/public/push-messaggio")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const parsed = schema.safeParse(await request.json());
|
||||
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
|
||||
// Messaggio in coda (es. sollecito presenze): ha la precedenza e viene consumato.
|
||||
const { data: promemoria } = await supabaseAdmin
|
||||
.from("promemoria_push")
|
||||
.select("id, titolo, testo, creato_il")
|
||||
.eq("endpoint", parsed.data.endpoint)
|
||||
.order("creato_il", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (promemoria) {
|
||||
// La coda si svuota comunque, anche quando il messaggio è vecchio: altrimenti
|
||||
// resterebbe lì a dirottare la prossima notifica di qualunque tipo.
|
||||
await supabaseAdmin.from("promemoria_push").delete().eq("endpoint", parsed.data.endpoint);
|
||||
if (promemoriaAncoraValido(promemoria.creato_il)) {
|
||||
return Response.json({ title: promemoria.titolo, body: promemoria.testo });
|
||||
}
|
||||
}
|
||||
|
||||
const { data: iscrizione } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("giocatore_id")
|
||||
.eq("endpoint", parsed.data.endpoint)
|
||||
.maybeSingle();
|
||||
|
||||
if (!iscrizione)
|
||||
return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
|
||||
|
||||
const { data: righe } = await supabaseAdmin
|
||||
.from("turni_palloni")
|
||||
.select("evento_id, giocatore_id");
|
||||
const salvati: Record<string, string> = {};
|
||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
||||
const eventi = await leggiEventi();
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const rosa = squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const turni = completaTurni(salvati, eventi, rosa);
|
||||
|
||||
const oggi = oggiISO();
|
||||
const mioId = iscrizione.giocatore_id;
|
||||
const giocatore = squadra.find((g) => g.id === mioId);
|
||||
const nome = giocatore ? nomeCompleto(giocatore) : "";
|
||||
|
||||
return Response.json(messaggioPalloniOggi(turni, eventi, oggi, mioId, nome));
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({ endpoint: z.string().url().max(1000) });
|
||||
|
||||
/**
|
||||
* Manda una push di prova al dispositivo che la chiede, e racconta com'è andata.
|
||||
*
|
||||
* Serve a rendere osservabile un guasto che finora si vedeva solo come "non arriva":
|
||||
* senza questa route ogni prova richiede un admin, un evento nello stato giusto e una
|
||||
* seconda persona, e la risposta del servizio push viene buttata via. Qui invece si vede
|
||||
* lo stato HTTP, il corpo della risposta e se l'endpoint è davvero quello registrato in
|
||||
* `push_subscriptions` — cioè se il server sta parlando con questo telefono o con una
|
||||
* vecchia iscrizione morta.
|
||||
*
|
||||
* Nessun controllo di accesso, come per `push-config` e `push-subscribe`: manda solo
|
||||
* all'endpoint che il chiamante fornisce, quindi al massimo si sveglia da solo. È un URL
|
||||
* segreto per dispositivo, e chi lo conosce lo ha già.
|
||||
*/
|
||||
export const Route = createFileRoute("/api/public/push-prova")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const parsed = schema.safeParse(await request.json());
|
||||
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: iscrizione } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth")
|
||||
.eq("endpoint", parsed.data.endpoint)
|
||||
.maybeSingle();
|
||||
|
||||
if (!iscrizione) {
|
||||
// Il dispositivo ha una sottoscrizione che il database non conosce: la push non
|
||||
// partirebbe mai da sola, perché i mittenti leggono solo da qui.
|
||||
return Response.json({
|
||||
nelDatabase: false,
|
||||
stato: 0,
|
||||
corpo: "Questo dispositivo non risulta iscritto: riattiva le notifiche.",
|
||||
});
|
||||
}
|
||||
|
||||
const { stato, corpo } = await inviaPush(
|
||||
iscrizione,
|
||||
"🔔 Notifica di prova",
|
||||
`Inviata alle ${new Date().toLocaleTimeString("it-IT")}. Se la leggi ad app chiusa, il canale funziona.`,
|
||||
);
|
||||
return Response.json({ nelDatabase: true, stato, corpo });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, giocatore_id")
|
||||
.select("endpoint, p256dh, auth")
|
||||
.in("giocatore_id", destinatari);
|
||||
|
||||
const titolo = "Manca la tua risposta";
|
||||
@@ -57,10 +57,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
try {
|
||||
await supabaseAdmin
|
||||
.from("promemoria_push")
|
||||
.insert({ endpoint: iscrizione.endpoint, titolo, testo });
|
||||
const stato = await inviaPush(iscrizione.endpoint);
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
|
||||
+47
-1
@@ -1,7 +1,17 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
|
||||
import {
|
||||
Flame,
|
||||
Camera,
|
||||
Trash2,
|
||||
Bell,
|
||||
BellRing,
|
||||
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";
|
||||
@@ -21,6 +31,7 @@ import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
|
||||
import {
|
||||
attivaNotifiche,
|
||||
disattivaNotifiche,
|
||||
notificaDiProva,
|
||||
pushSupportato,
|
||||
statoNotifiche,
|
||||
} from "@/lib/push-client";
|
||||
@@ -72,6 +83,7 @@ function Profilo() {
|
||||
const [bust, setBust] = useState(0);
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [inProva, setInProva] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,6 +115,22 @@ function Profilo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function provaNotifica() {
|
||||
if (inProva) return;
|
||||
setInProva(true);
|
||||
try {
|
||||
const esito = await notificaDiProva();
|
||||
if (!esito.nelDatabase) toast.error(esito.corpo);
|
||||
else if (esito.stato >= 200 && esito.stato < 300)
|
||||
toast.success("Push accettata dal servizio: verifica la notifica sul dispositivo");
|
||||
else toast.error(`Il servizio push ha risposto ${esito.stato}: ${esito.corpo}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Prova non riuscita");
|
||||
} finally {
|
||||
setInProva(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
@@ -271,6 +299,24 @@ function Profilo() {
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{notifiche ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={provaNotifica}
|
||||
disabled={inProva}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Mandami una notifica di prova</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Invia subito una push a questo dispositivo
|
||||
</span>
|
||||
</span>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-secondary text-muted-foreground">
|
||||
<BellRing className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
|
||||
@@ -15,6 +15,12 @@ bun test/unit/badges.test.ts # un singolo file
|
||||
Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test
|
||||
non può inquinare gli altri.
|
||||
|
||||
Per le push, `unit/push-sw.test.ts` esegue il service worker reale in contesti isolati,
|
||||
senza pagina né rete: verifica la notifica al risveglio e le promesse di `waitUntil`.
|
||||
`unit/push-client.test.ts` verifica anche l'aggiornamento del worker all'avvio e al ritorno
|
||||
in primo piano, inclusi errori offline e pulizia del listener. Sono test del codice:
|
||||
la consegna effettiva a schermo bloccato richiede un telefono e il servizio push reale.
|
||||
|
||||
## Struttura
|
||||
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
|
||||
@@ -96,8 +96,14 @@ try {
|
||||
|
||||
await prova("il service worker e la sua configurazione si parlano", async () => {
|
||||
const sw = await (await fetch(url("/push-sw.js"))).text();
|
||||
assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker");
|
||||
// DD-026: ora il worker riceve il testo cifrato, senza recuperarlo dalla rete.
|
||||
assert.match(sw, /event\.data\?\.json\(\)/, "legge il payload della push");
|
||||
assert.match(sw, /showNotification/, "il worker mostra la notifica");
|
||||
assert.doesNotMatch(
|
||||
sw,
|
||||
/\bfetch\s*\(|\/api\/public\/push-messaggio/,
|
||||
"mostrare la push non deve richiedere rete ad app chiusa",
|
||||
);
|
||||
const config = await fetch(url("/api/public/push-config"));
|
||||
assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile");
|
||||
});
|
||||
|
||||
@@ -109,8 +109,15 @@ try {
|
||||
assert.equal(res.status, 400, "anche la cancellazione valida l'input");
|
||||
});
|
||||
|
||||
await prova("push-messaggio rifiuta i payload non validi", async () => {
|
||||
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400);
|
||||
// DD-026: il testo è nel payload; la vecchia route non deve più essere esposta.
|
||||
await prova("push-messaggio è stata rimossa", async () => {
|
||||
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 404);
|
||||
});
|
||||
|
||||
await prova("push-prova rifiuta i payload non validi senza inviare notifiche", async () => {
|
||||
for (const body of [{}, { endpoint: "non-un-url" }, { endpoint: "x".repeat(1001) }]) {
|
||||
assert.equal((await postJson("/api/public/push-prova", body)).status, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// --- le route che mandano notifiche a tutti (DD-024) ------------------------
|
||||
@@ -151,13 +158,15 @@ try {
|
||||
|
||||
// --- endpoint che leggono dal database -------------------------------------
|
||||
if (haSupabase()) {
|
||||
await prova("push-messaggio risponde con il messaggio di default", async () => {
|
||||
const res = await postJson("/api/public/push-messaggio", {
|
||||
await prova("push-prova segnala un dispositivo non iscritto senza inviare", async () => {
|
||||
const res = await postJson("/api/public/push-prova", {
|
||||
endpoint: `https://push.example/test-${Date.now()}`,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const dati = (await json(res)) as { title?: string; body?: string };
|
||||
assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo");
|
||||
const dati = (await json(res)) as { nelDatabase?: boolean; stato?: number; corpo?: string };
|
||||
assert.equal(dati.nelDatabase, false);
|
||||
assert.equal(dati.stato, 0, "nessuna chiamata al servizio push");
|
||||
assert.ok(dati.corpo, "spiega come riattivare le notifiche");
|
||||
});
|
||||
|
||||
// La validazione dell'input di sollecita-presenze (400 sul corpo vuoto, 404
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Check dei turni palloni: `bun src/lib/palloni-core.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { formatData, giocatori } from "@/lib/crapp-data";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
completaTurni,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
eventiPalloni,
|
||||
eventoPrecedente,
|
||||
eventoSuccessivo,
|
||||
messaggioPalloniOggi,
|
||||
oggiISO,
|
||||
} from "@/lib/palloni-core";
|
||||
|
||||
@@ -176,37 +175,4 @@ assert.deepEqual(
|
||||
"senza turni assegnati non c'è nessuno da avvisare",
|
||||
);
|
||||
|
||||
// --- messaggioPalloniOggi -----------------------------------------------------
|
||||
assert.deepEqual(
|
||||
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"),
|
||||
{
|
||||
title: "Porta i palloni oggi",
|
||||
body: "Evento p2 · 21:00. I palloni li hai tu dalla volta scorsa.",
|
||||
},
|
||||
"chi li aveva alla volta prima deve riportarli oggi",
|
||||
);
|
||||
assert.deepEqual(
|
||||
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g2", "Luca"),
|
||||
{
|
||||
title: "Tocca a te prendere i palloni",
|
||||
body: `A fine Evento p2 porta a casa i palloni e riportali il ${formatData("2026-02-03")}.`,
|
||||
},
|
||||
"l'incaricato di oggi sa quando riportarli, se c'è un evento successivo",
|
||||
);
|
||||
assert.deepEqual(
|
||||
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-03", "g3", "Anna"),
|
||||
{ title: "Tocca a te prendere i palloni", body: "A fine Evento p3 porta a casa i palloni." },
|
||||
"senza evento successivo il messaggio non promette una data",
|
||||
);
|
||||
assert.deepEqual(
|
||||
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g9", "Sara"),
|
||||
{ title: "CrAPP · Turno palloni", body: "Sara, controlla il turno palloni nel calendario." },
|
||||
"chi non è coinvolto oggi riceve il messaggio generico col proprio nome",
|
||||
);
|
||||
assert.deepEqual(
|
||||
messaggioPalloniOggi(turniPush, eventiPush, "2026-05-01", "g9", ""),
|
||||
{ title: "CrAPP · Turno palloni", body: "Controlla il calendario." },
|
||||
"senza eventi in quella data e senza nome noto, il messaggio resta generico",
|
||||
);
|
||||
|
||||
console.log("palloni-core: ok");
|
||||
|
||||
@@ -7,6 +7,8 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
attivaNotifiche,
|
||||
disattivaNotifiche,
|
||||
mantieniWorkerPushAggiornato,
|
||||
notificaDiProva,
|
||||
pushSupportato,
|
||||
statoNotifiche,
|
||||
} from "@/lib/push-client";
|
||||
@@ -21,7 +23,113 @@ assert.equal(await statoNotifiche(), false);
|
||||
// --- attivaNotifiche: rifiuta subito, senza chiedere permessi o rete ----------
|
||||
await assert.rejects(() => attivaNotifiche("g1"), /Notifiche non supportate su questo dispositivo/);
|
||||
|
||||
// --- notificaDiProva: rifiuta subito, senza toccare la rete -------------------
|
||||
await assert.rejects(() => notificaDiProva(), /Notifiche non supportate su questo dispositivo/);
|
||||
|
||||
// --- disattivaNotifiche: no-op silenzioso, nessun errore ----------------------
|
||||
await assert.doesNotReject(() => disattivaNotifiche());
|
||||
|
||||
// Il mantenimento del worker è innocuo anche durante SSR.
|
||||
assert.doesNotThrow(mantieniWorkerPushAggiornato());
|
||||
|
||||
const originali = new Map(
|
||||
["window", "navigator", "document"].map((nome) => [
|
||||
nome,
|
||||
Object.getOwnPropertyDescriptor(globalThis, nome),
|
||||
]),
|
||||
);
|
||||
const documento = Object.assign(new EventTarget(), { visibilityState: "visible" });
|
||||
let letture = 0;
|
||||
let aggiornamenti = 0;
|
||||
let presente = true;
|
||||
let errore = false;
|
||||
let sblocca: (() => void) | undefined;
|
||||
let attesa: Promise<void> | undefined;
|
||||
let interrompi = () => {};
|
||||
const ciclo = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const visibilita = async (stato: string) => {
|
||||
documento.visibilityState = stato;
|
||||
documento.dispatchEvent(new Event("visibilitychange"));
|
||||
await ciclo();
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { PushManager: {}, Notification: {} },
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: documento });
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
serviceWorker: {
|
||||
async getRegistration(url: string) {
|
||||
assert.equal(url, "/push-sw.js");
|
||||
letture++;
|
||||
return presente
|
||||
? {
|
||||
async update() {
|
||||
aggiornamenti++;
|
||||
if (errore) throw new Error("offline");
|
||||
await attesa;
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
// Un aggiornamento non deve cancellare o sostituire la registrazione.
|
||||
register() {
|
||||
assert.fail("non deve registrare un nuovo worker");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interrompi = mantieniWorkerPushAggiornato();
|
||||
await ciclo();
|
||||
assert.equal(aggiornamenti, 1, "aggiorna il worker esistente all'avvio");
|
||||
|
||||
await visibilita("hidden");
|
||||
assert.equal(letture, 1, "nessun lavoro quando l'app viene chiusa");
|
||||
await visibilita("visible");
|
||||
assert.equal(aggiornamenti, 2, "controlla gli aggiornamenti al ritorno nella webapp");
|
||||
|
||||
errore = true;
|
||||
await visibilita("visible");
|
||||
assert.equal(aggiornamenti, 3, "un errore offline non blocca l'app");
|
||||
errore = false;
|
||||
await visibilita("visible");
|
||||
assert.equal(aggiornamenti, 4, "riprova dopo l'errore");
|
||||
|
||||
attesa = new Promise<void>((resolve) => (sblocca = resolve));
|
||||
await visibilita("visible");
|
||||
await visibilita("visible");
|
||||
assert.equal(aggiornamenti, 5, "accorpa gli aggiornamenti sovrapposti");
|
||||
sblocca!();
|
||||
await ciclo();
|
||||
attesa = undefined;
|
||||
|
||||
presente = false;
|
||||
await visibilita("visible");
|
||||
assert.equal(aggiornamenti, 5, "nessuna nuova iscrizione senza registrazione");
|
||||
|
||||
interrompi();
|
||||
const prima = letture;
|
||||
await visibilita("visible");
|
||||
assert.equal(letture, prima, "rimuove il listener allo smontaggio");
|
||||
|
||||
documento.visibilityState = "hidden";
|
||||
presente = true;
|
||||
interrompi = mantieniWorkerPushAggiornato();
|
||||
await ciclo();
|
||||
assert.equal(letture, prima, "non aggiorna se parte già in background");
|
||||
await visibilita("visible");
|
||||
assert.equal(aggiornamenti, 6);
|
||||
} finally {
|
||||
interrompi();
|
||||
for (const [nome, originale] of originali) {
|
||||
if (originale) Object.defineProperty(globalThis, nome, originale);
|
||||
else Reflect.deleteProperty(globalThis, nome);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("push-client: ok");
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Esegue il worker reale senza pagina, DOM, rete o stato lasciato dall'app. */
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const codice = readFileSync(new URL("../../public/push-sw.js", import.meta.url), "utf8");
|
||||
type Evento = { data?: { json: () => unknown } | null; waitUntil: (p: Promise<unknown>) => void };
|
||||
|
||||
function avviaWorker() {
|
||||
const listener = new Map<string, (event: Evento) => void>();
|
||||
const notifiche: Array<{ titolo: string; opzioni: NotificationOptions }> = [];
|
||||
let completa: (() => void) | undefined;
|
||||
const mostrata = new Promise<void>((resolve) => (completa = resolve));
|
||||
const installata = Promise.resolve();
|
||||
const attivata = Promise.resolve();
|
||||
runInNewContext(codice, {
|
||||
self: {
|
||||
addEventListener: (nome: string, callback: (event: Evento) => void) =>
|
||||
listener.set(nome, callback),
|
||||
skipWaiting: () => installata,
|
||||
clients: {
|
||||
claim: () => attivata,
|
||||
matchAll: () => assert.fail("la push deve funzionare senza consultare finestre aperte"),
|
||||
},
|
||||
registration: {
|
||||
showNotification(titolo: string, opzioni: NotificationOptions) {
|
||||
notifiche.push({ titolo, opzioni });
|
||||
return mostrata;
|
||||
},
|
||||
},
|
||||
},
|
||||
fetch: () => assert.fail("nessuna rete per mostrare la notifica"),
|
||||
});
|
||||
return { listener, notifiche, mostrata, completa: completa!, installata, attivata };
|
||||
}
|
||||
|
||||
// L'installazione e l'attivazione tengono vivo il worker fino al completamento.
|
||||
const avvio = avviaWorker();
|
||||
for (const [nome, promessa] of [
|
||||
["install", avvio.installata],
|
||||
["activate", avvio.attivata],
|
||||
] as const) {
|
||||
let attesa: Promise<unknown> | undefined;
|
||||
avvio.listener.get(nome)!({ waitUntil: (p) => (attesa = p) });
|
||||
assert.equal(attesa, promessa, `${nome}: la promessa è collegata a waitUntil`);
|
||||
await attesa;
|
||||
}
|
||||
|
||||
const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [
|
||||
{
|
||||
data: { json: () => ({ title: "🏐 Palloni", body: "Sabato tocca a te." }) },
|
||||
titolo: "🏐 Palloni",
|
||||
testo: "Sabato tocca a te.",
|
||||
},
|
||||
{ data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
|
||||
{
|
||||
data: {
|
||||
json: () => {
|
||||
throw new SyntaxError("payload non JSON");
|
||||
},
|
||||
},
|
||||
titolo: "CrAPP",
|
||||
testo: "Apri l'app per i dettagli.",
|
||||
},
|
||||
];
|
||||
for (const { data, titolo, testo } of casi) {
|
||||
// Nuovo contesto a ogni consegna: simula il risveglio dopo la terminazione del worker.
|
||||
const worker = avviaWorker();
|
||||
let attesa: Promise<unknown> | undefined;
|
||||
worker.listener.get("push")!({ data, waitUntil: (p) => (attesa = p) });
|
||||
assert.equal(worker.notifiche.length, 1, "mostra subito una notifica anche senza l'app");
|
||||
assert.equal(attesa, worker.mostrata, "waitUntil copre tutta showNotification");
|
||||
const notifica = worker.notifiche[0]!;
|
||||
assert.equal(notifica.titolo, titolo);
|
||||
assert.equal(notifica.opzioni.body, testo);
|
||||
assert.ok(notifica.opzioni.tag, "il tag permette di sostituire la notifica precedente");
|
||||
assert.equal(notifica.opzioni.renotify, true, "anche una sostituzione avvisa l'utente");
|
||||
worker.completa();
|
||||
await attesa;
|
||||
}
|
||||
|
||||
console.log("push-sw: ok");
|
||||
@@ -2,10 +2,12 @@
|
||||
* Check dell'invio push server-side: `bun test/unit/webpush-server.test.ts`.
|
||||
* `inviaPush` firma un JWT VAPID con Web Crypto e fa una POST all'endpoint del
|
||||
* browser: qui generiamo una vera coppia di chiavi P-256 e sostituiamo `fetch`
|
||||
* per intercettare la richiesta, così il test non tocca mai la rete.
|
||||
* per intercettare la richiesta, così il test non tocca mai la rete. Il corpo
|
||||
* cifrato viene decifrato con la chiave del "dispositivo": se la cifratura fosse
|
||||
* sbagliata il browser scarterebbe la push in silenzio e non arriverebbe niente.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { ORE_VALIDITA_PROMEMORIA, inviaPush, promemoriaAncoraValido } from "@/lib/webpush.server";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
@@ -13,6 +15,33 @@ function base64UrlEncode(bytes: Uint8Array): string {
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function concat(...parti: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0));
|
||||
let offset = 0;
|
||||
for (const parte of parti) {
|
||||
out.set(parte, offset);
|
||||
offset += parte.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function etichetta(testo: string): Uint8Array {
|
||||
return concat(new TextEncoder().encode(testo), new Uint8Array([0]));
|
||||
}
|
||||
|
||||
async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) {
|
||||
const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource },
|
||||
chiave,
|
||||
byte * 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function chiaviVapidDiProva() {
|
||||
const coppia = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
||||
"sign",
|
||||
@@ -23,6 +52,64 @@ async function chiaviVapidDiProva() {
|
||||
return { publicKey: base64UrlEncode(raw), privateKey: jwk.d! };
|
||||
}
|
||||
|
||||
/** Il "dispositivo": chiavi che finirebbero in `push_subscriptions`, più la privata. */
|
||||
async function dispositivoDiProva() {
|
||||
const coppia = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
||||
"deriveBits",
|
||||
]);
|
||||
const pubblica = new Uint8Array(await crypto.subtle.exportKey("raw", coppia.publicKey));
|
||||
const auth = crypto.getRandomValues(new Uint8Array(16));
|
||||
return {
|
||||
iscrizione: {
|
||||
endpoint: "https://push.example/abc123",
|
||||
p256dh: base64UrlEncode(pubblica),
|
||||
auth: base64UrlEncode(auth),
|
||||
},
|
||||
pubblica,
|
||||
auth,
|
||||
privata: coppia.privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fa quello che fa il browser alla ricezione: apre il record aes128gcm (RFC 8188/8291). */
|
||||
async function decifra(corpo: Uint8Array, device: Awaited<ReturnType<typeof dispositivoDiProva>>) {
|
||||
const salt = corpo.slice(0, 16);
|
||||
const lunghezzaChiave = corpo[20]!;
|
||||
const asPublic = corpo.slice(21, 21 + lunghezzaChiave);
|
||||
const cifrato = corpo.slice(21 + lunghezzaChiave);
|
||||
|
||||
const asKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
asPublic as BufferSource,
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const condiviso = new Uint8Array(
|
||||
await crypto.subtle.deriveBits({ name: "ECDH", public: asKey }, device.privata, 256),
|
||||
);
|
||||
const ikm = await hkdf(
|
||||
device.auth,
|
||||
condiviso,
|
||||
concat(etichetta("WebPush: info"), device.pubblica, asPublic),
|
||||
32,
|
||||
);
|
||||
const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16);
|
||||
const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12);
|
||||
const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [
|
||||
"decrypt",
|
||||
]);
|
||||
const chiaro = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce as BufferSource },
|
||||
aes,
|
||||
cifrato as BufferSource,
|
||||
),
|
||||
);
|
||||
assert.equal(chiaro.at(-1), 2, "l'ultimo record finisce col delimitatore 0x02");
|
||||
return new TextDecoder().decode(chiaro.slice(0, -1));
|
||||
}
|
||||
|
||||
const originali = {
|
||||
pub: process.env["VAPID_PUBLIC_KEY"],
|
||||
priv: process.env["VAPID_PRIVATE_KEY"],
|
||||
@@ -40,11 +127,16 @@ function ripristinaEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
const device = await dispositivoDiProva();
|
||||
|
||||
try {
|
||||
// --- senza chiavi configurate: rifiuta subito, senza tentare la firma -------
|
||||
delete process.env["VAPID_PUBLIC_KEY"];
|
||||
delete process.env["VAPID_PRIVATE_KEY"];
|
||||
await assert.rejects(() => inviaPush("https://push.example/ep"), /Chiavi VAPID non configurate/);
|
||||
await assert.rejects(
|
||||
() => inviaPush(device.iscrizione, "t", "b"),
|
||||
/Chiavi VAPID non configurate/,
|
||||
);
|
||||
|
||||
// --- con le chiavi: firma il JWT e chiama fetch con l'header vapid ----------
|
||||
const { publicKey, privateKey } = await chiaviVapidDiProva();
|
||||
@@ -60,15 +152,25 @@ try {
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const stato = await inviaPush("https://push.example/abc123");
|
||||
const { stato, corpo: risposta } = await inviaPush(
|
||||
device.iscrizione,
|
||||
"Porta i palloni",
|
||||
"Stasera tocca a te.",
|
||||
);
|
||||
assert.equal(stato, 201, "restituisce lo status della risposta");
|
||||
assert.equal(typeof risposta, "string", "riporta anche il corpo, per capire i rifiuti");
|
||||
assert.ok(richiesta, "ha chiamato fetch");
|
||||
assert.equal(richiesta!.url, "https://push.example/abc123");
|
||||
assert.equal(richiesta!.init.method, "POST");
|
||||
|
||||
const headers = new Headers(richiesta!.init.headers);
|
||||
assert.equal(headers.get("TTL"), "86400");
|
||||
assert.equal(headers.get("Content-Length"), "0");
|
||||
assert.equal(headers.get("Content-Encoding"), "aes128gcm");
|
||||
assert.equal(
|
||||
headers.get("Urgency"),
|
||||
"high",
|
||||
"con l'urgenza normale un telefono in doze accumula i messaggi fino al risveglio: la notifica arriverebbe solo a dispositivo già attivo",
|
||||
);
|
||||
|
||||
const auth = headers.get("Authorization")!;
|
||||
assert.ok(auth.startsWith("vapid t="), "usa lo schema vapid con il token JWT");
|
||||
@@ -83,6 +185,15 @@ try {
|
||||
assert.equal(claim.aud, "https://push.example", "l'audience è l'origine dell'endpoint");
|
||||
assert.equal(claim.sub, "mailto:test@example.com");
|
||||
assert.ok(claim.exp > Date.now() / 1000, "il token scade nel futuro");
|
||||
|
||||
// --- il payload: il testo viaggia dentro la push, non lo si va a cercare --
|
||||
const corpo = new Uint8Array(richiesta!.init.body as ArrayBuffer);
|
||||
assert.equal(corpo[20], 65, "l'intestazione dichiara la chiave effimera da 65 byte");
|
||||
assert.deepEqual(
|
||||
JSON.parse(await decifra(corpo, device)),
|
||||
{ title: "Porta i palloni", body: "Stasera tocca a te." },
|
||||
"il dispositivo legge titolo e testo senza nessuna chiamata di rete",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = fetchOriginale;
|
||||
}
|
||||
@@ -90,24 +201,4 @@ try {
|
||||
ripristinaEnv();
|
||||
}
|
||||
|
||||
// --- scadenza dei promemoria in coda ------------------------------------------
|
||||
// La coda si svuota solo quando il dispositivo legge: se la push non arriva mai, la
|
||||
// riga resta. Senza scadenza dirotterebbe la notifica successiva, giorni dopo.
|
||||
const ADESSO = new Date("2026-09-06T12:00:00Z");
|
||||
const oreFa = (n: number) => new Date(ADESSO.getTime() - n * 60 * 60 * 1000).toISOString();
|
||||
|
||||
assert.equal(promemoriaAncoraValido(oreFa(1), ADESSO), true, "un'ora fa è attuale");
|
||||
assert.equal(
|
||||
promemoriaAncoraValido(oreFa(ORE_VALIDITA_PROMEMORIA - 0.1), ADESSO),
|
||||
true,
|
||||
"poco prima della scadenza vale ancora",
|
||||
);
|
||||
assert.equal(
|
||||
promemoriaAncoraValido(oreFa(ORE_VALIDITA_PROMEMORIA + 0.1), ADESSO),
|
||||
false,
|
||||
"oltre la scadenza non è più un promemoria",
|
||||
);
|
||||
assert.equal(promemoriaAncoraValido(oreFa(72), ADESSO), false, "un sollecito di tre giorni fa no");
|
||||
assert.equal(promemoriaAncoraValido("non-una-data", ADESSO), false, "una data illeggibile scade");
|
||||
|
||||
console.log("webpush-server: ok");
|
||||
|
||||
Reference in New Issue
Block a user