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:
2026-09-06 12:15:39 +02:00
co-authored by Claude Opus 5
parent 847972b582
commit ee9f3f1b8c
10 changed files with 260 additions and 96 deletions
+57 -5
View File
@@ -40,6 +40,7 @@ Serve a rispondere a domande del tipo:
| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara | | [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara |
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo | | [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate | | [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
**In valutazione** **In valutazione**
@@ -839,8 +840,8 @@ i chiamanti sono diversi (`src/lib/auth-route.server.ts`):
`src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il `src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il
controllo viene **prima** della validazione dell'input, così la risposta non rivela controllo viene **prima** della validazione dell'input, così la risposta non rivela
nemmeno se un evento esiste. nemmeno se un evento esiste.
- `promemoria-palloni``richiediSegreto`: intestazione `x-cron-segreto` confrontata con la - `promemoria-palloni` all'inizio `richiediSegreto`, con un segreto da cron; sostituito
variabile `CRON_SEGRETO`. La chiama un cron, che una sessione non ce l'ha. subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025).
`csi`, `push-config`, `push-subscribe` e `push-messaggio` restano aperte: le chiamano il `csi`, `push-config`, `push-subscribe` e `push-messaggio` restano aperte: le chiamano il
browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubblico. browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubblico.
@@ -857,9 +858,8 @@ browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubb
**Conseguenze** **Conseguenze**
- Se `CRON_SEGRETO` non è configurata, `promemoria-palloni` risponde `503` e il promemoria - Nessuna variabile d'ambiente da configurare: dopo DD-025 tutte e tre le route usano lo
non parte. È voluto: una porta che si riapre da sola quando manca una variabile stesso controllo sul ruolo.
d'ambiente non se ne accorge nessuno. Va impostata negli ambienti di deploy e nel cron.
- I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()` - I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()`
(`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si (`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si
spedisce un token scaduto. spedisce un token scaduto.
@@ -871,3 +871,55 @@ browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubb
**Riesame** **Riesame**
Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada
naturale e questi controlli vanno riletti alla sua luce. naturale e questi controlli vanno riletti alla sua luce.
### DD-025 — Il promemoria palloni lo manda l'admin, per un evento
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
`promemoria-palloni` era disegnata per un cron quotidiano: calcolava chi è di turno **oggi** e
gli mandava una push. DD-024 l'aveva chiusa con un segreto condiviso, coerente con quel
disegno. Ma nel repository non c'è nessun cron, e `docs/modules/palloni.md` lo annotava già
come «da verificare lato hosting»: nei fatti quel promemoria non è mai partito. Una route che
funziona e che nessuno chiama.
Nel frattempo l'app aveva già il precedente giusto: `apri-sondaggio` è manuale fin dall'inizio
(«Nessun cron: l'invio è manuale», CHANGELOG v1.0.6).
**Decisione**
Il promemoria lo fa partire un amministratore dal pulsante «Avvisa chi è di turno», dentro il
riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i destinatari di
**quell'evento** — chi deve prendere i palloni e chi deve riportarli — invece della giornata
corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e
`richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono.
Il testo dell'avviso viaggia in coda su `promemoria_push`: la push parte vuota e il service
worker chiede a `push-messaggio` cosa mostrare, ma quella route sa raccontare solo la giornata
corrente. Senza la coda, un avviso mandato il martedì per il sabato arriverebbe con il testo
generico.
**Alternative scartate**
- Un pulsante «manda il promemoria di oggi» in Dashboard → rispecchia la route com'era, ma
premuto un martedì qualsiasi risponderebbe «inviate: 0» e sembrerebbe rotto. L'admin
ragiona per evento, non per giornata.
- Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in
ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che
non esiste.
- Calcolare il testo al volo come fa `messaggioPalloniOggi` → funziona solo se l'avviso parte
il giorno stesso, cioè proprio il vincolo da cui volevamo uscire.
**Conseguenze**
- Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme
il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo
avanti rispetto alla realtà, dove non partiva mai.
- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`,
perché `push-messaggio` continua a costruire il testo della giornata per le push senza coda.
- Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun
ambiente.
**Riesame**
Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron
torna utile, e con lui il segreto: DD-024 descrive già come farlo.
+8 -11
View File
@@ -59,20 +59,17 @@ L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un J
(ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da (ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
`sollecita-presenze.ts` e `promemoria-palloni.ts`. `sollecita-presenze.ts` e `promemoria-palloni.ts`.
### Chi può farle partire (DD-024) ### Chi può farle partire (DD-024, DD-025)
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
route. `src/lib/auth-route.server.ts` fornisce i due controlli: route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama | | Route | Controllo | Chi la chiama |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, dal pulsante riservato agli admin | | `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 |
| `promemoria-palloni` | `richiediSegreto` — intestazione `x-cron-segreto` uguale alla variabile `CRON_SEGRETO` | un cron, senza sessione | | `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno |
| `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno |
**`CRON_SEGRETO` va configurata negli ambienti**: se manca, `promemoria-palloni` risponde
503 e il promemoria non parte. È voluto — una porta che si riapre da sola quando manca una
configurazione non se ne accorge nessuno.
--- ---
+15 -12
View File
@@ -42,23 +42,26 @@ compaiono.
## Route API pubblica `/api/public/promemoria-palloni` ## Route API pubblica `/api/public/promemoria-palloni`
Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile, La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
testo effettivo viene calcolato al volo dal service worker interrogando per ciascuno.
`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)).
Il testo va messo in coda su `promemoria_push` prima di inviare la push, perché la push parte
"vuota" e il service worker chiede a `/api/public/push-messaggio` cosa mostrare — e quella
route da sola sa raccontare solo la giornata corrente, quindi un avviso mandato con giorni di
anticipo arriverebbe con il testo generico. Stesso meccanismo di `apri-sondaggio` (vedi
[Notifiche](notifiche.md)).
--- ---
## Limiti noti ## Limiti noti
- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal - **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
codice versionato — da verificare lato Supabase/hosting. Finché non esiste, il promemoria pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
quotidiano non parte da solo. turno oggi" — resta in `palloni-core.ts` perché la usa `push-messaggio` per il testo
- La route è protetta dal segreto previsto dal piano originale (DD-024): chi la chiama deve calcolato al volo, ma nessuno scheduler la interroga.
mandare `x-cron-segreto` uguale alla variabile `CRON_SEGRETO`. Se la variabile non è
configurata nell'ambiente la route risponde `503`.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche - Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno. possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto - La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
+44 -1
View File
@@ -1,17 +1,21 @@
import { useState } from "react"; 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 { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { intestazioniAutenticate } from "@/lib/auth";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni"; import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
export function TurnoPalloni({ eventoId }: { eventoId: string }) { export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const [aperto, setAperto] = useState(false); const [aperto, setAperto] = useState(false);
const [avviso, setAvviso] = useState(false);
const { salvati, turni, isPending } = useTurniPalloni(); const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno(); const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo); 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 ( return (
<div className="mt-3 rounded-2xl bg-secondary/60 p-2.5"> <div className="mt-3 rounded-2xl bg-secondary/60 p-2.5">
<button <button
@@ -78,6 +105,22 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
))} ))}
</div> </div>
) : null} ) : 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> </div>
); );
} }
+4 -25
View File
@@ -7,13 +7,10 @@
* è un timestamp in base 36 e compare negli URL che la squadra si scambia quindi non * è un timestamp in base 36 e compare negli URL che la squadra si scambia quindi non
* può fare da credenziale. * può fare da credenziale.
* *
* Due tipi di chiamante, due controlli: * Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
* - dall'app, per un'azione da amministratore -> `richiediAdmin`, che verifica il token * amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
* della sessione Supabase e poi il ruolo in `user_roles`; * sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* - da una macchina (cron), dove nessuna sessione esiste -> `richiediSegreto`. * proseguire, altrimenti la `Response` di rifiuto già pronta.
*
* Entrambe tornano `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. */ /** 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 }); if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
return null; 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;
}
+46
View File
@@ -139,3 +139,49 @@ export function messaggioPalloniOggi(
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.", 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;
}
+33 -11
View File
@@ -1,19 +1,33 @@
import { createFileRoute } from "@tanstack/react-router"; 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 { nomeCompleto } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; 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 { inviaPush } from "@/lib/webpush.server";
import { leggiEventi } from "@/lib/eventi.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")({ export const Route = createFileRoute("/api/public/promemoria-palloni")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }) => { POST: async ({ request }) => {
// Chiamata da un cron, senza sessione: qui vale il segreto condiviso. const negato = await richiediAdmin(request);
const negato = richiediSegreto(request);
if (negato) return negato; 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 { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin const { data: righe } = await supabaseAdmin
@@ -21,26 +35,34 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
.select("evento_id, giocatore_id"); .select("evento_id, giocatore_id");
const salvati: Record<string, string> = {}; const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id; for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const squadra = await leggiGiocatoriSquadra(); const squadra = await leggiGiocatoriSquadra();
const rosa = squadra const rosa = squadra
.filter((g) => g.attivo) .filter((g) => g.attivo)
.map((g) => ({ id: g.id, nome: nomeCompleto(g) })); .map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const turni = completaTurni(salvati, eventi, rosa); const turni = completaTurni(salvati, eventi, rosa);
const oggi = oggiISO(); const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
const destinatari = destinatariPromemoriaPalloni(turni, eventi, oggi); if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
if (destinatari.length === 0) return Response.json({ inviate: 0 });
const { data: iscrizioni } = await supabaseAdmin const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions") .from("push_subscriptions")
.select("endpoint, giocatore_id") .select("endpoint, giocatore_id")
.in("giocatore_id", destinatari); .in(
"giocatore_id",
avvisi.map((a) => a.giocatoreId),
);
let inviate = 0; let inviate = 0;
for (const iscrizione of iscrizioni ?? []) { for (const iscrizione of iscrizioni ?? []) {
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id);
if (!avviso) continue;
try { try {
await supabaseAdmin.from("promemoria_push").insert({
endpoint: iscrizione.endpoint,
titolo: avviso.titolo,
testo: avviso.testo,
});
const stato = await inviaPush(iscrizione.endpoint); const stato = await inviaPush(iscrizione.endpoint);
if (stato === 404 || stato === 410) { if (stato === 404 || stato === 410) {
await supabaseAdmin 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 });
}, },
}, },
}, },
+6 -6
View File
@@ -117,18 +117,18 @@ try {
// Il controllo di accesso viene prima della validazione: senza credenziali la // Il controllo di accesso viene prima della validazione: senza credenziali la
// risposta non deve nemmeno dire se l'evento esiste. // risposta non deve nemmeno dire se l'evento esiste.
await prova("le route che avvisano la squadra chiedono le credenziali", async () => { await prova("le route che avvisano la squadra chiedono le credenziali", async () => {
for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) { const route = [
"/api/public/sollecita-presenze",
"/api/public/apri-sondaggio",
"/api/public/promemoria-palloni",
];
for (const percorso of route) {
assert.equal( assert.equal(
(await postJson(percorso, { eventoId: "non-esiste" })).status, (await postJson(percorso, { eventoId: "non-esiste" })).status,
401, 401,
`${percorso} senza token`, `${percorso} senza token`,
); );
} }
assert.equal(
(await postJson("/api/public/promemoria-palloni", {})).status < 400,
false,
"promemoria-palloni senza segreto non parte",
);
}); });
await prova("un token malformato non passa", async () => { await prova("un token malformato non passa", async () => {
+10 -25
View File
@@ -22,13 +22,11 @@ if (!locale) {
riepilogo("permessi route"); riepilogo("permessi route");
} else { } else {
const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale; const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale;
const SEGRETO = "segreto-di-prova";
// Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso. // Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso.
process.env["SUPABASE_URL"] = SUPABASE; process.env["SUPABASE_URL"] = SUPABASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON; process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO; process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
process.env["CRON_SEGRETO"] = SEGRETO;
const PASSWORD = "prova-route-123"; const PASSWORD = "prova-route-123";
const idUtenti: string[] = []; const idUtenti: string[] = [];
@@ -74,6 +72,13 @@ if (!locale) {
const server = await avviaServer(); const server = await avviaServer();
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`); console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
/** Le tre route che mandano notifiche: stesso controllo, stesso corpo (DD-024, DD-025). */
const ROUTE = [
"/api/public/sollecita-presenze",
"/api/public/apri-sondaggio",
"/api/public/promemoria-palloni",
];
const chiama = (percorso: string, intestazioni: Record<string, string> = {}) => const chiama = (percorso: string, intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}${percorso}`, { fetch(`${server.baseUrl}${percorso}`, {
method: "POST", method: "POST",
@@ -83,12 +88,11 @@ if (!locale) {
try { try {
await prova("senza token la route non risponde nemmeno se l'evento esiste", async () => { await prova("senza token la route non risponde nemmeno se l'evento esiste", async () => {
assert.equal((await chiama("/api/public/sollecita-presenze")).status, 401); for (const percorso of ROUTE) assert.equal((await chiama(percorso)).status, 401, percorso);
assert.equal((await chiama("/api/public/apri-sondaggio")).status, 401);
}); });
await prova("un giocatore autenticato non avvisa la squadra", async () => { await prova("un giocatore autenticato non avvisa la squadra", async () => {
for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) { for (const percorso of ROUTE) {
const res = await chiama(percorso, { authorization: `Bearer ${tokenGiocatore}` }); const res = await chiama(percorso, { authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403, `${percorso}: token valido ma senza ruolo`); assert.equal(res.status, 403, `${percorso}: token valido ma senza ruolo`);
} }
@@ -98,30 +102,11 @@ if (!locale) {
// validazione dell'input. Il 404 dice esattamente questo — è passato, e l'evento // validazione dell'input. Il 404 dice esattamente questo — è passato, e l'evento
// inventato non esiste. // inventato non esiste.
await prova("un amministratore passa e arriva alla validazione", async () => { await prova("un amministratore passa e arriva alla validazione", async () => {
for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) { for (const percorso of ROUTE) {
const res = await chiama(percorso, { authorization: `Bearer ${tokenAdmin}` }); const res = await chiama(percorso, { authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`); assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
} }
}); });
await prova("il promemoria palloni chiede il segreto del cron", async () => {
const senza = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, {
method: "POST",
});
assert.equal(senza.status, 401, "senza segreto non parte");
const sbagliato = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, {
method: "POST",
headers: { "x-cron-segreto": "non-e-questo" },
});
assert.equal(sbagliato.status, 401, "un segreto sbagliato vale come nessun segreto");
const giusto = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, {
method: "POST",
headers: { "x-cron-segreto": SEGRETO },
});
assert.equal(giusto.status, 200, "con il segreto giusto il job parte");
});
} finally { } finally {
server.stop(); server.stop();
for (const id of idUtenti) { for (const id of idUtenti) {
+37
View File
@@ -5,6 +5,7 @@ import type { Evento } from "@/lib/eventi";
import { import {
completaTurni, completaTurni,
conteggioTurni, conteggioTurni,
avvisiPalloniEvento,
destinatariPromemoriaPalloni, destinatariPromemoriaPalloni,
eventiDelGiorno, eventiDelGiorno,
eventiPalloni, eventiPalloni,
@@ -139,6 +140,42 @@ assert.deepEqual(
"stessa persona oggi e alla volta prima: un solo avviso", "stessa persona oggi e alla volta prima: un solo avviso",
); );
// --- avvisiPalloniEvento ------------------------------------------------------
// Il promemoria per un evento scelto dall'admin: due destinatari, con testi diversi.
const avvisi = avvisiPalloniEvento(turniPush, eventiPush, "p2");
assert.deepEqual(
avvisi.map((a) => a.giocatoreId),
["g2", "g1"],
"l'incaricato di questo evento, e chi ha i palloni dalla volta prima",
);
assert.match(avvisi[0]!.titolo, /prendere i palloni/);
assert.match(avvisi[1]!.titolo, /Porta i palloni/);
assert.ok(
avvisi.every((a) => !/oggi/i.test(a.testo)),
"il testo nomina l'evento, non 'oggi': può arrivare giorni prima",
);
assert.deepEqual(
avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId),
["g1"],
"il primo evento non ha un precedente da avvisare",
);
assert.deepEqual(
avvisiPalloniEvento({ p1: "g1", p2: "g1" }, eventiPush, "p2").map((a) => a.giocatoreId),
["g1"],
"stessa persona nei due ruoli: un avviso solo, non due notifiche uguali",
);
assert.deepEqual(
avvisiPalloniEvento(turniPush, eventiPush, "non-esiste"),
[],
"un evento inesistente non produce avvisi",
);
assert.deepEqual(
avvisiPalloniEvento({}, eventiPush, "p2"),
[],
"senza turni assegnati non c'è nessuno da avvisare",
);
// --- messaggioPalloniOggi ----------------------------------------------------- // --- messaggioPalloniOggi -----------------------------------------------------
assert.deepEqual( assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"), messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"),