diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 58a1854..d5c2cb6 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -88,7 +88,15 @@ 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. + 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. +- **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 diff --git a/src/components/crapp/TurnoPalloni.tsx b/src/components/crapp/TurnoPalloni.tsx index 5ffaf23..42d78a5 100644 --- a/src/components/crapp/TurnoPalloni.tsx +++ b/src/components/crapp/TurnoPalloni.tsx @@ -45,11 +45,16 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) { }); if (!res.ok) throw new Error(); const dati = (await res.json()) as { inviate: number; destinatari: number }; - toast.success( - dati.inviate > 0 - ? `Promemoria inviato a ${dati.inviate} dispositivi` - : "Nessun dispositivo con le notifiche attive tra gli incaricati", - ); + if (dati.inviate > 0) { + toast.success(`Promemoria inviato a ${dati.inviate} dispositivi`); + } else if (dati.destinatari === 0) { + toast.info("Nessun dispositivo con le notifiche attive tra gli incaricati"); + } else { + // I dispositivi ci sono ma l'invio non è riuscito: quasi sempre le chiavi VAPID + // mancanti (in locale non sono configurate). Dirlo, invece di dare la colpa + // all'assenza di iscrizioni. + toast.error(`Invio non riuscito verso ${dati.destinatari} dispositivi`); + } } catch { toast.error("Non sono riuscito a inviare il promemoria"); } finally { diff --git a/src/lib/webpush.server.ts b/src/lib/webpush.server.ts index 63ea259..50fe9b9 100644 --- a/src/lib/webpush.server.ts +++ b/src/lib/webpush.server.ts @@ -60,6 +60,23 @@ 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 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; +} + /** Invia una notifica "vuota": il service worker recupera poi il testo aggiornato. */ export async function inviaPush(endpoint: string): Promise { const publicKey = process.env["VAPID_PUBLIC_KEY"]; diff --git a/src/routes/api/public/push-messaggio.ts b/src/routes/api/public/push-messaggio.ts index 3c57a62..17c39bb 100644 --- a/src/routes/api/public/push-messaggio.ts +++ b/src/routes/api/public/push-messaggio.ts @@ -4,6 +4,7 @@ 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) }); @@ -19,14 +20,18 @@ export const Route = createFileRoute("/api/public/push-messaggio")({ // Messaggio in coda (es. sollecito presenze): ha la precedenza e viene consumato. const { data: promemoria } = await supabaseAdmin .from("promemoria_push") - .select("id, titolo, testo") + .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); - return Response.json({ title: promemoria.titolo, body: promemoria.testo }); + if (promemoriaAncoraValido(promemoria.creato_il)) { + return Response.json({ title: promemoria.titolo, body: promemoria.testo }); + } } const { data: iscrizione } = await supabaseAdmin diff --git a/test/unit/webpush-server.test.ts b/test/unit/webpush-server.test.ts index 9924dea..03585b9 100644 --- a/test/unit/webpush-server.test.ts +++ b/test/unit/webpush-server.test.ts @@ -5,7 +5,7 @@ * per intercettare la richiesta, così il test non tocca mai la rete. */ import assert from "node:assert/strict"; -import { inviaPush } from "@/lib/webpush.server"; +import { ORE_VALIDITA_PROMEMORIA, inviaPush, promemoriaAncoraValido } from "@/lib/webpush.server"; function base64UrlEncode(bytes: Uint8Array): string { let binary = ""; @@ -90,4 +90,24 @@ 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");