Il promemoria palloni lo manda un admin dalla pagina evento (DD-025).
La route era disegnata per un cron quotidiano: calcolava chi è di turno oggi e gli mandava una push. Ma nel repository nessun cron esiste, e palloni.md lo annotava già come "da verificare lato hosting": nei fatti quel promemoria non è mai partito. Il segreto condiviso introdotto ieri proteggeva una porta che nessuno apriva, al prezzo di una variabile d'ambiente da configurare ovunque. Ora la fa partire un amministratore dal pulsante "Avvisa chi è di turno", dentro il riquadro palloni dell'evento. La route accetta un eventoId e avvisa i destinatari di quell'evento invece della giornata corrente: chi deve prendere i palloni e chi deve riportarli, con un testo diverso per ciascuno. Stesso precedente di apri-sondaggio, manuale fin dalla v1.0.6. Il testo va in coda su promemoria_push prima dell'invio. Serve: la push parte vuota e il service worker chiede a push-messaggio cosa mostrare, ma quella route sa raccontare solo la giornata corrente, quindi un avviso mandato il martedì per il sabato arriverebbe con il testo generico. avvisiPalloniEvento() è la nuova funzione pura, con i suoi test: due destinatari con testi diversi, un avviso solo quando sono la stessa persona, niente avvisi per un evento inesistente o senza turni. Un'asserzione verifica che il testo non dica mai "oggi", perché può arrivare giorni prima. richiediSegreto e CRON_SEGRETO spariscono: tutte e tre le route di notifica usano ora richiediAdmin, e non resta nessuna variabile da configurare. destinatariPromemoriaPalloni() resta in palloni-core.ts con i suoi test perché push-messaggio continua a usarla per il testo calcolato al volo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
|
||||
import { BellRing, Check, CircleDot, Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const [avviso, setAvviso] = useState(false);
|
||||
const { salvati, turni, isPending } = useTurniPalloni();
|
||||
const assegna = useAssegnaTurno();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
|
||||
@@ -30,6 +34,29 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Avvisa via push chi è di turno per questo evento, e chi deve riportare i palloni. */
|
||||
async function avvisa() {
|
||||
setAvviso(true);
|
||||
try {
|
||||
const res = await fetch("/api/public/promemoria-palloni", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||
body: JSON.stringify({ eventoId }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const dati = (await res.json()) as { inviate: number; destinatari: number };
|
||||
toast.success(
|
||||
dati.inviate > 0
|
||||
? `Promemoria inviato a ${dati.inviate} dispositivi`
|
||||
: "Nessun dispositivo con le notifiche attive tra gli incaricati",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a inviare il promemoria");
|
||||
} finally {
|
||||
setAvviso(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-2.5">
|
||||
<button
|
||||
@@ -78,6 +105,22 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{admin && giocatore ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={avvisa}
|
||||
disabled={avviso}
|
||||
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
|
||||
>
|
||||
{avviso ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<BellRing className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Avvisa chi è di turno
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,10 @@
|
||||
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
|
||||
* può fare da credenziale.
|
||||
*
|
||||
* Due tipi di chiamante, due controlli:
|
||||
* - dall'app, per un'azione da amministratore -> `richiediAdmin`, che verifica il token
|
||||
* della sessione Supabase e poi il ruolo in `user_roles`;
|
||||
* - da una macchina (cron), dove nessuna sessione esiste -> `richiediSegreto`.
|
||||
*
|
||||
* Entrambe tornano `null` quando la richiesta può proseguire, altrimenti la `Response` di
|
||||
* rifiuto già pronta.
|
||||
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
|
||||
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
|
||||
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
|
||||
* proseguire, altrimenti la `Response` di rifiuto già pronta.
|
||||
*/
|
||||
|
||||
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */
|
||||
@@ -49,21 +46,3 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
|
||||
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lascia passare solo chi conosce `CRON_SEGRETO`, per le chiamate senza sessione.
|
||||
*
|
||||
* Se la variabile non è configurata la route resta chiusa: una porta che si apre da sola
|
||||
* quando manca una configurazione è peggio di una porta che non funziona, perché nessuno
|
||||
* se ne accorge.
|
||||
*/
|
||||
export function richiediSegreto(request: Request): Response | null {
|
||||
const atteso = process.env["CRON_SEGRETO"];
|
||||
if (!atteso) {
|
||||
console.error("[auth] CRON_SEGRETO non configurato: la route resta chiusa");
|
||||
return new Response("Route non configurata", { status: 503 });
|
||||
}
|
||||
const fornito = request.headers.get("x-cron-segreto");
|
||||
if (fornito !== atteso) return new Response("Segreto non valido", { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -139,3 +139,49 @@ export function messaggioPalloniOggi(
|
||||
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
|
||||
*
|
||||
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
|
||||
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
|
||||
* Il testo viaggia in coda su `promemoria_push` perché la push parte vuota e il service
|
||||
* worker chiede a `push-messaggio` cosa mostrare — che da solo saprebbe raccontare solo
|
||||
* la giornata corrente.
|
||||
*/
|
||||
export function avvisiPalloniEvento(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
eventoId: string,
|
||||
): Array<{ giocatoreId: string; titolo: string; testo: string }> {
|
||||
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
|
||||
if (!evento) return [];
|
||||
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
|
||||
|
||||
const incaricato = turni[evento.id];
|
||||
if (incaricato) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Tocca a te prendere i palloni",
|
||||
testo: dopo
|
||||
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
|
||||
: `${quando}: a fine evento porti a casa i palloni.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
const precedente = prima ? turni[prima.id] : undefined;
|
||||
if (precedente && precedente !== incaricato) {
|
||||
avvisi.push({
|
||||
giocatoreId: precedente,
|
||||
titolo: "Porta i palloni",
|
||||
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
|
||||
});
|
||||
}
|
||||
|
||||
return avvisi;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { richiediSegreto } from "@/lib/auth-route.server";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { completaTurni, destinatariPromemoriaPalloni, oggiISO } from "@/lib/palloni-core";
|
||||
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
|
||||
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
||||
|
||||
/**
|
||||
* Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina
|
||||
* dell'evento (DD-025). Il testo va in coda su `promemoria_push` perché la push parte
|
||||
* vuota e il service worker chiede a `push-messaggio` cosa mostrare.
|
||||
*/
|
||||
export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
// Chiamata da un cron, senza sessione: qui vale il segreto condiviso.
|
||||
const negato = richiediSegreto(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 eventi = await leggiEventi();
|
||||
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
|
||||
if (!evento) return new Response("Evento non trovato", { status: 404 });
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
|
||||
const { data: righe } = await supabaseAdmin
|
||||
@@ -21,26 +35,34 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
.select("evento_id, giocatore_id");
|
||||
const salvati: Record<string, string> = {};
|
||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
||||
const eventi = await leggiEventi();
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const rosa = squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const turni = completaTurni(salvati, eventi, rosa);
|
||||
|
||||
const oggi = oggiISO();
|
||||
const destinatari = destinatariPromemoriaPalloni(turni, eventi, oggi);
|
||||
|
||||
if (destinatari.length === 0) return Response.json({ inviate: 0 });
|
||||
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
|
||||
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, giocatore_id")
|
||||
.in("giocatore_id", destinatari);
|
||||
.in(
|
||||
"giocatore_id",
|
||||
avvisi.map((a) => a.giocatoreId),
|
||||
);
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id);
|
||||
if (!avviso) continue;
|
||||
try {
|
||||
await supabaseAdmin.from("promemoria_push").insert({
|
||||
endpoint: iscrizione.endpoint,
|
||||
titolo: avviso.titolo,
|
||||
testo: avviso.testo,
|
||||
});
|
||||
const stato = await inviaPush(iscrizione.endpoint);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
@@ -55,7 +77,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ inviate });
|
||||
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user