diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 5ef76d3..9ca2145 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -81,10 +81,14 @@ 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` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | +| Route | Controllo | Chi la chiama | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin | +| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | + +`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno +un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche" +della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo. --- diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 83d19e3..346524a 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -165,9 +165,17 @@ Contiene. ## Dashboard amministratore Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da -Profilo → Opzioni). +Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso +componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se +c'è almeno un giocatore disattivato) e Notifiche. -Per ogni giocatore vengono mostrati. +La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto +alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi +[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni +giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto +di altri. + +Per ogni giocatore, nella tab Profili, vengono mostrati. - Stato del profilo - Certificato medico diff --git a/src/lib/notifiche-attive.server.ts b/src/lib/notifiche-attive.server.ts new file mode 100644 index 0000000..f50ba79 --- /dev/null +++ b/src/lib/notifiche-attive.server.ts @@ -0,0 +1,4 @@ +/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */ +export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] { + return [...new Set(righe.map((r) => r.giocatore_id))]; +} diff --git a/src/routes/api/public/notifiche-attive.ts b/src/routes/api/public/notifiche-attive.ts index c97d683..092044d 100644 --- a/src/routes/api/public/notifiche-attive.ts +++ b/src/routes/api/public/notifiche-attive.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { richiediAdmin } from "@/lib/auth-route.server"; +import { idsConNotificheAttive } from "@/lib/notifiche-attive.server"; export const Route = createFileRoute("/api/public/notifiche-attive")({ server: { @@ -17,8 +18,7 @@ export const Route = createFileRoute("/api/public/notifiche-attive")({ return new Response("Errore lettura", { status: 500 }); } - const giocatoreIds = [...new Set((data ?? []).map((r) => r.giocatore_id))]; - return Response.json({ giocatoreIds }); + return Response.json({ giocatoreIds: idsConNotificheAttive(data ?? []) }); }, }, }, diff --git a/test/integration/notifiche-attive-route.test.ts b/test/integration/notifiche-attive-route.test.ts new file mode 100644 index 0000000..b7615a9 --- /dev/null +++ b/test/integration/notifiche-attive-route.test.ts @@ -0,0 +1,106 @@ +/** + * Chi può leggere l'elenco dei giocatori con notifiche push attive: + * `bun test/integration/notifiche-attive-route.test.ts`. + * + * `/api/public/notifiche-attive` legge `push_subscriptions` con la service role e + * salta la RLS (come le route che mandano notifiche, DD-024): il permesso deve stare + * nella route. Serve un database vero per provare token di un giocatore normale e di + * un amministratore, quindi solo stack locale. + */ +import assert from "node:assert/strict"; +import { statoLocale } from "../helpers/locale"; +import { avviaServer, json } from "../helpers/server"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("notifiche attive: permessi route", "stack locale non attivo (npx supabase start)"); + riepilogo("notifiche-attive-route"); +} else { + const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale; + + // Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso. + process.env["SUPABASE_URL"] = SUPABASE; + process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON; + process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO; + + const PASSWORD = "prova-notifiche-123"; + const idUtenti: string[] = []; + + const authAdmin = { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` }; + + async function creaUtente(email: string): Promise { + const res = await fetch(`${SUPABASE}/auth/v1/admin/users`, { + method: "POST", + headers: { ...authAdmin, "content-type": "application/json" }, + body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }), + }); + const corpo = (await res.json()) as { id?: string }; + if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`); + idUtenti.push(corpo.id); + return corpo.id; + } + + async function accedi(email: string): Promise { + const res = await fetch(`${SUPABASE}/auth/v1/token?grant_type=password`, { + method: "POST", + headers: { apikey: ANON, "content-type": "application/json" }, + body: JSON.stringify({ email, password: PASSWORD }), + }); + const corpo = (await res.json()) as { access_token?: string }; + if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`); + return corpo.access_token; + } + + const emailGiocatore = `test-notifiche-giocatore-${Date.now()}@example.test`; + const emailAdmin = `test-notifiche-admin-${Date.now()}@example.test`; + await creaUtente(emailGiocatore); + const idAdmin = await creaUtente(emailAdmin); + await fetch(`${SUPABASE}/rest/v1/user_roles`, { + method: "POST", + headers: { ...authAdmin, "content-type": "application/json" }, + body: JSON.stringify({ user_id: idAdmin, role: "admin" }), + }); + + const tokenGiocatore = await accedi(emailGiocatore); + const tokenAdmin = await accedi(emailAdmin); + + const server = await avviaServer(); + console.log(`notifiche-attive-route su ${server.baseUrl} (database ${SUPABASE})`); + + const PERCORSO = "/api/public/notifiche-attive"; + const chiama = (intestazioni: Record = {}) => + fetch(`${server.baseUrl}${PERCORSO}`, { headers: intestazioni }); + + try { + await prova("senza token la route non risponde", async () => { + assert.equal((await chiama()).status, 401); + }); + + await prova("un giocatore autenticato non è admin", async () => { + const res = await chiama({ authorization: `Bearer ${tokenGiocatore}` }); + assert.equal(res.status, 403); + }); + + await prova("un amministratore riceve l'elenco degli id", async () => { + const res = await chiama({ authorization: `Bearer ${tokenAdmin}` }); + assert.equal(res.status, 200); + const corpo = (await json(res)) as { giocatoreIds: unknown }; + assert.ok(Array.isArray(corpo.giocatoreIds), "giocatoreIds è un array"); + }); + } finally { + server.stop(); + for (const id of idUtenti) { + await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, { + method: "DELETE", + headers: authAdmin, + }); + await fetch(`${SUPABASE}/auth/v1/admin/users/${id}`, { + method: "DELETE", + headers: authAdmin, + }); + } + riepilogo("notifiche-attive-route"); + } +} diff --git a/test/unit/notifiche-attive.test.ts b/test/unit/notifiche-attive.test.ts new file mode 100644 index 0000000..9e90d66 --- /dev/null +++ b/test/unit/notifiche-attive.test.ts @@ -0,0 +1,24 @@ +/** + * Check della deduplica per la sezione Notifiche in admin: + * `bun test/unit/notifiche-attive.test.ts`. + */ +import assert from "node:assert/strict"; +import { idsConNotificheAttive } from "@/lib/notifiche-attive.server"; + +assert.deepEqual(idsConNotificheAttive([]), [], "nessuna riga -> nessun id"); + +assert.deepEqual(idsConNotificheAttive([{ giocatore_id: "g1" }]), ["g1"], "una riga -> un id"); + +assert.deepEqual( + idsConNotificheAttive([{ giocatore_id: "g1" }, { giocatore_id: "g1" }, { giocatore_id: "g2" }]), + ["g1", "g2"], + "più dispositivi dello stesso giocatore contano una volta sola", +); + +assert.deepEqual( + idsConNotificheAttive([{ giocatore_id: "g2" }, { giocatore_id: "g1" }]), + ["g2", "g1"], + "ordine di prima comparsa, non alfabetico", +); + +console.log("notifiche-attive: ok");