Aggiunge la possibilità di inviare notifiche push personalizzate dall'admin

Nella tab Notifiche della dashboard admin è ora possibile mostrare anche i
giocatori senza notifiche attive e inviare un messaggio libero a tutta la
squadra o a un singolo giocatore, tramite un nuovo endpoint
/api/public/notifica-personalizzata che riusa lo stesso pattern di invio
delle altre route push admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 08:05:15 +02:00
co-authored by Claude Sonnet 5
parent 6372ef9c8d
commit 0286961e92
7 changed files with 257 additions and 9 deletions
+11 -1
View File
@@ -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.
---
+16 -1
View File
@@ -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 };
},
});
}
+22
View File
@@ -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,
+123 -7
View File
@@ -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<string | null>(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"
/>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
<div className="mt-3 space-y-2">
{attivi
.filter((g) => notificheAttive.has(g.id))
.map((g) => (
{attivi.map((g) => {
const attiva = notificheAttive.has(g.id);
return (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
{attiva ? (
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
{nomeCompleto(g)}
</p>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
>
<Send className="h-3.5 w-3.5" />
</button>
</div>
))}
);
})}
</div>
</>
) : (
@@ -737,6 +801,58 @@ function Dashboard() {
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]}
/>
<Drawer
open={!!destinatarioMessaggio}
onOpenChange={(aperto) => {
if (!aperto) {
setDestinatarioMessaggio(null);
setMessaggio("");
}
}}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Invia messaggio</DrawerTitle>
<DrawerDescription>
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<textarea
value={messaggio}
maxLength={300}
rows={3}
onChange={(e) => setMessaggio(e.target.value)}
placeholder="Scrivi il messaggio da inviare come notifica push…"
className={cn(classiInput, "h-auto")}
/>
</div>
<DrawerFooter>
<button
type="button"
onClick={inviaMessaggio}
disabled={inviaNotifica.isPending || !messaggio.trim()}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}{" "}
Invia
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}
@@ -0,0 +1,53 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
messaggio: z.string().min(1).max(300),
giocatoreId: z.string().min(1).max(50).optional(),
});
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
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");
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
if (parsed.data.giocatoreId) {
query = query.eq("giocatore_id", parsed.data.giocatoreId);
}
const { data: iscrizioni } = await query;
const titolo = "Messaggio dallo staff";
const testo = parsed.data.messaggio.trim();
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("notifica-personalizzata", error);
}
}
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
},
},
},
});
+7
View File
@@ -230,6 +230,13 @@ try {
}
});
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
assert.equal(
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
401,
);
});
await prova("un token malformato non passa", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"), {
method: "POST",
+25
View File
@@ -107,6 +107,31 @@ if (!locale) {
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
}
});
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
method: "POST",
headers: { "content-type": "application/json", ...intestazioni },
body: JSON.stringify({ messaggio: "prova" }),
});
await prova("senza token non si manda un messaggio personalizzato", async () => {
assert.equal((await chiamaMessaggio()).status, 401);
});
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403);
});
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200);
const corpo = (await res.json()) as { inviate: number; destinatari: number };
assert.equal(corpo.destinatari, 0);
assert.equal(corpo.inviate, 0);
});
} finally {
server.stop();
for (const id of idUtenti) {