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:
@@ -83,12 +83,22 @@ variabile d'ambiente.
|
|||||||
|
|
||||||
| Route | Controllo | Chi la chiama |
|
| 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 |
|
| `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
|
`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"
|
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { intestazioniAutenticate } from "./auth";
|
import { intestazioniAutenticate } from "./auth";
|
||||||
|
|
||||||
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||||
@@ -20,3 +20,18 @@ export function useNotificheAttive() {
|
|||||||
staleTime: 5 * 60_000,
|
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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
|||||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
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 ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||||
@@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
|||||||
path: '/api/public/csi',
|
path: '/api/public/csi',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPublicNotificaPersonalizzataRoute =
|
||||||
|
ApiPublicNotificaPersonalizzataRouteImport.update({
|
||||||
|
id: '/api/public/notifica-personalizzata',
|
||||||
|
path: '/api/public/notifica-personalizzata',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiPublicNotificheAttiveRoute =
|
const ApiPublicNotificheAttiveRoute =
|
||||||
ApiPublicNotificheAttiveRouteImport.update({
|
ApiPublicNotificheAttiveRouteImport.update({
|
||||||
id: '/api/public/notifiche-attive',
|
id: '/api/public/notifiche-attive',
|
||||||
@@ -149,6 +156,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
@@ -171,6 +179,7 @@ export interface FileRoutesByTo {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
@@ -194,6 +203,7 @@ export interface FileRoutesById {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
@@ -218,6 +228,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
@@ -240,6 +251,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
@@ -262,6 +274,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
@@ -285,6 +298,7 @@ export interface RootRouteChildren {
|
|||||||
PartitaIdRoute: typeof PartitaIdRoute
|
PartitaIdRoute: typeof PartitaIdRoute
|
||||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||||
|
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||||
@@ -393,6 +407,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/public/notifiche-attive': {
|
||||||
id: '/api/public/notifiche-attive'
|
id: '/api/public/notifiche-attive'
|
||||||
path: '/api/public/notifiche-attive'
|
path: '/api/public/notifiche-attive'
|
||||||
@@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
PartitaIdRoute: PartitaIdRoute,
|
PartitaIdRoute: PartitaIdRoute,
|
||||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||||
|
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||||
|
|||||||
+123
-7
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Bell,
|
Bell,
|
||||||
|
BellOff,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
Send,
|
||||||
Unlink,
|
Unlink,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -49,8 +51,17 @@ import {
|
|||||||
import { oggiISO } from "@/lib/palloni-core";
|
import { oggiISO } from "@/lib/palloni-core";
|
||||||
import { scaricaCsv } from "@/lib/scout-export";
|
import { scaricaCsv } from "@/lib/scout-export";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
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 { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin")({
|
export const Route = createFileRoute("/admin")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@@ -621,6 +632,32 @@ function Dashboard() {
|
|||||||
const { data: notificheAttive } = useNotificheAttive();
|
const { data: notificheAttive } = useNotificheAttive();
|
||||||
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
||||||
const oggi = oggiISO();
|
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) {
|
if (!admin) {
|
||||||
return (
|
return (
|
||||||
@@ -703,15 +740,42 @@ function Dashboard() {
|
|||||||
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||||
label="Notifiche attive"
|
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">
|
<div className="mt-3 space-y-2">
|
||||||
{attivi
|
{attivi.map((g) => {
|
||||||
.filter((g) => notificheAttive.has(g.id))
|
const attiva = notificheAttive.has(g.id);
|
||||||
.map((g) => (
|
return (
|
||||||
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
|
<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" />
|
{attiva ? (
|
||||||
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
|
<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>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -737,6 +801,58 @@ function Dashboard() {
|
|||||||
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
{ 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 () => {
|
await prova("un token malformato non passa", async () => {
|
||||||
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -107,6 +107,31 @@ if (!locale) {
|
|||||||
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
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 {
|
} finally {
|
||||||
server.stop();
|
server.stop();
|
||||||
for (const id of idUtenti) {
|
for (const id of idUtenti) {
|
||||||
|
|||||||
Reference in New Issue
Block a user