diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 1d44de4..5ef76d3 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -3,8 +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-subscribe.ts`, `src/routes/api/public/push-prova.ts`, -`public/push-sw.js` +`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js` --- @@ -56,13 +55,6 @@ copre in particolare le sessioni lunghe della webapp (vedi il - **`push-config`** — espone la sola chiave pubblica VAPID. - **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo. -- **`push-prova`** — manda una push al dispositivo che la chiede e riporta stato e corpo - della risposta del servizio push, più se l'endpoint risulta in `push_subscriptions`. Serve - a rendere osservabile un "non arriva": senza, ogni prova richiede un admin, un evento nello - stato giusto e una seconda persona. Il pulsante sta in Profilo → Opzioni, sotto - l'interruttore, e compare solo a notifiche attive. L'invio parte dopo 10 secondi - (`ritardoMs`, max 25): premendo il pulsante l'app è per forza aperta, e senza attesa si - proverebbe solo il caso che già funziona. - **`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)). @@ -92,7 +84,7 @@ variabile d'ambiente. | Route | Controllo | Chi la chiama | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin | -| `csi`, `push-config`, `push-subscribe`, `push-prova` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | +| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | --- diff --git a/src/lib/push-client.ts b/src/lib/push-client.ts index 039ddc8..adccfc0 100644 --- a/src/lib/push-client.ts +++ b/src/lib/push-client.ts @@ -80,36 +80,6 @@ export async function attivaNotifiche(giocatoreId: string): Promise { 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. Il server aspetta `ritardoMs` prima di - * inviare: premendo il pulsante l'app è per forza aperta, e senza attesa si proverebbe - * solo il caso che già funziona. - */ -export async function notificaDiProva(ritardoMs?: number): 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, - ...(ritardoMs === undefined ? {} : { ritardoMs }), - }), - }); - 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 { if (!pushSupportato()) return; const reg = await navigator.serviceWorker.getRegistration("/push-sw.js"); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 83d5887..f127f35 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -24,7 +24,6 @@ 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 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,11 +103,6 @@ const ApiPublicPushConfigRoute = ApiPublicPushConfigRouteImport.update({ path: '/api/public/push-config', getParentRoute: () => rootRouteImport, } as any) -const ApiPublicPushProvaRoute = ApiPublicPushProvaRouteImport.update({ - id: '/api/public/push-prova', - path: '/api/public/push-prova', - getParentRoute: () => rootRouteImport, -} as any) const ApiPublicPushSubscribeRoute = ApiPublicPushSubscribeRouteImport.update({ id: '/api/public/push-subscribe', path: '/api/public/push-subscribe', @@ -137,7 +131,6 @@ export interface FileRoutesByFullPath { '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute - '/api/public/push-prova': typeof ApiPublicPushProvaRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute } @@ -157,7 +150,6 @@ export interface FileRoutesByTo { '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute - '/api/public/push-prova': typeof ApiPublicPushProvaRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute } @@ -178,7 +170,6 @@ export interface FileRoutesById { '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute - '/api/public/push-prova': typeof ApiPublicPushProvaRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute } @@ -200,7 +191,6 @@ export interface FileRouteTypes { | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' - | '/api/public/push-prova' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' fileRoutesByTo: FileRoutesByTo @@ -220,7 +210,6 @@ export interface FileRouteTypes { | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' - | '/api/public/push-prova' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' id: @@ -240,7 +229,6 @@ export interface FileRouteTypes { | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' - | '/api/public/push-prova' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' fileRoutesById: FileRoutesById @@ -261,7 +249,6 @@ export interface RootRouteChildren { ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute - ApiPublicPushProvaRoute: typeof ApiPublicPushProvaRoute ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute } @@ -373,13 +360,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicPushConfigRouteImport parentRoute: typeof rootRouteImport } - '/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': { id: '/api/public/push-subscribe' path: '/api/public/push-subscribe' @@ -413,7 +393,6 @@ const rootRouteChildren: RootRouteChildren = { ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, - ApiPublicPushProvaRoute: ApiPublicPushProvaRoute, ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute, ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute, } diff --git a/src/routes/api/public/push-prova.ts b/src/routes/api/public/push-prova.ts deleted file mode 100644 index 2b4e5cc..0000000 --- a/src/routes/api/public/push-prova.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { z } from "zod"; -import { inviaPush } from "@/lib/webpush.server"; - -/** Quanto aspettare prima di mandare davvero, per dare il tempo di chiudere l'app. */ -const RITARDO_PREDEFINITO_MS = 10_000; - -const schema = z.object({ - endpoint: z.string().url().max(1000), - ritardoMs: z.number().int().min(0).max(25_000).optional(), -}); - -/** - * 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. - * - * L'invio è ritardato di qualche secondo: premendo il pulsante l'app è per forza aperta, - * quindi senza attesa la notifica arriverebbe sempre in primo piano — cioè nell'unico caso - * che non serve provare. - * - * 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.", - }); - } - - // ponytail: la funzione resta aperta per il ritardo, semplice ma limitato dal - // tetto di durata dell'hosting. Se servisse aspettare di più, ci vuole una coda. - await new Promise((r) => setTimeout(r, parsed.data.ritardoMs ?? RITARDO_PREDEFINITO_MS)); - - const { stato, corpo } = await inviaPush( - iscrizione, - "🔔 Notifica di prova", - // Il fuso va detto: il server gira in UTC, quindi senza `timeZone` l'orario - // arriverebbe indietro di un'ora o due e sembrerebbe un orologio sballato. - `Inviata alle ${new Date().toLocaleTimeString("it-IT", { - timeZone: "Europe/Rome", - hour: "2-digit", - minute: "2-digit", - })}. Se la leggi ad app chiusa, il canale funziona.`, - ); - return Response.json({ nelDatabase: true, stato, corpo }); - }, - }, - }, -}); diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 3f582df..8499f41 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,17 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { - Flame, - Camera, - Trash2, - Bell, - BellRing, - LogOut, - ShieldCheck, - Bug, - Lightbulb, -} from "lucide-react"; +import { Flame, Camera, Trash2, Bell, 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"; @@ -31,7 +21,6 @@ import { usePresenzeUltimoMese } from "@/lib/presenze-mese"; import { attivaNotifiche, disattivaNotifiche, - notificaDiProva, pushSupportato, statoNotifiche, } from "@/lib/push-client"; @@ -83,7 +72,6 @@ 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(() => { @@ -115,22 +103,6 @@ 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(); @@ -299,24 +271,6 @@ function Profilo() { - {notifiche ? ( - - ) : null} {admin ? ( { - 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) ------------------------ // Il controllo di accesso viene prima della validazione: senza credenziali la // risposta non deve nemmeno dire se l'evento esiste. @@ -158,17 +152,6 @@ try { // --- endpoint che leggono dal database ------------------------------------- if (haSupabase()) { - 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 { 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 // sull'evento inesistente) ora sta dietro al controllo di accesso: serve un // token di amministratore, che questo file non ha. La copre diff --git a/test/unit/push-client.test.ts b/test/unit/push-client.test.ts index b806c7c..2601cf4 100644 --- a/test/unit/push-client.test.ts +++ b/test/unit/push-client.test.ts @@ -8,7 +8,6 @@ import { attivaNotifiche, disattivaNotifiche, mantieniWorkerPushAggiornato, - notificaDiProva, pushSupportato, statoNotifiche, } from "@/lib/push-client"; @@ -23,9 +22,6 @@ 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());