Aggiunge il centro notifiche in-app accanto al profilo

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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Mythos committed 2026-09-15 11:19:07 +02:00
1 parent 0314560885
commit 4d036f532d
15 files changed
+1184 -11

No files matched your search

+7 -2
View File
@@ -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
+2
View File
@@ -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
+70
View File
@@ -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 (`<input type="time">` 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.
+3
View File
@@ -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
+47 -4
View File
@@ -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
+158 -3
View File
@@ -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 (
<motion.li
layout={!ridotto}
initial={false}
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
transition={molla.ui}
className="relative overflow-hidden rounded-xl"
>
<motion.div
drag={ridotto ? false : "x"}
dragConstraints={{ left: -80, right: 0 }}
dragElastic={{ left: 0.2, right: 0 }}
dragMomentum={false}
onDragEnd={(_, info) => {
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"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? <p className="mt-0.5 text-muted-foreground">{notifica.corpo}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
type="button"
onClick={() => onElimina(notifica.id)}
aria-label="Rimuovi notifica"
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.li>
);
}
/**
* 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<HTMLDivElement>(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 (
<div ref={riquadro} className="relative shrink-0">
<button
type="button"
onClick={alClick}
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
className="premi relative flex h-12 w-12 items-center justify-center rounded-2xl text-primary-foreground ring-2 ring-primary-foreground/30"
>
<Bell className="h-5 w-5" />
{nonLette > 0 ? (
<span className="bg-accent-grad absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold text-accent-foreground shadow-pop">
{contatoreBadge(nonLette)}
</span>
) : null}
</button>
{aperto ? (
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
{notifiche.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">Nessuna notifica</p>
) : (
<ul className="max-h-80 overflow-y-auto">
<AnimatePresence initial={false}>
{notifiche.map((n) => (
<RigaNotifica key={n.id} notifica={n} onElimina={eliminaNotifica.mutate} />
))}
</AnimatePresence>
</ul>
)}
</div>
) : null}
</div>
);
}
export function PageHeader({
titolo,
sottotitolo,
@@ -91,7 +241,12 @@ export function PageHeader({
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
) : null}
</div>
{azione ?? <LinkProfilo />}
{azione ?? (
<div className="flex shrink-0 items-center gap-2">
<IconaNotifiche />
<LinkProfilo />
</div>
)}
</div>
</header>
);
+131
View File
@@ -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<NotificaUtente[]> => {
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<NotificaUtente[]>(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<NotificaUtente[]>(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);
}
@@ -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 });
},
},
@@ -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")
@@ -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 {
+12 -2
View File
@@ -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() {
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
</div>
</div>
<LinkProfilo />
<div className="flex shrink-0 items-center gap-2">
<IconaNotifiche />
<LinkProfilo />
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
@@ -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 (`<input type="time">` 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') $$
);
+483
View File
@@ -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<string, unknown>) {
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<T>(percorso: string): Promise<T[]> {
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<number> {
const righe = await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&select=id");
return righe.length;
}
async function chiamaRpc(nome: string, corpo: Record<string, unknown>) {
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<string> {
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<string> {
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");
}
}
+6
View File
@@ -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",
+39
View File
@@ -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");