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>
This commit is contained in:
1 parent
58eba642f7
commit
e9013b703b
27 files changed
+2076
-92
No files matched your search
@@ -0,0 +1,72 @@
|
||||
-- M23 — I promemoria evento non ricompaiono più se il giocatore elimina la notifica (DD-037)
|
||||
--
|
||||
-- Fino a M22 `genera_promemoria_eventi()` evitava i doppioni con `ON CONFLICT DO NOTHING` sul
|
||||
-- vincolo UNIQUE (giocatore_id, evento_id, tipo) di `notifiche_utente`: la riga della notifica
|
||||
-- era l'unica traccia che il promemoria fosse già stato generato. Ma il giocatore può eliminare
|
||||
-- la notifica (swipe o ×, DD-030): senza più la riga, al giro successivo del job (ogni ora per
|
||||
-- il promemoria a 24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il
|
||||
-- promemoria veniva rigenerato come non letto e, dal trigger di M22, riaccodato per email.
|
||||
--
|
||||
-- Il registro `promemoria_eventi_generati` ricorda in modo indipendente che il promemoria è già
|
||||
-- stato generato per (giocatore, evento, tipo). Eliminare la notifica non lo tocca: il
|
||||
-- promemoria non torna, e non parte una nuova mail. Il registro sparisce con l'evento o con il
|
||||
-- giocatore (ON DELETE CASCADE), come le altre tabelle collegate (DD-029).
|
||||
|
||||
CREATE TABLE public.promemoria_eventi_generati (
|
||||
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
||||
evento_id text NOT NULL REFERENCES public.eventi_app(id) ON DELETE CASCADE,
|
||||
tipo text NOT NULL CHECK (tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h')),
|
||||
generato_il timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (giocatore_id, evento_id, tipo)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.promemoria_eventi_generati IS
|
||||
'Promemoria evento già generati per giocatore, evento e tipo: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M23, DD-037).';
|
||||
|
||||
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
|
||||
ALTER TABLE public.promemoria_eventi_generati ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.promemoria_eventi_generati FROM PUBLIC, anon, authenticated;
|
||||
GRANT ALL ON public.promemoria_eventi_generati TO service_role;
|
||||
|
||||
-- I promemoria già presenti in `notifiche_utente` risultano generati. Senza questo, la prima
|
||||
-- notifica eliminata dopo la migration verrebbe rigenerata come prima.
|
||||
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||
SELECT giocatore_id, evento_id, tipo
|
||||
FROM public.notifiche_utente
|
||||
WHERE tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h')
|
||||
AND evento_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Stessa firma e stessi criteri di M17 (i cron non cambiano). La differenza: prima si registra
|
||||
-- il promemoria come generato, e la notifica si crea solo per le righe appena registrate. Il
|
||||
-- vincolo UNIQUE su `notifiche_utente` resta come seconda difesa.
|
||||
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
|
||||
WITH da_generare AS (
|
||||
SELECT d AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora
|
||||
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
|
||||
),
|
||||
nuovi AS (
|
||||
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||
SELECT giocatore_id, evento_id, p_tipo FROM da_generare
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING giocatore_id, evento_id
|
||||
)
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||
SELECT g.giocatore_id, p_tipo, 'Promemoria: ' || g.titolo,
|
||||
to_char(g.data, 'DD/MM/YYYY') || ' alle ' || g.ora,
|
||||
g.evento_id
|
||||
FROM nuovi n
|
||||
JOIN da_generare g ON g.giocatore_id = n.giocatore_id AND g.evento_id = n.evento_id
|
||||
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,197 @@
|
||||
-- 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;
|
||||
Reference in new issue
Block a user