Files
CRAPP/supabase/migrations/20260929160000_m24_push_promemoria_e_sondaggio.sql
davideandClaude Sonnet 5.5 e9013b703b Promemoria senza ritorni e tre canali per ogni notifica (DD-037, DD-038)
m23: un registro (promemoria_eventi_generati) ricorda i promemoria a 24 e 3 ore
già generati, così eliminare la notifica non li fa rigenerare dal cron né
riaccodare la mail a ogni giro.

m24: la push dei promemoria, che nascono nel database, la manda il worker
mailer/ con lo stesso modulo dell'app (coda notifiche_push_coda, esiti, backoff,
iscrizioni scadute eliminate, push scartate oltre 3 ore). Il sondaggio
pre-partita scrive anche notifiche_utente (tipo sondaggio_cacche): compare
in-app e arriva per email. L'immagine del worker si costruisce dalla radice
del repository per includere src/lib/webpush.server.ts.

Test: 9 di integrazione per m23, 17 per la coda push, 6 per la route del
sondaggio, nuovi casi unitari sul worker; prova end-to-end con un servizio
push finto. Documentazione e catalogo delle notifiche aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:19:54 +02:00

198 lines
7.7 KiB
PL/PgSQL

-- M24 — Tre canali per ogni notifica: push dei promemoria e sondaggio in-app/email (DD-038)
--
-- Due lacune rispetto al requisito «ogni notifica arriva per push, in-app ed email»:
--
-- 1. I promemoria a 24 e 3 ore nascono nel database (job pg_cron, M17), che non può firmare né
-- cifrare una push Web: arrivavano solo in-app e per email. Qui si accodano in
-- `notifiche_push_coda`; la push la manda il worker `mailer/`, con lo stesso modulo
-- dell'app (`src/lib/webpush.server.ts`).
-- 2. Il sondaggio pre-partita partiva solo come push. La route `apri-sondaggio` ora scrive anche
-- `notifiche_utente` (tipo nuovo `sondaggio_cacche`): compare nel centro notifiche e la
-- mail parte dal trigger di M22. Serve solo estendere il vincolo CHECK sul tipo.
--
-- Come per le email (M22), la coda è una tabella a parte con RLS senza policy: la policy UPDATE
-- del giocatore su `notifiche_utente` gli permetterebbe di riportare a 'in_coda' una push già
-- partita. Solo la service role (il worker) la legge e la scrive.
-- --- Tipo `sondaggio_cacche` -------------------------------------------------------------------
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
tipo IN (
'admin',
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni',
'sollecita_presenze',
'sondaggio_cacche'
)
);
-- --- Coda delle push dei promemoria ------------------------------------------------------------
CREATE TABLE public.notifiche_push_coda (
notifica_id uuid PRIMARY KEY REFERENCES public.notifiche_utente(id) ON DELETE CASCADE,
stato text NOT NULL DEFAULT 'in_coda'
CHECK (stato IN ('in_coda', 'in_invio', 'inviata', 'fallita', 'saltata')),
tentativi integer NOT NULL DEFAULT 0,
prossimo_tentativo timestamptz NOT NULL DEFAULT now(),
errore text,
inviata_il timestamptz,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.notifiche_push_coda IS
'Coda delle push per i promemoria evento (M24): una riga per notifica, scritta dal trigger e dal worker. Le altre notifiche mandano la push dall''app.';
CREATE INDEX notifiche_push_coda_da_inviare_idx
ON public.notifiche_push_coda (prossimo_tentativo)
WHERE stato = 'in_coda';
ALTER TABLE public.notifiche_push_coda ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.notifiche_push_coda FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.notifiche_push_coda TO service_role;
-- Solo i promemoria: turno palloni, sollecito presenze, messaggio dello staff e sondaggio la push
-- la mandano già dall'app, e una seconda dal worker sarebbe un doppione. Le notifiche già
-- presenti alla migration non si accodano: il trigger non ha backfill.
CREATE OR REPLACE FUNCTION public.accoda_push_promemoria()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.notifiche_push_coda (notifica_id)
VALUES (NEW.id)
ON CONFLICT (notifica_id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
AFTER INSERT ON public.notifiche_utente
FOR EACH ROW
WHEN (NEW.tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h'))
EXECUTE FUNCTION public.accoda_push_promemoria();
-- --- RPC del worker ----------------------------------------------------------------------------
-- Prende fino a `p_max` push da spedire, le marca 'in_invio' e restituisce per ciascuna il testo e
-- le iscrizioni (dispositivi) del giocatore. `FOR UPDATE SKIP LOCKED`: due worker avviati per
-- errore non prendono mai la stessa riga. Prima ripulisce tre casi:
-- * righe ferme 'in_invio' da più di 10 minuti (worker morto a metà) tornano 'in_coda';
-- * promemoria creati da più di 3 ore diventano 'saltata': una push arrivata a evento iniziato
-- è solo rumore (worker fermo a lungo);
-- * giocatori senza nessun dispositivo iscritto diventano 'saltata', senza errore.
CREATE OR REPLACE FUNCTION public.prendi_push_promemoria(p_max integer DEFAULT 20)
RETURNS TABLE (
id_notifica uuid,
oggetto text,
testo text,
tentativi_fatti integer,
iscrizioni jsonb
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.notifiche_push_coda c
SET stato = 'in_coda', aggiornata_il = now()
WHERE c.stato = 'in_invio'
AND c.aggiornata_il < now() - interval '10 minutes';
UPDATE public.notifiche_push_coda c
SET stato = 'saltata', aggiornata_il = now()
FROM public.notifiche_utente n
WHERE c.notifica_id = n.id
AND c.stato = 'in_coda'
AND c.prossimo_tentativo <= now()
AND (
n.creato_il < now() - interval '3 hours'
OR NOT EXISTS (
SELECT 1 FROM public.push_subscriptions s WHERE s.giocatore_id = n.giocatore_id
)
);
RETURN QUERY
WITH scelte AS (
SELECT c.notifica_id
FROM public.notifiche_push_coda c
WHERE c.stato = 'in_coda' AND c.prossimo_tentativo <= now()
ORDER BY c.prossimo_tentativo
LIMIT greatest(p_max, 0)
FOR UPDATE SKIP LOCKED
),
presi AS (
UPDATE public.notifiche_push_coda c
SET stato = 'in_invio', aggiornata_il = now()
FROM scelte s
WHERE c.notifica_id = s.notifica_id
RETURNING c.notifica_id, c.tentativi
)
SELECT p.notifica_id,
n.titolo,
n.corpo,
p.tentativi,
coalesce(
(SELECT jsonb_agg(jsonb_build_object(
'endpoint', s.endpoint, 'p256dh', s.p256dh, 'auth', s.auth))
FROM public.push_subscriptions s
WHERE s.giocatore_id = n.giocatore_id),
'[]'::jsonb
)
FROM presi p
JOIN public.notifiche_utente n ON n.id = p.notifica_id
ORDER BY n.creato_il;
END;
$$;
-- Registra l'esito. Agisce solo su righe 'in_invio', così un esito in ritardo non ne sovrascrive
-- uno già deciso.
-- 'inviata' → almeno un dispositivo ha accettato la push
-- 'riprova' → errore temporaneo, torna in coda dopo `p_prossimo`, conta un tentativo
-- 'fallita' → definitiva (rifiuto permanente o tentativi finiti)
-- 'saltata' → nessun dispositivo utile (tutte le iscrizioni erano scadute)
CREATE OR REPLACE FUNCTION public.esito_push_promemoria(
p_id uuid,
p_esito text,
p_errore text DEFAULT NULL,
p_prossimo timestamptz DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF p_esito NOT IN ('inviata', 'riprova', 'fallita', 'saltata') THEN
RAISE EXCEPTION 'esito non valido: %', p_esito;
END IF;
UPDATE public.notifiche_push_coda c
SET stato = CASE p_esito
WHEN 'riprova' THEN 'in_coda'
ELSE p_esito
END,
tentativi = c.tentativi + CASE WHEN p_esito IN ('riprova', 'fallita') THEN 1 ELSE 0 END,
prossimo_tentativo = CASE
WHEN p_esito = 'riprova' THEN coalesce(p_prossimo, now())
ELSE c.prossimo_tentativo
END,
errore = CASE WHEN p_esito = 'inviata' THEN NULL ELSE left(p_errore, 500) END,
inviata_il = CASE WHEN p_esito = 'inviata' THEN now() ELSE c.inviata_il END,
aggiornata_il = now()
WHERE c.notifica_id = p_id AND c.stato = 'in_invio';
END;
$$;
REVOKE ALL ON FUNCTION public.accoda_push_promemoria() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.prendi_push_promemoria(integer) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.esito_push_promemoria(uuid, text, text, timestamptz)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.prendi_push_promemoria(integer) TO service_role;
GRANT EXECUTE ON FUNCTION public.esito_push_promemoria(uuid, text, text, timestamptz)
TO service_role;