diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 9ca2145..bf5b2cd 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -83,12 +83,22 @@ variabile d'ambiente. | 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 | +| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `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. +La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate: +l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non +l'ha ancora attivata. + +`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId` +lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a +quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto +dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione +che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard +admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore. --- diff --git a/src/lib/notifiche-admin.ts b/src/lib/notifiche-admin.ts index 6fd4954..1e45061 100644 --- a/src/lib/notifiche-admin.ts +++ b/src/lib/notifiche-admin.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { intestazioniAutenticate } from "./auth"; const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const; @@ -20,3 +20,18 @@ export function useNotificheAttive() { staleTime: 5 * 60_000, }); } + +/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */ +export function useInviaNotifica() { + return useMutation({ + mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => { + const res = await fetch("/api/public/notifica-personalizzata", { + method: "POST", + headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, + body: JSON.stringify({ messaggio, giocatoreId }), + }); + if (!res.ok) throw new Error("Impossibile inviare la notifica"); + return (await res.json()) as { inviate: number; destinatari: number }; + }, + }); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b99d831..af1cfbf 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id' import { Route as PartitaIdRouteImport } from './routes/partita.$id' import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio' import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' +import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata' import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' @@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({ path: '/api/public/csi', getParentRoute: () => rootRouteImport, } as any) +const ApiPublicNotificaPersonalizzataRoute = + ApiPublicNotificaPersonalizzataRouteImport.update({ + id: '/api/public/notifica-personalizzata', + path: '/api/public/notifica-personalizzata', + getParentRoute: () => rootRouteImport, + } as any) const ApiPublicNotificheAttiveRoute = ApiPublicNotificheAttiveRouteImport.update({ id: '/api/public/notifiche-attive', @@ -149,6 +156,7 @@ export interface FileRoutesByFullPath { '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute + '/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -171,6 +179,7 @@ export interface FileRoutesByTo { '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute + '/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -194,6 +203,7 @@ export interface FileRoutesById { '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute + '/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -218,6 +228,7 @@ export interface FileRouteTypes { | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' + | '/api/public/notifica-personalizzata' | '/api/public/notifiche-attive' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -240,6 +251,7 @@ export interface FileRouteTypes { | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' + | '/api/public/notifica-personalizzata' | '/api/public/notifiche-attive' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -262,6 +274,7 @@ export interface FileRouteTypes { | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' + | '/api/public/notifica-personalizzata' | '/api/public/notifiche-attive' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -285,6 +298,7 @@ export interface RootRouteChildren { PartitaIdRoute: typeof PartitaIdRoute ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute ApiPublicCsiRoute: typeof ApiPublicCsiRoute + ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute @@ -393,6 +407,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicCsiRouteImport parentRoute: typeof rootRouteImport } + '/api/public/notifica-personalizzata': { + id: '/api/public/notifica-personalizzata' + path: '/api/public/notifica-personalizzata' + fullPath: '/api/public/notifica-personalizzata' + preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport + parentRoute: typeof rootRouteImport + } '/api/public/notifiche-attive': { id: '/api/public/notifiche-attive' path: '/api/public/notifiche-attive' @@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = { PartitaIdRoute: PartitaIdRoute, ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute, ApiPublicCsiRoute: ApiPublicCsiRoute, + ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute, ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index f14ff1f..4c31abd 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { BadgeCheck, Bell, + BellOff, ChevronDown, Download, FileText, @@ -10,6 +11,7 @@ import { Image, Loader2, Lock, + Send, Unlink, UserCheck, UserPlus, @@ -49,8 +51,17 @@ import { import { oggiISO } from "@/lib/palloni-core"; import { scaricaCsv } from "@/lib/scout-export"; import { useIsAdmin } from "@/lib/ruoli"; -import { useNotificheAttive } from "@/lib/notifiche-admin"; +import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin"; import { Reveal } from "@/components/motion/Reveal"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; export const Route = createFileRoute("/admin")({ head: () => ({ @@ -621,6 +632,32 @@ function Dashboard() { const { data: notificheAttive } = useNotificheAttive(); const [schedaAperta, setSchedaAperta] = useState(null); const oggi = oggiISO(); + const inviaNotifica = useInviaNotifica(); + const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{ + id?: string; + nome: string; + } | null>(null); + const [messaggio, setMessaggio] = useState(""); + + async function inviaMessaggio() { + if (!destinatarioMessaggio || !messaggio.trim()) return; + try { + const dati = await inviaNotifica.mutateAsync( + destinatarioMessaggio.id + ? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id } + : { messaggio: messaggio.trim() }, + ); + toast.success( + dati.inviate > 0 + ? `Notifica inviata a ${dati.inviate} dispositivi` + : "Nessun dispositivo con notifiche attive per l'invio", + ); + setDestinatarioMessaggio(null); + setMessaggio(""); + } catch { + toast.error("Non sono riuscito a inviare la notifica"); + } + } if (!admin) { return ( @@ -703,15 +740,42 @@ function Dashboard() { valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`} label="Notifiche attive" /> +
- {attivi - .filter((g) => notificheAttive.has(g.id)) - .map((g) => ( + {attivi.map((g) => { + const attiva = notificheAttive.has(g.id); + return (
- -

{nomeCompleto(g)}

+ {attiva ? ( + + ) : ( + + )} +

+ {nomeCompleto(g)} +

+
- ))} + ); + })}
) : ( @@ -737,6 +801,58 @@ function Dashboard() { { id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche }, ]} /> + + { + if (!aperto) { + setDestinatarioMessaggio(null); + setMessaggio(""); + } + }} + > + + + Invia messaggio + + {destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""} + + +
+