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>
163 lines
6.6 KiB
PL/PgSQL
163 lines
6.6 KiB
PL/PgSQL
-- 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') $$
|
|
);
|