diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md
index 046c30c..abe2946 100644
--- a/docs/DESIGN_DECISIONS.md
+++ b/docs/DESIGN_DECISIONS.md
@@ -40,6 +40,7 @@ Serve a rispondere a domande del tipo:
| [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-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**
@@ -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
controllo viene **prima** della validazione dell'input, così la risposta non rivela
nemmeno se un evento esiste.
-- `promemoria-palloni` → `richiediSegreto`: intestazione `x-cron-segreto` confrontata con la
- variabile `CRON_SEGRETO`. La chiama un cron, che una sessione non ce l'ha.
+- `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito
+ subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025).
`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.
@@ -857,9 +858,8 @@ browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubb
**Conseguenze**
-- Se `CRON_SEGRETO` non è configurata, `promemoria-palloni` risponde `503` e il promemoria
- non parte. È voluto: una porta che si riapre da sola quando manca una variabile
- d'ambiente non se ne accorge nessuno. Va impostata negli ambienti di deploy e nel cron.
+- Nessuna variabile d'ambiente da configurare: dopo DD-025 tutte e tre le route usano lo
+ stesso controllo sul ruolo.
- 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
spedisce un token scaduto.
@@ -871,3 +871,55 @@ browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubb
**Riesame**
Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada
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.
diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md
index 11fec89..58a1854 100644
--- a/docs/modules/notifiche.md
+++ b/docs/modules/notifiche.md
@@ -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
`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
-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 |
-| -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
-| `apri-sondaggio`, `sollecita-presenze` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, dal 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 |
-
-**`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.
+| Route | Controllo | Chi la chiama |
+| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| `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 |
+| `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno |
---
diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md
index 4a30183..da9a22c 100644
--- a/docs/modules/palloni.md
+++ b/docs/modules/palloni.md
@@ -42,23 +42,26 @@ compaiono.
## Route API pubblica `/api/public/promemoria-palloni`
-Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile,
-secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del
-giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato
-dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il
-testo effettivo viene calcolato al volo dal service worker interrogando
-`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)).
+La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
+palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
+e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
+palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
+per ciascuno.
+
+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
-- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal
- codice versionato — da verificare lato Supabase/hosting. Finché non esiste, il promemoria
- quotidiano non parte da solo.
-- La route è protetta dal segreto previsto dal piano originale (DD-024): chi la chiama deve
- mandare `x-cron-segreto` uguale alla variabile `CRON_SEGRETO`. Se la variabile non è
- configurata nell'ambiente la route risponde `503`.
+- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
+ pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
+ turno oggi" — resta in `palloni-core.ts` perché la usa `push-messaggio` per il testo
+ calcolato al volo, ma nessuno scheduler la interroga.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
diff --git a/src/components/crapp/TurnoPalloni.tsx b/src/components/crapp/TurnoPalloni.tsx
index ab73c7d..5ffaf23 100644
--- a/src/components/crapp/TurnoPalloni.tsx
+++ b/src/components/crapp/TurnoPalloni.tsx
@@ -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 (
);
}
diff --git a/src/lib/auth-route.server.ts b/src/lib/auth-route.server.ts
index 728f9d1..bb9f48a 100644
--- a/src/lib/auth-route.server.ts
+++ b/src/lib/auth-route.server.ts
@@ -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
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;
-}
diff --git a/src/lib/palloni-core.ts b/src/lib/palloni-core.ts
index 45bbc5c..8335337 100644
--- a/src/lib/palloni-core.ts
+++ b/src/lib/palloni-core.ts
@@ -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,
+ 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;
+}
diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts
index e581430..2347d70 100644
--- a/src/routes/api/public/promemoria-palloni.ts
+++ b/src/routes/api/public/promemoria-palloni.ts
@@ -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 = {};
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 });
},
},
},
diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts
index c37529c..a34e5e8 100644
--- a/test/integration/api.test.ts
+++ b/test/integration/api.test.ts
@@ -117,18 +117,18 @@ try {
// Il controllo di accesso viene prima della validazione: senza credenziali la
// risposta non deve nemmeno dire se l'evento esiste.
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(
(await postJson(percorso, { eventoId: "non-esiste" })).status,
401,
`${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 () => {
diff --git a/test/integration/permessi-route.test.ts b/test/integration/permessi-route.test.ts
index 421588b..64253b7 100644
--- a/test/integration/permessi-route.test.ts
+++ b/test/integration/permessi-route.test.ts
@@ -22,13 +22,11 @@ if (!locale) {
riepilogo("permessi route");
} else {
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.
process.env["SUPABASE_URL"] = SUPABASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
- process.env["CRON_SEGRETO"] = SEGRETO;
const PASSWORD = "prova-route-123";
const idUtenti: string[] = [];
@@ -74,6 +72,13 @@ if (!locale) {
const server = await avviaServer();
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 = {}) =>
fetch(`${server.baseUrl}${percorso}`, {
method: "POST",
@@ -83,12 +88,11 @@ if (!locale) {
try {
await prova("senza token la route non risponde nemmeno se l'evento esiste", async () => {
- assert.equal((await chiama("/api/public/sollecita-presenze")).status, 401);
- assert.equal((await chiama("/api/public/apri-sondaggio")).status, 401);
+ for (const percorso of ROUTE) assert.equal((await chiama(percorso)).status, 401, percorso);
});
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}` });
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
// inventato non esiste.
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}` });
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 {
server.stop();
for (const id of idUtenti) {
diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts
index 4b44448..03c8603 100644
--- a/test/unit/palloni-core.test.ts
+++ b/test/unit/palloni-core.test.ts
@@ -5,6 +5,7 @@ import type { Evento } from "@/lib/eventi";
import {
completaTurni,
conteggioTurni,
+ avvisiPalloniEvento,
destinatariPromemoriaPalloni,
eventiDelGiorno,
eventiPalloni,
@@ -139,6 +140,42 @@ assert.deepEqual(
"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 -----------------------------------------------------
assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"),