diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index 7fce601..7ad7ac3 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -23,8 +23,9 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
-- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
- (09/09/2026)
+- 28 migration in `supabase/migrations/`, fino a `m17_notifiche_utente` (15/09/2026) —
+ quest'ultima testata solo in locale (`npx supabase db reset`), non ancora applicata in
+ produzione (`supabase db push`)
- Sviluppo locale verificato con il nuovo Supabase
---
@@ -42,6 +43,10 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
`npx supabase migration list`)
+- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente`
+ per il centro notifiche in-app, testata sullo stack locale — **non ancora applicata in
+ produzione**, va fatta con `supabase db push` quando si decide di rilasciare la
+ funzionalità (è additiva: non tocca schema o comportamento esistenti, vedi DD-030)
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index c600c5b..8c6f783 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -17,6 +17,7 @@ nessuna di esse da M4 (DD-011).
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
+| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
| `user_roles` | solo admin |
@@ -74,6 +75,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
+| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, accanto al profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin. Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
## Funzioni speciali
diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md
index 1d3d7fd..0577be2 100644
--- a/docs/DESIGN_DECISIONS.md
+++ b/docs/DESIGN_DECISIONS.md
@@ -1155,3 +1155,73 @@ di orfani.
**Riesame**
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
aggiornata a mano, non c'è un meccanismo che lo forzi).
+
+### DD-030 — Centro notifiche in-app: storico separato dalla push, letto solo con RLS
+
+**Data:** 15 settembre 2026
+**Stato:** Accettata
+
+**Contesto**
+Le notifiche esistenti (`docs/modules/notifiche.md`) sono solo push OS/browser: arrivano se
+il dispositivo ha attivato l'interruttore in Profilo, sono effimere (nessuno storico) e non
+hanno uno stato letto/non letto — chi non attiva le push, o cambia dispositivo, semplicemente
+non le vede mai. Serviva un canale visibile sempre, dentro l'app, con un badge accanto al
+profilo che segnali quante cose non lette ci sono.
+
+**Decisione**
+Nuova tabella `notifiche_utente` (migration `m17_notifiche_utente`), popolata da quattro
+sorgenti lato database — mai dal client:
+
+1. messaggio libero dell'admin (`notifica-personalizzata.ts`, in parallelo alla push);
+2. promemoria automatico prima di un evento, 24h e 3h prima, due `cron.schedule`
+ (estensione `pg_cron`, abilitata dal 31/07/2026 e usata per la prima volta qui);
+3. turno palloni (`promemoria-palloni.ts`), in parallelo alla push;
+4. sollecito presenze (`sollecita-presenze.ts`), in parallelo alla push.
+
+Lettura e scrittura (segna come letta, elimina) passano dal client Supabase autenticato con
+RLS (`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()` in
+`src/lib/notifiche-utente.ts`), senza una route API dedicata — stesso pattern di
+`useSalvaEvento()` in `eventi.ts`. Nessuna policy INSERT per `authenticated`: le righe nascono
+solo da funzioni `SECURITY DEFINER` o dalla service role, mai da un client.
+
+Icona a campana con badge (`IconaNotifiche`, `src/components/crapp/ui-bits.tsx`), pannello a
+comparsa costruito a mano (nel progetto non c'è una libreria dropdown). Ogni riga si elimina
+con uno swipe o una ×: non c'è pulizia automatica delle notifiche vecchie, quindi è l'unico
+modo per un giocatore di toglierle di torno per sempre.
+
+**Alternative scartate**
+
+- Notificare anche sulla sola creazione di un evento, non solo sull'avvicinarsi della data →
+ scartata su richiesta esplicita: conta quando l'evento sta per succedere, non il momento in
+ cui è stato messo in calendario. La prima versione aveva un trigger `AFTER INSERT ON
+ eventi_app` per questo, rimosso prima del rilascio.
+- Una route API dedicata per leggere/segnare come lette (come le route push esistenti) →
+ scartata: qui non serve la service role (niente da nascondere al giocatore stesso), quindi
+ la RLS basta ed evita di duplicare la logica di lettura già scritta lato client per gli
+ eventi.
+- Pulizia automatica delle notifiche vecchie con un cron di retention (es. elimina le lette
+ dopo 30 giorni) → scartata per ora a favore dello swipe manuale: più diretto da capire per
+ chi usa l'app, e la tabella resta comunque piccola per una sola squadra. Resta un'estensione
+ naturale se in futuro servisse.
+- Semplice `INSERT` invece di `upsert` per turno palloni e sollecito presenze → scartata: un
+ admin che preme due volte lo stesso pulsante per lo stesso evento avrebbe urtato contro il
+ vincolo `UNIQUE (giocatore_id, evento_id, tipo)`. L'upsert aggiorna la riga esistente
+ (testo e `creato_il` freschi, `letta` di nuovo `false`), così ripremere il pulsante si
+ comporta come un rinvio vero, non un errore silenzioso.
+
+**Conseguenze**
+
+- Il badge sull'icona riflette solo ciò che sta in `notifiche_utente`: un dispositivo senza
+ push attive vede comunque tutto, perché il canale è del tutto indipendente.
+- `eventi_app.ora` è testo libero e non validato dal form (`` si può
+ svuotare): la funzione `genera_promemoria_eventi()` usa `ora_evento_a_time()`, che assorbe
+ l'errore di cast riga per riga invece di far fallire l'intera generazione dei promemoria per
+ tutti gli eventi quando uno solo ha l'ora scritta male.
+- `test/integration/notifiche-utente.test.ts` è la definizione eseguibile del comportamento:
+ promemoria 24h/3h e deduplica, tolleranza a un'ora malformata, RLS su lettura/segna
+ letta/elimina, e le tre route (`notifica-personalizzata`, `promemoria-palloni`,
+ `sollecita-presenze`) che scrivono lo storico in-app.
+
+**Riesame**
+Se la tabella crescesse in modo sensibile (squadre molto più numerose, o giocatori che non
+fanno mai swipe), va aggiunta la pulizia automatica scartata sopra.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 39cceef..0c87f57 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -21,6 +21,9 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
+- [x] Centro notifiche in-app — icona a campana accanto al profilo, con badge delle non
+ lette, indipendente dalla push (messaggi admin, promemoria evento, turno palloni,
+ sollecito presenze)
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md
index bf5b2cd..b9329bb 100644
--- a/docs/modules/notifiche.md
+++ b/docs/modules/notifiche.md
@@ -1,9 +1,11 @@
# Modulo — Notifiche
-**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
-**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
-`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
-`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`
+**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
+centro notifiche in-app indipendente accanto al profilo
+**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
+`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
+`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
+`src/components/crapp/ui-bits.tsx` (`IconaNotifiche`)
---
@@ -112,6 +114,47 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
---
+## Centro notifiche in-app (M17)
+
+Icona a campana accanto al link profilo (`IconaNotifiche` in `src/components/crapp/ui-bits.tsx`,
+dentro `PageHeader`), con badge del numero di non lette. Indipendente dal canale push sopra:
+non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
+`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
+stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
+
+Quattro sorgenti scrivono in `notifiche_utente`, mai il client:
+
+1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
+ in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
+2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
+ minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
+ (o tutta la rosa attiva se l'evento non ne specifica), deduplicata da un vincolo
+ `UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda
+ doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
+ l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
+3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
+ evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
+ da `avvisiPalloniEvento()`, in parallelo alla push.
+4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
+ riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
+
+Turno palloni e sollecito presenze usano un `upsert` su `(giocatore_id, evento_id, tipo)`
+invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
+notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
+invece di duplicarsi o fallire per il vincolo `UNIQUE`.
+
+Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
+(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
+— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
+le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
+separato.
+
+**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
+sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
+(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
+× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
+ritrova per sempre nell'elenco, solo segnata come letta.
+
## Limiti noti
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
diff --git a/src/components/crapp/ui-bits.tsx b/src/components/crapp/ui-bits.tsx
index f7da477..f0b371d 100644
--- a/src/components/crapp/ui-bits.tsx
+++ b/src/components/crapp/ui-bits.tsx
@@ -1,10 +1,27 @@
-import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
+import {
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type ComponentPropsWithoutRef,
+ type ReactNode,
+} from "react";
import { Link } from "@tanstack/react-router";
-import { ChevronDown } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { Bell, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
+import { useMotoRidotto } from "@/lib/motion";
+import { molla, proietta } from "@/lib/molla";
+import {
+ contatoreBadge,
+ useEliminaNotifica,
+ useNotificheMie,
+ useSegnaLette,
+ type NotificaUtente,
+} from "@/lib/notifiche-utente";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
@@ -72,6 +89,139 @@ export function LinkProfilo() {
);
}
+/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
+function tempoRelativo(dataIso: string): string {
+ const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
+ const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
+ if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
+ const ore = Math.round(minuti / 60);
+ if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
+ return rtf.format(Math.round(ore / 24), "day");
+}
+
+/**
+ * Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
+ * c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
+ * giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
+ * lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
+ * c'è "indietro", solo "via".
+ */
+function RigaNotifica({
+ notifica,
+ onElimina,
+}: {
+ notifica: NotificaUtente;
+ onElimina: (id: string) => void;
+}) {
+ const ridotto = useMotoRidotto();
+ return (
+
+ {
+ const arrivo = info.offset.x + proietta(info.velocity.x);
+ if (arrivo < -60) onElimina(notifica.id);
+ }}
+ className="touch-pan-y bg-card p-3 pr-8 text-sm"
+ >
+
{notifica.titolo}
+ {notifica.corpo ?
{notifica.corpo}
: null}
+
{tempoRelativo(notifica.creataIl)}
+
+
+
+ );
+}
+
+/**
+ * Campanella accanto al profilo (M17): badge con le notifiche non lette, pannello con
+ * l'elenco al click. Copre quattro sorgenti generate lato database (mai dal client):
+ * messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
+ * `src/lib/notifiche-utente.ts`.
+ *
+ * Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
+ * chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
+ * toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
+ */
+export function IconaNotifiche() {
+ const { notifiche, nonLette } = useNotificheMie();
+ const segnaLette = useSegnaLette();
+ const eliminaNotifica = useEliminaNotifica();
+ const [aperto, setAperto] = useState(false);
+ const riquadro = useRef(null);
+
+ useEffect(() => {
+ if (!aperto) return;
+ const chiudiSeFuori = (e: MouseEvent) => {
+ if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
+ };
+ const chiudiConEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setAperto(false);
+ };
+ document.addEventListener("mousedown", chiudiSeFuori);
+ document.addEventListener("keydown", chiudiConEsc);
+ return () => {
+ document.removeEventListener("mousedown", chiudiSeFuori);
+ document.removeEventListener("keydown", chiudiConEsc);
+ };
+ }, [aperto]);
+
+ function alClick() {
+ const stavaChiuso = !aperto;
+ setAperto(stavaChiuso);
+ if (stavaChiuso && nonLette > 0) segnaLette.mutate();
+ }
+
+ return (
+
+
+ {aperto ? (
+
+ {notifiche.length === 0 ? (
+
Nessuna notifica
+ ) : (
+
+
+ {notifiche.map((n) => (
+
+ ))}
+
+
+ )}
+
+ ) : null}
+
+ );
+}
+
export function PageHeader({
titolo,
sottotitolo,
@@ -91,7 +241,12 @@ export function PageHeader({
{sottotitolo}
) : null}
- {azione ?? }
+ {azione ?? (
+
+
+
+
+ )}
);
diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts
new file mode 100644
index 0000000..8cbca40
--- /dev/null
+++ b/src/lib/notifiche-utente.ts
@@ -0,0 +1,131 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
+import { useGiocatoreId } from "./user-store";
+
+/**
+ * Centro notifiche in-app (M17): icona a campana accanto al profilo, con badge delle non
+ * lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
+ * messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
+ * turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
+ * parallelo alla push esistente). Qui si legge e si segna come letto, con
+ * `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi
+ * client-nuove-tabelle.ts).
+ */
+export type TipoNotifica =
+ | "admin"
+ | "evento_promemoria_24h"
+ | "evento_promemoria_3h"
+ | "turno_palloni"
+ | "sollecita_presenze";
+
+export type NotificaUtente = {
+ id: string;
+ tipo: TipoNotifica;
+ titolo: string;
+ corpo: string;
+ eventoId: string | null;
+ letta: boolean;
+ creataIl: string;
+};
+
+export type RigaNotifica = {
+ id: string;
+ tipo: TipoNotifica;
+ titolo: string;
+ corpo: string;
+ evento_id: string | null;
+ letta: boolean;
+ creato_il: string;
+};
+
+const COLONNE = "id, tipo, titolo, corpo, evento_id, letta, creato_il";
+
+/** Conversione riga database -> modello applicativo. */
+export function daRiga(r: RigaNotifica): NotificaUtente {
+ return {
+ id: r.id,
+ tipo: r.tipo,
+ titolo: r.titolo,
+ corpo: r.corpo,
+ eventoId: r.evento_id,
+ letta: r.letta,
+ creataIl: r.creato_il,
+ };
+}
+
+function chiaveNotifiche(giocatoreId: string | null) {
+ return ["notifiche-utente", giocatoreId] as const;
+}
+
+/** Ultime notifiche del giocatore selezionato, più recenti per prime. */
+export function useNotificheMie() {
+ const giocatoreId = useGiocatoreId();
+ const query = useQuery({
+ queryKey: chiaveNotifiche(giocatoreId),
+ queryFn: async (): Promise => {
+ const { data, error } = await supabaseNuoveTabelle
+ .from("notifiche_utente")
+ .select(COLONNE)
+ .eq("giocatore_id", giocatoreId)
+ .order("creato_il", { ascending: false })
+ .limit(30);
+ if (error) throw error;
+ return ((data ?? []) as RigaNotifica[]).map(daRiga);
+ },
+ enabled: !!giocatoreId,
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ });
+ const notifiche = query.data ?? [];
+ return { ...query, notifiche, nonLette: notifiche.filter((n) => !n.letta).length };
+}
+
+/** Segna come lette tutte le notifiche non lette del giocatore selezionato. */
+export function useSegnaLette() {
+ const giocatoreId = useGiocatoreId();
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ if (!giocatoreId) return;
+ const { error } = await supabaseNuoveTabelle
+ .from("notifiche_utente")
+ .update({ letta: true })
+ .eq("giocatore_id", giocatoreId)
+ .eq("letta", false);
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ qc.setQueryData(chiaveNotifiche(giocatoreId), (prec) =>
+ (prec ?? []).map((n) => ({ ...n, letta: true })),
+ );
+ },
+ });
+}
+
+/**
+ * Elimina una notifica del giocatore selezionato (swipe o pulsante nel pannello). A
+ * differenza di "segna come letta" qui la riga sparisce anche dal database, non solo
+ * dalla vista: non c'è pulizia automatica delle notifiche vecchie, quindi eliminarla è
+ * l'unico modo per un giocatore di toglierla di mezzo per sempre.
+ */
+export function useEliminaNotifica() {
+ const giocatoreId = useGiocatoreId();
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await supabaseNuoveTabelle.from("notifiche_utente").delete().eq("id", id);
+ if (error) throw error;
+ return id;
+ },
+ onSuccess: (id) => {
+ qc.setQueryData(chiaveNotifiche(giocatoreId), (prec) =>
+ (prec ?? []).filter((n) => n.id !== id),
+ );
+ },
+ });
+}
+
+/** Testo del badge: il conteggio esatto fino a 9, poi "9+" per non farlo esplodere. */
+export function contatoreBadge(nonLette: number): string {
+ return nonLette > 9 ? "9+" : String(nonLette);
+}
diff --git a/src/routes/api/public/notifica-personalizzata.ts b/src/routes/api/public/notifica-personalizzata.ts
index 0a756ea..609ac1c 100644
--- a/src/routes/api/public/notifica-personalizzata.ts
+++ b/src/routes/api/public/notifica-personalizzata.ts
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
+import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { inviaPush } from "@/lib/webpush.server";
@@ -19,6 +20,9 @@ export const Route = createFileRoute("/api/public/notifica-personalizzata")({
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
+ // `types.ts` non include ancora `notifiche_utente` (M17): stesso aggiramento di
+ // `giocatori-squadra.server.ts` finché non viene rigenerato.
+ const client = supabaseAdmin as unknown as SupabaseClient;
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
if (parsed.data.giocatoreId) {
@@ -46,6 +50,29 @@ export const Route = createFileRoute("/api/public/notifica-personalizzata")({
}
}
+ // Storico in-app (M17): indipendente dall'esito della push, e non limitato a chi ha
+ // un dispositivo iscritto. Senza `giocatoreId` va a tutta la rosa attiva.
+ let destinatariNotifica: string[];
+ if (parsed.data.giocatoreId) {
+ destinatariNotifica = [parsed.data.giocatoreId];
+ } else {
+ const { data: rosa } = await client
+ .from("giocatori_squadra")
+ .select("id")
+ .eq("attivo", true);
+ destinatariNotifica = ((rosa ?? []) as Array<{ id: string }>).map((r) => r.id);
+ }
+ if (destinatariNotifica.length > 0) {
+ await client.from("notifiche_utente").insert(
+ destinatariNotifica.map((giocatoreId) => ({
+ giocatore_id: giocatoreId,
+ tipo: "admin",
+ titolo,
+ corpo: testo,
+ })),
+ );
+ }
+
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
},
},
diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts
index 97a318c..52832f3 100644
--- a/src/routes/api/public/promemoria-palloni.ts
+++ b/src/routes/api/public/promemoria-palloni.ts
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
+import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { nomeCompleto } from "@/lib/giocatori-squadra";
@@ -44,6 +45,24 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
+ // Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
+ // notifica a non letta con il testo aggiornato, non fallire per il vincolo UNIQUE.
+ // `types.ts` non include ancora `notifiche_utente`, stesso aggiramento di
+ // `giocatori-squadra.server.ts`.
+ const client = supabaseAdmin as unknown as SupabaseClient;
+ await client.from("notifiche_utente").upsert(
+ avvisi.map((a) => ({
+ giocatore_id: a.giocatoreId,
+ tipo: "turno_palloni",
+ titolo: a.titolo,
+ corpo: a.testo,
+ evento_id: evento.id,
+ letta: false,
+ creato_il: new Date().toISOString(),
+ })),
+ { onConflict: "giocatore_id,evento_id,tipo" },
+ );
+
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id, p256dh, auth")
diff --git a/src/routes/api/public/sollecita-presenze.ts b/src/routes/api/public/sollecita-presenze.ts
index e2864cc..d08d615 100644
--- a/src/routes/api/public/sollecita-presenze.ts
+++ b/src/routes/api/public/sollecita-presenze.ts
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
+import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data";
@@ -48,6 +49,23 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
} una conferma: presente, assente o in ritardo?`;
+ // Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
+ // notifica a non letta, non fallire per il vincolo UNIQUE. `types.ts` non include
+ // ancora `notifiche_utente`, stesso aggiramento di `giocatori-squadra.server.ts`.
+ const client = supabaseAdmin as unknown as SupabaseClient;
+ await client.from("notifiche_utente").upsert(
+ destinatari.map((giocatoreId) => ({
+ giocatore_id: giocatoreId,
+ tipo: "sollecita_presenze",
+ titolo,
+ corpo: testo,
+ evento_id: evento.id,
+ letta: false,
+ creato_il: new Date().toISOString(),
+ })),
+ { onConflict: "giocatore_id,evento_id,tipo" },
+ );
+
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 6b60011..41f55b6 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -2,7 +2,14 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { Flame, ChevronRight } from "lucide-react";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
-import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
+import {
+ Card,
+ IconaNotifiche,
+ LinkProfilo,
+ Section,
+ StatTile,
+ TeamLogo,
+} from "@/components/crapp/ui-bits";
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra";
@@ -83,7 +90,10 @@ function Index() {
CRAP Volley
-
+
+
+
+
diff --git a/supabase/migrations/20260915090000_m17_notifiche_utente.sql b/supabase/migrations/20260915090000_m17_notifiche_utente.sql
new file mode 100644
index 0000000..0733044
--- /dev/null
+++ b/supabase/migrations/20260915090000_m17_notifiche_utente.sql
@@ -0,0 +1,162 @@
+-- M17 — Centro notifiche in-app (badge non lette accanto al profilo)
+--
+-- Fino ad oggi le uniche notifiche erano push OS/browser (`push_subscriptions`, vedi
+-- docs/modules/notifiche.md), senza storico né stato letto/non letto. Questa migration
+-- aggiunge `notifiche_utente`: una riga per notifica, per giocatore, con un flag `letta`.
+-- Quattro sorgenti generano righe, mai il client:
+--
+-- 1. Messaggio libero dell'admin (`notifica-personalizzata.ts`, invariato lato push).
+-- 2. Promemoria automatico prima di un evento, 24h e 3h prima (due `cron.schedule`,
+-- estensione `pg_cron` già abilitata dal 31/07/2026 e mai usata finora). Volutamente
+-- non c'è una notifica sulla sola creazione dell'evento: conta l'avvicinarsi della
+-- data, non il momento in cui è stato messo in calendario.
+-- 3. Turno palloni (`promemoria-palloni.ts`, avviato a mano da un admin dalla pagina
+-- evento), in parallelo alla push.
+-- 4. Sollecito presenze (`sollecita-presenze.ts`, stesso innesco manuale), in parallelo
+-- alla push.
+--
+-- I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`
+-- (src/lib/eventi.ts) ed `evento_permette_voto()` (M13): `convocati` vuoto significa tutta
+-- la rosa attiva, altrimenti solo gli id in `convocati`.
+
+CREATE TABLE public.notifiche_utente (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
+ tipo text NOT NULL CHECK (
+ tipo IN (
+ 'admin',
+ 'evento_promemoria_24h',
+ 'evento_promemoria_3h',
+ 'turno_palloni',
+ 'sollecita_presenze'
+ )
+ ),
+ titolo text NOT NULL,
+ corpo text NOT NULL DEFAULT '',
+ evento_id text REFERENCES public.eventi_app(id) ON DELETE CASCADE,
+ letta boolean NOT NULL DEFAULT false,
+ creato_il timestamptz NOT NULL DEFAULT now(),
+ -- Per tipo='admin' evento_id è NULL: NULL non è mai uguale a NULL, quindi la UNIQUE non
+ -- si applica e ogni messaggio admin resta una riga separata (corretto). Per i tipi legati
+ -- a un evento, un secondo invio (cron che gira di nuovo, admin che preme di nuovo il
+ -- pulsante) aggiorna la stessa riga invece di duplicarla — vedi gli `upsert` sotto.
+ UNIQUE (giocatore_id, evento_id, tipo)
+);
+
+COMMENT ON TABLE public.notifiche_utente IS
+ 'Centro notifiche in-app per giocatore: messaggi admin, promemoria evento, turno palloni, sollecito presenze.';
+
+CREATE INDEX notifiche_utente_giocatore_letta_idx
+ ON public.notifiche_utente (giocatore_id, letta);
+
+GRANT SELECT, UPDATE, DELETE ON public.notifiche_utente TO authenticated;
+GRANT ALL ON public.notifiche_utente TO service_role;
+
+ALTER TABLE public.notifiche_utente ENABLE ROW LEVEL SECURITY;
+
+-- Un account può essere collegato a più slot giocatore (`giocatori_squadra.auth_user_id`):
+-- vede le notifiche di tutti i suoi slot, il client filtra per il giocatore selezionato in
+-- localStorage (stesso pattern di `LinkProfilo`/`useGiocatoreId`).
+CREATE POLICY "Il giocatore legge le proprie notifiche" ON public.notifiche_utente
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.giocatori_squadra g
+ WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
+ )
+ );
+
+-- Solo per segnarle come lette: nessuna policy INSERT per authenticated, le righe nascono
+-- solo da funzioni `SECURITY DEFINER` o dalla service role (route admin).
+CREATE POLICY "Il giocatore segna le proprie notifiche come lette" ON public.notifiche_utente
+ FOR UPDATE TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.giocatori_squadra g
+ WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.giocatori_squadra g
+ WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
+ )
+ );
+
+-- Eliminazione (swipe o pulsante nel pannello): non c'è pulizia automatica delle notifiche
+-- vecchie, quindi è l'unico modo per un giocatore di toglierle di mezzo per sempre.
+CREATE POLICY "Il giocatore elimina le proprie notifiche" ON public.notifiche_utente
+ FOR DELETE TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.giocatori_squadra g
+ WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
+ )
+ );
+
+-- --- Destinatari di un evento: stessa convenzione di convocatiEvento()/evento_permette_voto() ---
+
+CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text)
+RETURNS SETOF text
+LANGUAGE sql STABLE AS $$
+ SELECT g.id
+ FROM public.eventi_app e
+ JOIN public.giocatori_squadra g
+ ON g.attivo = true
+ AND (cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
+ WHERE e.id = p_evento_id;
+$$;
+
+-- --- Promemoria evento imminente (pg_cron) -----------------------------------------------
+--
+-- `data`/`ora` sono salvate come ora locale Italia (stessa convenzione di
+-- `m10_azzera_turni_palloni_allenamenti`), quindi il confronto con `now()` (timestamptz)
+-- passa esplicitamente da `AT TIME ZONE 'Europe/Rome'`.
+--
+-- `eventi_app.ora` è testo libero (`` lato client, ma la form non
+-- impedisce di svuotarlo, vedi `src/routes/eventi.tsx`): un `::time` diretto su un valore
+-- non valido manda in errore l'intera istruzione, e con essa **tutti** i promemoria di
+-- quel giro di cron, non solo quelli dell'evento incriminato. `ora_evento_a_time()` assorbe
+-- l'errore riga per riga: un evento con l'ora scritta male viene solo escluso.
+
+CREATE OR REPLACE FUNCTION public.ora_evento_a_time(p_ora text)
+RETURNS time
+LANGUAGE plpgsql IMMUTABLE AS $$
+BEGIN
+ RETURN p_ora::time;
+EXCEPTION WHEN OTHERS THEN
+ RETURN NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.genera_promemoria_eventi(p_tipo text, p_finestra interval)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
+ SELECT d, p_tipo, 'Promemoria: ' || e.titolo,
+ to_char(e.data, 'DD/MM/YYYY') || ' alle ' || e.ora,
+ e.id
+ FROM public.eventi_app e
+ CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
+ WHERE public.ora_evento_a_time(e.ora) IS NOT NULL
+ AND ((e.data + public.ora_evento_a_time(e.ora)) AT TIME ZONE 'Europe/Rome') - now()
+ BETWEEN interval '0' AND p_finestra
+ ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
+END;
+$$;
+
+SELECT cron.schedule(
+ 'promemoria-eventi-24h',
+ '0 * * * *',
+ $$ SELECT public.genera_promemoria_eventi('evento_promemoria_24h', interval '24 hours') $$
+);
+
+SELECT cron.schedule(
+ 'promemoria-eventi-3h',
+ '*/15 * * * *',
+ $$ SELECT public.genera_promemoria_eventi('evento_promemoria_3h', interval '3 hours') $$
+);
diff --git a/test/integration/notifiche-utente.test.ts b/test/integration/notifiche-utente.test.ts
new file mode 100644
index 0000000..e8cbf3a
--- /dev/null
+++ b/test/integration/notifiche-utente.test.ts
@@ -0,0 +1,483 @@
+/**
+ * Centro notifiche in-app (M17): `bun test/integration/notifiche-utente.test.ts`.
+ *
+ * Copre le quattro sorgenti che scrivono in `notifiche_utente` mai dal client (le due
+ * funzioni `pg_cron` dei promemoria evento, la route `notifica-personalizzata`, la route
+ * `promemoria-palloni`, la route `sollecita-presenze`), più la RLS che decide chi può
+ * leggerle e segnarle come lette. `notifiche-utente.test.ts` (unit) copre la sola
+ * conversione riga->modello e il badge; qui serve un database vero per funzioni, trigger
+ * e policy scritte su `auth.uid()`.
+ *
+ * Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
+ * `test-notifiche-utente` per gli eventi e lo slot `g4` della rosa seed per la RLS,
+ * ripristinati alla fine.
+ */
+import assert from "node:assert/strict";
+import { statoLocale } from "../helpers/locale";
+import { avviaServer } from "../helpers/server";
+import { prova, riepilogo, salta } from "../helpers/prova";
+
+const locale = statoLocale();
+
+if (!locale) {
+ salta("notifiche in-app", "stack locale non attivo (npx supabase start)");
+ riepilogo("notifiche-utente");
+} else {
+ const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
+ console.log(`notifiche in-app su ${URL_BASE}`);
+
+ // Il server di sviluppo (per le sezioni con le route) eredita queste: le route leggono
+ // i nomi senza prefisso e senza questo override punterebbero al progetto cloud di `.env`.
+ process.env["SUPABASE_URL"] = URL_BASE;
+ process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
+ process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
+
+ const PREFISSO = "test-notifiche-utente";
+ const EVENTO_24H = `${PREFISSO}-24h`;
+ const EVENTO_3H = `${PREFISSO}-3h`;
+ const EVENTO_ORA_VALIDA = `${PREFISSO}-ora-valida`;
+ const EVENTO_ORA_ROTTA = `${PREFISSO}-ora-rotta`;
+ const EVENTO_PALLONI = `${PREFISSO}-palloni`;
+ const EVENTO_PRESENZE = `${PREFISSO}-presenze`;
+ const GIOCATORE_RLS = "g4";
+ const MESSAGGIO_UNICO = `${PREFISSO}-messaggio-${Date.now()}`;
+
+ /**
+ * `data`/`ora` sono ora locale Italia (stessa convenzione del resto dell'app, vedi
+ * `m10_azzera_turni_palloni_allenamenti` e la funzione `genera_promemoria_eventi`):
+ * calcolarle da `Date` in UTC senza passare da `Europe/Rome` sfaserebbe il confronto
+ * con `now()` lato database fino a due ore, abbastanza da far scattare il promemoria
+ * sbagliato vicino ai bordi delle finestre di 3 e 24 ore.
+ */
+ function dataOraRoma(quando: Date): { data: string; ora: string } {
+ const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando);
+ const ora = new Intl.DateTimeFormat("it-IT", {
+ timeZone: "Europe/Rome",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(quando);
+ return { data, ora };
+ }
+
+ const rest = (percorso: string, token: string, init?: RequestInit) =>
+ fetch(`${URL_BASE}/rest/v1/${percorso}`, {
+ ...init,
+ headers: {
+ apikey: token === SERVIZIO ? SERVIZIO : ANON,
+ Authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ });
+
+ async function inserisci(tabella: string, riga: Record) {
+ const res = await rest(tabella, SERVIZIO, {
+ method: "POST",
+ headers: { Prefer: "return=minimal" },
+ body: JSON.stringify(riga),
+ });
+ if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
+ }
+
+ async function leggi(percorso: string): Promise {
+ const res = await rest(percorso, SERVIZIO);
+ if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
+ return (await res.json()) as T[];
+ }
+
+ async function contaAttivi(): Promise {
+ const righe = await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&select=id");
+ return righe.length;
+ }
+
+ async function chiamaRpc(nome: string, corpo: Record) {
+ const res = await fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
+ method: "POST",
+ headers: {
+ apikey: SERVIZIO,
+ Authorization: `Bearer ${SERVIZIO}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(corpo),
+ });
+ if (!res.ok) throw new Error(`rpc ${nome}: ${res.status} ${await res.text()}`);
+ }
+
+ async function creaUtente(email: string, password: string): Promise {
+ const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
+ method: "POST",
+ headers: {
+ apikey: SERVIZIO,
+ Authorization: `Bearer ${SERVIZIO}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ email, password, email_confirm: true }),
+ });
+ const corpo = (await res.json()) as { id?: string };
+ if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
+ return corpo.id;
+ }
+
+ async function accedi(email: string, password: string): Promise {
+ const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
+ method: "POST",
+ headers: { apikey: ANON, "content-type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+ const corpo = (await res.json()) as { access_token?: string };
+ if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
+ return corpo.access_token;
+ }
+
+ const eliminaUtente = (id: string) =>
+ fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
+ method: "DELETE",
+ headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
+ });
+
+ const PASSWORD = "prova-notifiche-utente-123";
+ const idUtenti: string[] = [];
+ let tokenAdmin = "";
+ let server: { baseUrl: string; stop: () => void } | null = null;
+
+ try {
+ // --- preparazione: un amministratore, un giocatore collegato a g4 -----------------
+ const emailAdmin = `test-notifiche-utente-admin-${Date.now()}@example.test`;
+ const emailGiocatore = `test-notifiche-utente-giocatore-${Date.now()}@example.test`;
+ const idAdmin = await creaUtente(emailAdmin, PASSWORD);
+ const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
+ idUtenti.push(idAdmin, idGiocatore);
+
+ await rest("user_roles", SERVIZIO, {
+ method: "POST",
+ body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
+ });
+
+ tokenAdmin = await accedi(emailAdmin, PASSWORD);
+ const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
+
+ // Il trigger `enforce_giocatori_squadra_update` rifiuta la service key (auth.uid() NULL):
+ // serve il JWT dell'admin, come in `permessi.test.ts`.
+ const collega = (utente: string | null) =>
+ rest(`giocatori_squadra?id=eq.${GIOCATORE_RLS}`, tokenAdmin, {
+ method: "PATCH",
+ body: JSON.stringify({ auth_user_id: utente }),
+ });
+ await collega(idGiocatore);
+
+ // --- 1. Promemoria: finestre 24h/3h e deduplica -----------------------------------
+ await prova("il promemoria 24h scatta per un evento a 20 ore, non il 3h", async () => {
+ const { data, ora } = dataOraRoma(new Date(Date.now() + 20 * 60 * 60 * 1000));
+ await inserisci("eventi_app", {
+ id: EVENTO_24H,
+ tipo: "allenamento",
+ titolo: "Allenamento imminente",
+ data,
+ ora,
+ convocati: [GIOCATORE_RLS],
+ });
+
+ await chiamaRpc("genera_promemoria_eventi", {
+ p_tipo: "evento_promemoria_24h",
+ p_finestra: "24 hours",
+ });
+ await chiamaRpc("genera_promemoria_eventi", {
+ p_tipo: "evento_promemoria_3h",
+ p_finestra: "3 hours",
+ });
+
+ const promemoria24h = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_24H}&tipo=eq.evento_promemoria_24h&select=id`,
+ );
+ const promemoria3h = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_24H}&tipo=eq.evento_promemoria_3h&select=id`,
+ );
+ assert.equal(promemoria24h.length, 1, "il promemoria 24h è stato generato");
+ assert.equal(
+ promemoria3h.length,
+ 0,
+ "il promemoria 3h non scatta ancora a 20 ore di distanza",
+ );
+ });
+
+ await prova(
+ "il promemoria 3h scatta per un evento a 2 ore, ed entrambi non duplicano",
+ async () => {
+ const { data, ora } = dataOraRoma(new Date(Date.now() + 2 * 60 * 60 * 1000));
+ await inserisci("eventi_app", {
+ id: EVENTO_3H,
+ tipo: "allenamento",
+ titolo: "Allenamento imminentissimo",
+ data,
+ ora,
+ convocati: [GIOCATORE_RLS],
+ });
+
+ for (let i = 0; i < 2; i += 1) {
+ await chiamaRpc("genera_promemoria_eventi", {
+ p_tipo: "evento_promemoria_24h",
+ p_finestra: "24 hours",
+ });
+ await chiamaRpc("genera_promemoria_eventi", {
+ p_tipo: "evento_promemoria_3h",
+ p_finestra: "3 hours",
+ });
+ }
+
+ const promemoria24h = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_3H}&tipo=eq.evento_promemoria_24h&select=id`,
+ );
+ const promemoria3h = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_3H}&tipo=eq.evento_promemoria_3h&select=id`,
+ );
+ assert.equal(promemoria24h.length, 1, "un solo promemoria 24h anche a due esecuzioni");
+ assert.equal(promemoria3h.length, 1, "un solo promemoria 3h anche a due esecuzioni");
+ },
+ );
+
+ // `ora` è testo libero (input nativo `type="time"`, ma la form non impedisce di
+ // svuotarlo): un evento con l'ora scritta male non deve rompere la generazione dei
+ // promemoria per tutti gli altri (era il caso prima che `ora_evento_a_time()`
+ // assorbisse l'errore riga per riga invece di far fallire l'intera INSERT...SELECT).
+ await prova(
+ "un evento con l'ora scritta male non blocca i promemoria degli altri eventi",
+ async () => {
+ const { data, ora } = dataOraRoma(new Date(Date.now() + 2 * 60 * 60 * 1000));
+ await inserisci("eventi_app", {
+ id: EVENTO_ORA_VALIDA,
+ tipo: "allenamento",
+ titolo: "Allenamento imminentissimo",
+ data,
+ ora,
+ convocati: [GIOCATORE_RLS],
+ });
+ await inserisci("eventi_app", {
+ id: EVENTO_ORA_ROTTA,
+ tipo: "allenamento",
+ titolo: "Evento con ora vuota",
+ data,
+ ora: "",
+ convocati: [GIOCATORE_RLS],
+ });
+
+ await chiamaRpc("genera_promemoria_eventi", {
+ p_tipo: "evento_promemoria_3h",
+ p_finestra: "3 hours",
+ });
+
+ const promemoria3h = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_ORA_VALIDA}&tipo=eq.evento_promemoria_3h&select=id`,
+ );
+ assert.equal(
+ promemoria3h.length,
+ 1,
+ "l'evento con l'ora valida riceve comunque il suo promemoria",
+ );
+ },
+ );
+
+ // --- 2. RLS: ognuno legge e segna come lette solo le proprie ----------------------
+ await prova("il giocatore legge solo le proprie notifiche", async () => {
+ const mie = await rest(
+ `notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&select=id`,
+ tokenGiocatore,
+ );
+ assert.equal(mie.status, 200);
+ const righeMie = (await mie.json()) as unknown[];
+ assert.ok(righeMie.length > 0, "g4 ha almeno una notifica dai test precedenti");
+
+ const altrui = await rest("notifiche_utente?giocatore_id=eq.g1&select=id", tokenGiocatore);
+ assert.equal(altrui.status, 200);
+ assert.deepEqual(
+ await altrui.json(),
+ [],
+ "le notifiche di un altro giocatore restano invisibili",
+ );
+ });
+
+ await prova("il giocatore segna come lette solo le proprie notifiche", async () => {
+ const res = await rest(
+ `notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&letta=eq.false`,
+ tokenGiocatore,
+ {
+ method: "PATCH",
+ headers: { Prefer: "return=representation" },
+ body: JSON.stringify({ letta: true }),
+ },
+ );
+ assert.equal(res.status, 200);
+ const aggiornate = (await res.json()) as Array<{ letta: boolean }>;
+ assert.ok(aggiornate.length > 0);
+ assert.ok(aggiornate.every((r) => r.letta === true));
+
+ const restaFalse = await leggi(
+ `notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&letta=eq.false&select=id`,
+ );
+ assert.equal(restaFalse.length, 0, "nessuna non letta è rimasta indietro");
+ });
+
+ await prova("il client non può inserire notifiche per sé", async () => {
+ const res = await rest("notifiche_utente", tokenGiocatore, {
+ method: "POST",
+ body: JSON.stringify({ giocatore_id: GIOCATORE_RLS, tipo: "admin", titolo: "finta" }),
+ });
+ assert.ok(!res.ok, `l'insert dal client deve fallire, invece ha risposto ${res.status}`);
+ });
+
+ // --- 3. Route notifica-personalizzata: scrive anche lo storico in-app -------------
+ server = await avviaServer();
+ console.log(`route notifica-personalizzata su ${server.baseUrl}`);
+
+ await prova("un messaggio a un giocatore specifico crea solo la sua notifica", async () => {
+ const res = await fetch(`${server!.baseUrl}/api/public/notifica-personalizzata`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
+ body: JSON.stringify({ messaggio: MESSAGGIO_UNICO, giocatoreId: GIOCATORE_RLS }),
+ });
+ assert.equal(res.status, 200);
+
+ const righe = await leggi<{ giocatore_id: string; tipo: string }>(
+ `notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}&select=giocatore_id,tipo`,
+ );
+ assert.deepEqual(righe, [{ giocatore_id: GIOCATORE_RLS, tipo: "admin" }]);
+
+ await rest(`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}`, SERVIZIO, {
+ method: "DELETE",
+ });
+ });
+
+ await prova("un messaggio senza destinatario notifica tutta la rosa attiva", async () => {
+ const attesi = await contaAttivi();
+ const res = await fetch(`${server!.baseUrl}/api/public/notifica-personalizzata`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
+ body: JSON.stringify({ messaggio: MESSAGGIO_UNICO }),
+ });
+ assert.equal(res.status, 200);
+
+ const righe = await leggi(
+ `notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}&tipo=eq.admin&select=giocatore_id`,
+ );
+ assert.equal(righe.length, attesi, "una notifica admin per ogni giocatore attivo");
+ });
+
+ await prova("il giocatore elimina solo le proprie notifiche", async () => {
+ const [miaNotifica] = await leggi<{ id: string }>(
+ `notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&select=id&limit=1`,
+ );
+ const [notificaAltrui] = await leggi<{ id: string }>(
+ "notifiche_utente?giocatore_id=eq.g1&select=id&limit=1",
+ );
+ assert.ok(miaNotifica, "g4 ha almeno una notifica da eliminare");
+ assert.ok(notificaAltrui, "g1 ha almeno una notifica altrui da provare a eliminare");
+
+ // Lo swipe/la × chiamano una DELETE per id: qui la RLS deve bloccare quella
+ // sull'id di un altro giocatore (0 righe toccate) e permettere la propria.
+ await rest(`notifiche_utente?id=eq.${notificaAltrui.id}`, tokenGiocatore, {
+ method: "DELETE",
+ });
+ const altruiRestaAncora = await leggi(
+ `notifiche_utente?id=eq.${notificaAltrui.id}&select=id`,
+ );
+ assert.equal(altruiRestaAncora.length, 1, "la notifica di un altro giocatore non si tocca");
+
+ await rest(`notifiche_utente?id=eq.${miaNotifica.id}`, tokenGiocatore, {
+ method: "DELETE",
+ });
+ const miaResta = await leggi(`notifiche_utente?id=eq.${miaNotifica.id}&select=id`);
+ assert.equal(miaResta.length, 0, "la propria notifica è stata eliminata");
+ });
+
+ // --- 4. Route promemoria-palloni: scrive anche lo storico in-app ------------------
+ await prova("il promemoria del turno palloni crea la notifica per l'incaricato", async () => {
+ await inserisci("eventi_app", {
+ id: EVENTO_PALLONI,
+ tipo: "partita",
+ titolo: "Partita con palloni assegnati",
+ data: "2026-09-25",
+ ora: "18:00",
+ convocati: [],
+ });
+ await inserisci("turni_palloni", { evento_id: EVENTO_PALLONI, giocatore_id: "g1" });
+
+ const res = await fetch(`${server!.baseUrl}/api/public/promemoria-palloni`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
+ body: JSON.stringify({ eventoId: EVENTO_PALLONI }),
+ });
+ assert.equal(res.status, 200);
+
+ const righe = await leggi<{ giocatore_id: string; letta: boolean }>(
+ `notifiche_utente?evento_id=eq.${EVENTO_PALLONI}&tipo=eq.turno_palloni&giocatore_id=eq.g1&select=giocatore_id,letta`,
+ );
+ assert.equal(righe.length, 1, "una sola notifica per l'incaricato");
+ assert.equal(righe[0]?.letta, false);
+
+ // Ripremere il pulsante aggiorna la stessa riga (upsert) invece di duplicarla o
+ // fallire per il vincolo UNIQUE.
+ const res2 = await fetch(`${server!.baseUrl}/api/public/promemoria-palloni`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
+ body: JSON.stringify({ eventoId: EVENTO_PALLONI }),
+ });
+ assert.equal(res2.status, 200);
+ const dopo = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_PALLONI}&tipo=eq.turno_palloni&giocatore_id=eq.g1&select=id`,
+ );
+ assert.equal(dopo.length, 1, "un secondo invio non duplica la notifica");
+ });
+
+ // --- 5. Route sollecita-presenze: scrive anche lo storico in-app ------------------
+ await prova("il sollecito presenze notifica chi non ha ancora risposto", async () => {
+ const attesi = await contaAttivi();
+ await inserisci("eventi_app", {
+ id: EVENTO_PRESENZE,
+ tipo: "allenamento",
+ titolo: "Allenamento da confermare",
+ data: "2026-09-26",
+ ora: "20:30",
+ convocati: [],
+ });
+
+ const res = await fetch(`${server!.baseUrl}/api/public/sollecita-presenze`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
+ body: JSON.stringify({ eventoId: EVENTO_PRESENZE }),
+ });
+ assert.equal(res.status, 200);
+
+ const righe = await leggi(
+ `notifiche_utente?evento_id=eq.${EVENTO_PRESENZE}&tipo=eq.sollecita_presenze&select=giocatore_id`,
+ );
+ assert.equal(righe.length, attesi, "nessuno ha ancora risposto: tutta la rosa attiva");
+ });
+ } finally {
+ server?.stop();
+ await rest(`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}`, SERVIZIO, {
+ method: "DELETE",
+ });
+ await rest(`turni_palloni?evento_id=eq.${EVENTO_PALLONI}`, SERVIZIO, { method: "DELETE" });
+ for (const id of [
+ EVENTO_24H,
+ EVENTO_3H,
+ EVENTO_ORA_VALIDA,
+ EVENTO_ORA_ROTTA,
+ EVENTO_PALLONI,
+ EVENTO_PRESENZE,
+ ]) {
+ await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
+ }
+ if (tokenAdmin) {
+ await rest(`giocatori_squadra?id=eq.${GIOCATORE_RLS}`, tokenAdmin, {
+ method: "PATCH",
+ body: JSON.stringify({ auth_user_id: null }),
+ });
+ }
+ for (const id of idUtenti) {
+ await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
+ await eliminaUtente(id);
+ }
+ riepilogo("notifiche-utente");
+ }
+}
diff --git a/test/integration/permessi-route.test.ts b/test/integration/permessi-route.test.ts
index 35f68e7..96429b6 100644
--- a/test/integration/permessi-route.test.ts
+++ b/test/integration/permessi-route.test.ts
@@ -134,6 +134,12 @@ if (!locale) {
});
} finally {
server.stop();
+ // La route scrive anche lo storico in-app (M17): il messaggio "prova" a tutta la
+ // rosa lascia righe in `notifiche_utente` che altrimenti restano per sempre.
+ await fetch(`${SUPABASE}/rest/v1/notifiche_utente?corpo=eq.prova&tipo=eq.admin`, {
+ method: "DELETE",
+ headers: authAdmin,
+ });
for (const id of idUtenti) {
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
method: "DELETE",
diff --git a/test/unit/notifiche-utente.test.ts b/test/unit/notifiche-utente.test.ts
new file mode 100644
index 0000000..7e902c3
--- /dev/null
+++ b/test/unit/notifiche-utente.test.ts
@@ -0,0 +1,39 @@
+/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
+import assert from "node:assert/strict";
+import { contatoreBadge, daRiga, type RigaNotifica } from "@/lib/notifiche-utente";
+
+// --- daRiga: conversione database -> modello applicativo ---------------------
+const riga: RigaNotifica = {
+ id: "n1",
+ tipo: "evento_promemoria_24h",
+ titolo: "Promemoria: Allenamento",
+ corpo: "10/09/2026 alle 20:30",
+ evento_id: "e1",
+ letta: false,
+ creato_il: "2026-09-10T09:00:00Z",
+};
+
+assert.deepEqual(daRiga(riga), {
+ id: "n1",
+ tipo: "evento_promemoria_24h",
+ titolo: "Promemoria: Allenamento",
+ corpo: "10/09/2026 alle 20:30",
+ eventoId: "e1",
+ letta: false,
+ creataIl: "2026-09-10T09:00:00Z",
+});
+
+assert.equal(
+ daRiga({ ...riga, evento_id: null }).eventoId,
+ null,
+ "un messaggio admin non ha evento",
+);
+
+// --- contatoreBadge: il numero esatto fino a 9, poi "9+" ---------------------
+assert.equal(contatoreBadge(0), "0");
+assert.equal(contatoreBadge(1), "1");
+assert.equal(contatoreBadge(9), "9");
+assert.equal(contatoreBadge(10), "9+");
+assert.equal(contatoreBadge(42), "9+");
+
+console.log("notifiche-utente: ok");