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:
1 parent
0314560885
commit
4d036f532d
15 files changed
+1184
-11
No files matched your search
+7
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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') $$
|
||||
);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user