From 4d036f532d6071c5034a7d024f7ea3749cc3e973 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 15 Sep 2026 11:19:07 +0200 Subject: [PATCH] Aggiunge il centro notifiche in-app accanto al profilo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icona a campana con badge delle non lette, indipendente dal canale push: copre messaggio libero admin, promemoria evento (24h/3h prima), turno palloni e sollecito presenze. Ogni notifica si rimuove con uno swipe o la ×, non c'è pulizia automatica delle vecchie (DD-030). Migration m17_notifiche_utente non ancora applicata in produzione. Co-Authored-By: Claude Mythos --- PROJECT_STATE.md | 9 +- docs/DATABASE.md | 2 + docs/DESIGN_DECISIONS.md | 70 +++ docs/ROADMAP.md | 3 + docs/modules/notifiche.md | 51 +- src/components/crapp/ui-bits.tsx | 161 +++++- src/lib/notifiche-utente.ts | 131 +++++ .../api/public/notifica-personalizzata.ts | 27 + src/routes/api/public/promemoria-palloni.ts | 19 + src/routes/api/public/sollecita-presenze.ts | 18 + src/routes/index.tsx | 14 +- .../20260915090000_m17_notifiche_utente.sql | 162 ++++++ test/integration/notifiche-utente.test.ts | 483 ++++++++++++++++++ test/integration/permessi-route.test.ts | 6 + test/unit/notifiche-utente.test.ts | 39 ++ 15 files changed, 1184 insertions(+), 11 deletions(-) create mode 100644 src/lib/notifiche-utente.ts create mode 100644 supabase/migrations/20260915090000_m17_notifiche_utente.sql create mode 100644 test/integration/notifiche-utente.test.ts create mode 100644 test/unit/notifiche-utente.test.ts 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");