Fa scadere i promemoria in coda e smette di dare la colpa alle iscrizioni.

Provando il promemoria palloni in produzione la notifica risultava inviata ma
non arrivava. La causa non era il codice: l'iscrizione di destinazione era
scaduta, FCM l'ha accettata con 2xx e ha buttato via il messaggio, e un minuto
dopo il dispositivo si è re-iscritto con un endpoint nuovo. Un 2xx dal server
push non significa consegnato, e non manda il 404/410 che farebbe pulire
push_subscriptions: è annotato fra i limiti noti, perché dal server non è
distinguibile.

Il difetto vero l'ha fatto emergere quella caccia. promemoria_push si svuota
solo quando il dispositivo legge il messaggio, quindi se la push non arriva mai
la riga resta per sempre — e push-messaggio serve la coda con priorità sul testo
calcolato. In produzione ce n'erano dieci, la più vecchia del 2 settembre: alla
notifica successiva, di qualunque tipo, quel telefono avrebbe mostrato un
sollecito presenze per un evento già passato.

Ora un promemoria vale 12 ore. La riga si cancella comunque alla prima lettura,
scaduta o no: cancellare solo le fresche lascerebbe le vecchie in coda a
dirottare ogni notifica futura, cioè il bug. Così la coda si smaltisce da sola e
le righe orfane già in produzione non vanno ripulite a mano.

Il messaggio del pulsante distingue infine i due casi che prima confondeva:
nessuna iscrizione fra gli incaricati, oppure iscrizioni presenti e invio non
riuscito. Il primo è informativo, il secondo è un errore — dirlo sbagliato
manda a cercare il problema dalla parte opposta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 12:25:21 +02:00
co-authored by Claude Opus 5
parent ee9f3f1b8c
commit 2b8ba340a6
5 changed files with 64 additions and 9 deletions
+9 -1
View File
@@ -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
+10 -5
View File
@@ -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 {
+17
View File
@@ -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<number> {
const publicKey = process.env["VAPID_PUBLIC_KEY"];
+7 -2
View File
@@ -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
+21 -1
View File
@@ -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");