Aggiunge la migration m26: notifiche automatiche di palloni e solleciti (DD-040)
Promemoria con «oggi/domani», data, ora e luogo. Turno palloni a 12, 6 e 3 ore per gli eventi con un incaricato confermato, con revoca una tantum se l'incaricato cambia. Sollecito presenze a 24, 12 e 6 ore ai destinatari dell'evento senza risposta o con «forse». Due job pg_cron ogni 15 minuti; push dei tipi automatici dalla coda; nessuna email per il turno palloni. Nuovo test di integrazione sui job veri del database; aggiornati i test di promemoria, code push ed email. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
07624f6b9f
commit
563142a30a
5 files changed
+1413
-15
No files matched your search
@@ -0,0 +1,417 @@
|
|||||||
|
-- M26 — Notifiche automatiche di promemoria, turno palloni e sollecito presenze (DD-040)
|
||||||
|
--
|
||||||
|
-- Cosa cambia rispetto a M17/M23/M24:
|
||||||
|
--
|
||||||
|
-- 1. I promemoria a 24 e 3 ore hanno un testo nuovo, con data, ora e luogo; la parola «domani»
|
||||||
|
-- compare se la data dell'evento è quella di domani (fuso Europe/Rome), «oggi» altrimenti.
|
||||||
|
-- 2. Il turno palloni parte anche da solo, a 12, 6 e 3 ore dall'inizio, per gli allenamenti e le
|
||||||
|
-- partite con un incaricato *confermato* in `turni_palloni`: all'incaricato e, se è un'altra
|
||||||
|
-- persona, a chi aveva i palloni all'evento precedente. Se l'incaricato cambia dopo un
|
||||||
|
-- avviso, chi lo aveva ricevuto riceve una sola revoca.
|
||||||
|
-- 3. Il sollecito presenze parte da solo a 24, 12 e 6 ore dall'inizio, a chi tra i destinatari
|
||||||
|
-- dell'evento non ha risposto o ha risposto «forse».
|
||||||
|
-- 4. Le notifiche del turno palloni non generano email: restano push e centro notifiche.
|
||||||
|
--
|
||||||
|
-- I pulsanti manuali («Avvisa chi è di turno», «Sollecita») restano: usano gli stessi testi (vedi
|
||||||
|
-- `src/lib/palloni-core.ts` e `src/lib/presenze.ts`) e mandano la push dalla route, non dalla coda.
|
||||||
|
--
|
||||||
|
-- Le fasce sono contigue e senza sovrapposizione: un evento creato a meno di 12 ore dall'inizio
|
||||||
|
-- riceve solo la notifica della fascia in cui cade, non tutte insieme. Ogni notifica automatica si
|
||||||
|
-- genera una sola volta per (giocatore, evento, tipo), anche se il giocatore la elimina: lo ricorda
|
||||||
|
-- il registro `promemoria_eventi_generati` (M23, DD-037), che qui si estende ai tipi nuovi.
|
||||||
|
|
||||||
|
-- --- Tipi di notifica --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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',
|
||||||
|
'turno_palloni_12h',
|
||||||
|
'turno_palloni_6h',
|
||||||
|
'turno_palloni_3h',
|
||||||
|
'turno_palloni_revocato',
|
||||||
|
'sollecita_presenze',
|
||||||
|
'sollecita_presenze_24h',
|
||||||
|
'sollecita_presenze_12h',
|
||||||
|
'sollecita_presenze_6h',
|
||||||
|
'sondaggio_cacche'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.promemoria_eventi_generati DROP CONSTRAINT promemoria_eventi_generati_tipo_check;
|
||||||
|
ALTER TABLE public.promemoria_eventi_generati ADD CONSTRAINT promemoria_eventi_generati_tipo_check CHECK (
|
||||||
|
tipo IN (
|
||||||
|
'evento_promemoria_24h',
|
||||||
|
'evento_promemoria_3h',
|
||||||
|
'turno_palloni_12h',
|
||||||
|
'turno_palloni_6h',
|
||||||
|
'turno_palloni_3h',
|
||||||
|
'turno_palloni_revocato',
|
||||||
|
'sollecita_presenze_24h',
|
||||||
|
'sollecita_presenze_12h',
|
||||||
|
'sollecita_presenze_6h'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --- Istante d'inizio di un evento -------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- `data` e `ora` sono ora locale Italia (stessa convenzione di M10 e M17). `ora` è testo libero: se
|
||||||
|
-- non è un'ora valida il risultato è NULL e l'evento viene solo escluso (vedi `ora_evento_a_time`).
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.inizio_evento(p_data date, p_ora text)
|
||||||
|
RETURNS timestamptz
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT (p_data + public.ora_evento_a_time(p_ora)) AT TIME ZONE 'Europe/Rome';
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- --- Promemoria a 24 e 3 ore: testo nuovo ------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Stessa firma e stessi criteri di M23 (i cron non cambiano). Cambia solo il testo: titolo
|
||||||
|
-- «Promemoria evento di domani|oggi: <titolo>», corpo con Data, Ora e, se c'è, Luogo.
|
||||||
|
|
||||||
|
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, e.luogo,
|
||||||
|
CASE WHEN e.data = ((now() AT TIME ZONE 'Europe/Rome')::date + 1)
|
||||||
|
THEN 'domani' ELSE 'oggi' END AS parola
|
||||||
|
FROM public.eventi_app e
|
||||||
|
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
|
||||||
|
WHERE public.inizio_evento(e.data, e.ora) IS NOT NULL
|
||||||
|
AND public.inizio_evento(e.data, e.ora) - 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 evento di ' || g.parola || ': ' || g.titolo,
|
||||||
|
'Data: ' || g.parola || ', ' || to_char(g.data, 'DD/MM/YYYY')
|
||||||
|
|| E'\nOra: ' || g.ora
|
||||||
|
|| CASE WHEN btrim(g.luogo) <> '' THEN E'\nLuogo: ' || btrim(g.luogo) ELSE '' END,
|
||||||
|
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;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- --- Turno palloni automatico ------------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- «Evento precedente» e «evento successivo» seguono `eventoPrecedente()`/`eventoSuccessivo()` di
|
||||||
|
-- `src/lib/palloni-core.ts`: solo allenamenti e partite, in ordine di data (a parità, di ora). La
|
||||||
|
-- logica è volutamente la stessa in due posti (il database non può chiamare il codice dell'app):
|
||||||
|
-- `test/integration/notifiche-automatiche.test.ts` li confronta sugli stessi dati.
|
||||||
|
|
||||||
|
-- Avvisi della fascia [p_da, p_a) prima dell'inizio. Parte solo se l'evento ha un incaricato
|
||||||
|
-- confermato in `turni_palloni`; i destinatari devono essere giocatori attivi.
|
||||||
|
CREATE OR REPLACE FUNCTION public.genera_avvisi_palloni_fascia(
|
||||||
|
p_tipo text, p_da interval, p_a interval
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
WITH ev AS (
|
||||||
|
SELECT e.id, e.titolo, e.data, e.ora,
|
||||||
|
public.inizio_evento(e.data, e.ora) AS inizio,
|
||||||
|
lag(e.id) OVER ordine AS precedente_id,
|
||||||
|
lead(e.data) OVER ordine AS successivo_data
|
||||||
|
FROM public.eventi_app e
|
||||||
|
WHERE e.tipo IN ('allenamento', 'partita')
|
||||||
|
WINDOW ordine AS (
|
||||||
|
ORDER BY e.data, public.ora_evento_a_time(e.ora), e.id COLLATE "C"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
turni AS (
|
||||||
|
SELECT ev.*, t.giocatore_id AS incaricato, tp.giocatore_id AS precedente
|
||||||
|
FROM ev
|
||||||
|
JOIN public.turni_palloni t ON t.evento_id = ev.id
|
||||||
|
LEFT JOIN public.turni_palloni tp ON tp.evento_id = ev.precedente_id
|
||||||
|
WHERE ev.inizio IS NOT NULL
|
||||||
|
AND ev.inizio - now() >= p_da
|
||||||
|
AND ev.inizio - now() < p_a
|
||||||
|
),
|
||||||
|
destinatari AS (
|
||||||
|
SELECT incaricato AS giocatore_id, id AS evento_id, true AS e_incaricato,
|
||||||
|
titolo, data, ora, successivo_data
|
||||||
|
FROM turni
|
||||||
|
UNION ALL
|
||||||
|
SELECT precedente, id, false, titolo, data, ora, successivo_data
|
||||||
|
FROM turni
|
||||||
|
WHERE precedente IS NOT NULL AND precedente <> incaricato
|
||||||
|
),
|
||||||
|
validi AS (
|
||||||
|
SELECT d.*
|
||||||
|
FROM destinatari d
|
||||||
|
JOIN public.giocatori_squadra g
|
||||||
|
ON g.id = d.giocatore_id AND g.attivo = true AND g.tipo = 'giocatore'
|
||||||
|
),
|
||||||
|
nuovi AS (
|
||||||
|
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||||
|
SELECT giocatore_id, evento_id, p_tipo FROM validi
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING giocatore_id, evento_id
|
||||||
|
)
|
||||||
|
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||||
|
SELECT v.giocatore_id, p_tipo,
|
||||||
|
CASE WHEN v.e_incaricato THEN 'Turno palloni: incarico assegnato'
|
||||||
|
ELSE 'Turno palloni: riconsegna' END,
|
||||||
|
'Evento: ' || v.titolo
|
||||||
|
|| E'\nData: ' || to_char(v.data, 'DD/MM/YYYY') || ', ore ' || v.ora
|
||||||
|
|| CASE WHEN v.e_incaricato
|
||||||
|
THEN E'\nIncarico: custodia dei palloni al termine dell''evento'
|
||||||
|
|| CASE WHEN v.successivo_data IS NOT NULL
|
||||||
|
THEN E'\nRiconsegna: ' || to_char(v.successivo_data, 'DD/MM/YYYY')
|
||||||
|
ELSE '' END
|
||||||
|
ELSE E'\nIncarico: riconsegna dei palloni in custodia dal turno precedente'
|
||||||
|
END,
|
||||||
|
v.evento_id
|
||||||
|
FROM nuovi n
|
||||||
|
JOIN validi v ON v.giocatore_id = n.giocatore_id AND v.evento_id = n.evento_id
|
||||||
|
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Revoca: chi ha ricevuto un avviso automatico per un evento non ancora iniziato e non è più
|
||||||
|
-- destinatario (incaricato cambiato o tolto, o turno precedente riassegnato) riceve una sola volta
|
||||||
|
-- «incarico revocato». Si guarda il registro, non `notifiche_utente`: il giocatore può aver
|
||||||
|
-- eliminato l'avviso. Gli avvisi del pulsante non contano: possono riguardare una proposta non
|
||||||
|
-- confermata, che qui non esiste.
|
||||||
|
CREATE OR REPLACE FUNCTION public.genera_revoche_palloni()
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
WITH ev AS (
|
||||||
|
SELECT e.id, e.titolo, e.data, e.ora,
|
||||||
|
public.inizio_evento(e.data, e.ora) AS inizio,
|
||||||
|
lag(e.id) OVER ordine AS precedente_id
|
||||||
|
FROM public.eventi_app e
|
||||||
|
WHERE e.tipo IN ('allenamento', 'partita')
|
||||||
|
WINDOW ordine AS (
|
||||||
|
ORDER BY e.data, public.ora_evento_a_time(e.ora), e.id COLLATE "C"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
attuali AS (
|
||||||
|
SELECT t.giocatore_id, ev.id AS evento_id
|
||||||
|
FROM ev JOIN public.turni_palloni t ON t.evento_id = ev.id
|
||||||
|
UNION
|
||||||
|
SELECT tp.giocatore_id, ev.id
|
||||||
|
FROM ev
|
||||||
|
JOIN public.turni_palloni t ON t.evento_id = ev.id
|
||||||
|
JOIN public.turni_palloni tp ON tp.evento_id = ev.precedente_id
|
||||||
|
JOIN public.giocatori_squadra g ON g.id = tp.giocatore_id
|
||||||
|
WHERE tp.giocatore_id <> t.giocatore_id
|
||||||
|
),
|
||||||
|
da_revocare AS (
|
||||||
|
SELECT DISTINCT r.giocatore_id, r.evento_id, ev.titolo, ev.data, ev.ora
|
||||||
|
FROM public.promemoria_eventi_generati r
|
||||||
|
JOIN ev ON ev.id = r.evento_id
|
||||||
|
WHERE r.tipo IN ('turno_palloni_12h', 'turno_palloni_6h', 'turno_palloni_3h')
|
||||||
|
AND ev.inizio IS NOT NULL
|
||||||
|
AND ev.inizio >= now()
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM attuali a
|
||||||
|
JOIN public.giocatori_squadra g
|
||||||
|
ON g.id = a.giocatore_id AND g.attivo = true AND g.tipo = 'giocatore'
|
||||||
|
WHERE a.giocatore_id = r.giocatore_id AND a.evento_id = r.evento_id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
nuovi AS (
|
||||||
|
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||||
|
SELECT giocatore_id, evento_id, 'turno_palloni_revocato' FROM da_revocare
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING giocatore_id, evento_id
|
||||||
|
)
|
||||||
|
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||||
|
SELECT d.giocatore_id, 'turno_palloni_revocato',
|
||||||
|
'Turno palloni: incarico revocato',
|
||||||
|
'Evento: ' || d.titolo
|
||||||
|
|| E'\nData: ' || to_char(d.data, 'DD/MM/YYYY') || ', ore ' || d.ora
|
||||||
|
|| E'\nIncarico: non più a tuo carico per questo evento',
|
||||||
|
d.evento_id
|
||||||
|
FROM nuovi n
|
||||||
|
JOIN da_revocare d ON d.giocatore_id = n.giocatore_id AND d.evento_id = n.evento_id
|
||||||
|
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.genera_avvisi_palloni()
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_12h', interval '6 hours', interval '12 hours');
|
||||||
|
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_6h', interval '3 hours', interval '6 hours');
|
||||||
|
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_3h', interval '0', interval '3 hours');
|
||||||
|
PERFORM public.genera_revoche_palloni();
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- --- Sollecito presenze automatico -------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Destinatari: quelli dell'evento (`giocatori_destinatari_evento`: convocati, o tutta la rosa
|
||||||
|
-- attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno una risposta o
|
||||||
|
-- hanno risposto «forse».
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.genera_solleciti_presenze_fascia(
|
||||||
|
p_tipo text, p_da interval, p_a interval
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
WITH da_generare AS (
|
||||||
|
SELECT d.gid AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora, e.luogo, r.stato
|
||||||
|
FROM public.eventi_app e
|
||||||
|
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d(gid)
|
||||||
|
LEFT JOIN public.risposte_presenze r
|
||||||
|
ON r.evento_id = e.id AND r.giocatore_id = d.gid
|
||||||
|
WHERE public.inizio_evento(e.data, e.ora) IS NOT NULL
|
||||||
|
AND public.inizio_evento(e.data, e.ora) - now() >= p_da
|
||||||
|
AND public.inizio_evento(e.data, e.ora) - now() < p_a
|
||||||
|
AND (r.stato IS NULL OR r.stato = 'forse')
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
'Conferma di partecipazione richiesta: ' || g.titolo,
|
||||||
|
'Data: ' || to_char(g.data, 'DD/MM/YYYY')
|
||||||
|
|| E'\nOra: ' || g.ora
|
||||||
|
|| CASE WHEN btrim(g.luogo) <> '' THEN E'\nLuogo: ' || btrim(g.luogo) ELSE '' END
|
||||||
|
|| E'\nRisposta attuale: ' || CASE WHEN g.stato = 'forse' THEN 'forse' ELSE 'nessuna' END
|
||||||
|
|| E'\nAzione: indicare presente, assente o in ritardo',
|
||||||
|
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;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.genera_solleciti_presenze()
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_24h', interval '12 hours', interval '24 hours');
|
||||||
|
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_12h', interval '6 hours', interval '12 hours');
|
||||||
|
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_6h', interval '0', interval '6 hours');
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Come gli altri job di M17: solo il proprietario (pg_cron) e la service role li eseguono.
|
||||||
|
REVOKE ALL ON FUNCTION public.inizio_evento(date, text) FROM PUBLIC, anon, authenticated;
|
||||||
|
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni_fascia(text, interval, interval)
|
||||||
|
FROM PUBLIC, anon, authenticated;
|
||||||
|
REVOKE ALL ON FUNCTION public.genera_revoche_palloni() FROM PUBLIC, anon, authenticated;
|
||||||
|
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni() FROM PUBLIC, anon, authenticated;
|
||||||
|
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze_fascia(text, interval, interval)
|
||||||
|
FROM PUBLIC, anon, authenticated;
|
||||||
|
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze() FROM PUBLIC, anon, authenticated;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.inizio_evento(date, text) TO service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni_fascia(text, interval, interval)
|
||||||
|
TO service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.genera_revoche_palloni() TO service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni() TO service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze_fascia(text, interval, interval)
|
||||||
|
TO service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze() TO service_role;
|
||||||
|
|
||||||
|
SELECT cron.schedule(
|
||||||
|
'avvisi-palloni-automatici',
|
||||||
|
'*/15 * * * *',
|
||||||
|
$$ SELECT public.genera_avvisi_palloni() $$
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT cron.schedule(
|
||||||
|
'solleciti-presenze-automatici',
|
||||||
|
'*/15 * * * *',
|
||||||
|
$$ SELECT public.genera_solleciti_presenze() $$
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --- Push: anche le notifiche automatiche di palloni e solleciti -------------------------------
|
||||||
|
--
|
||||||
|
-- Nascono nel database come i promemoria: nessuna route le manda, quindi la push passa dalla coda
|
||||||
|
-- (DD-038). Gli avvisi dei pulsanti (`turno_palloni`, `sollecita_presenze`) restano fuori: la push
|
||||||
|
-- la manda già la route e una seconda sarebbe un doppione.
|
||||||
|
|
||||||
|
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
|
||||||
|
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',
|
||||||
|
'turno_palloni_12h',
|
||||||
|
'turno_palloni_6h',
|
||||||
|
'turno_palloni_3h',
|
||||||
|
'turno_palloni_revocato',
|
||||||
|
'sollecita_presenze_24h',
|
||||||
|
'sollecita_presenze_12h',
|
||||||
|
'sollecita_presenze_6h'
|
||||||
|
))
|
||||||
|
EXECUTE FUNCTION public.accoda_push_promemoria();
|
||||||
|
|
||||||
|
-- --- Email: il turno palloni non ne manda ------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Stessa funzione di M22, con un'uscita anticipata per i tipi dei palloni (automatici, revoca e
|
||||||
|
-- pulsante). L'`upsert` del pulsante riscrive `creato_il` e fa scattare il trigger: anche quel
|
||||||
|
-- caso esce senza accodare nulla.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.accoda_notifica_email()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.tipo IN (
|
||||||
|
'turno_palloni',
|
||||||
|
'turno_palloni_12h',
|
||||||
|
'turno_palloni_6h',
|
||||||
|
'turno_palloni_3h',
|
||||||
|
'turno_palloni_revocato'
|
||||||
|
) THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.notifiche_email_coda (notifica_id)
|
||||||
|
VALUES (NEW.id)
|
||||||
|
ON CONFLICT (notifica_id) DO UPDATE
|
||||||
|
SET stato = 'in_coda',
|
||||||
|
tentativi = 0,
|
||||||
|
prossimo_tentativo = now(),
|
||||||
|
errore = NULL,
|
||||||
|
inviata_il = NULL,
|
||||||
|
aggiornata_il = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,808 @@
|
|||||||
|
/**
|
||||||
|
* Notifiche automatiche di turno palloni e sollecito presenze (M26, DD-040):
|
||||||
|
* `bun test/integration/notifiche-automatiche.test.ts`.
|
||||||
|
*
|
||||||
|
* Esegue i job veri del database (`genera_avvisi_palloni`, `genera_solleciti_presenze`) su eventi
|
||||||
|
* creati con orari relativi a «adesso» e verifica:
|
||||||
|
*
|
||||||
|
* - le finestre contigue e senza sovrapposizione (palloni 12·6·3 ore, solleciti 24·12·6 ore);
|
||||||
|
* - i destinatari (incaricato e turno precedente; convocati senza risposta o con «forse»; mai
|
||||||
|
* l'allenatore né chi non è più attivo);
|
||||||
|
* - una sola generazione per giocatore, evento e tipo, anche se la notifica viene eliminata;
|
||||||
|
* - la revoca dell'incarico, una sola volta, guardando il registro e non le notifiche;
|
||||||
|
* - il canale: push in coda, nessuna email per i palloni, email per i solleciti;
|
||||||
|
* - che il testo generato in SQL sia identico a quello del codice TypeScript (`avvisiPalloniEvento`,
|
||||||
|
* `testoSollecito`) e che «evento precedente/successivo» seguano lo stesso ordine di
|
||||||
|
* `palloni-core.ts`.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`). Usa eventi con il prefisso
|
||||||
|
* `test-notifiche-automatiche` e gli slot `g4`, `g5`, `g7`, `g8`, `g9` (inattivo) e `g10`
|
||||||
|
* (allenatore) della rosa seed, ripristinati alla fine.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import type { Evento } from "@/lib/eventi";
|
||||||
|
import { avvisiPalloniEvento, eventoPrecedente, eventoSuccessivo } from "@/lib/palloni-core";
|
||||||
|
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("notifiche automatiche", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("notifiche-automatiche");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`notifiche automatiche su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-notifiche-automatiche";
|
||||||
|
const PASSWORD = "prova-notifiche-automatiche-123";
|
||||||
|
const G4 = "g4";
|
||||||
|
const G5 = "g5";
|
||||||
|
const G6 = "g6";
|
||||||
|
const G7 = "g7";
|
||||||
|
const G8 = "g8";
|
||||||
|
const INATTIVO = "g9";
|
||||||
|
const ALLENATORE = "g10";
|
||||||
|
|
||||||
|
// Palloni: in ordine cronologico PASSATO < PREC < MAIN < EV6 < EV12 < LONTANO.
|
||||||
|
const PASSATO = `${PREFISSO}-passato`;
|
||||||
|
const PREC = `${PREFISSO}-prec`;
|
||||||
|
const MAIN = `${PREFISSO}-main`;
|
||||||
|
const EV6 = `${PREFISSO}-ev6`;
|
||||||
|
const EV12 = `${PREFISSO}-ev12`;
|
||||||
|
const LONTANO = `${PREFISSO}-lontano`;
|
||||||
|
const GENERICO = `${PREFISSO}-generico`;
|
||||||
|
const COPPIA_A = `${PREFISSO}-coppia-a`;
|
||||||
|
const COPPIA_B = `${PREFISSO}-coppia-b`;
|
||||||
|
const ORA_ROTTA = `${PREFISSO}-ora-rotta`;
|
||||||
|
// Solleciti: eventi extra-campo, così non entrano nell'ordine dei palloni.
|
||||||
|
const S24 = `${PREFISSO}-s24`;
|
||||||
|
const S12 = `${PREFISSO}-s12`;
|
||||||
|
const S6 = `${PREFISSO}-s6`;
|
||||||
|
const S_TARDI = `${PREFISSO}-s-tardi`;
|
||||||
|
const S_FORSE = `${PREFISSO}-s-forse`;
|
||||||
|
const S_FUORI = `${PREFISSO}-s-fuori`;
|
||||||
|
const TUTTI = [
|
||||||
|
PASSATO,
|
||||||
|
PREC,
|
||||||
|
MAIN,
|
||||||
|
EV6,
|
||||||
|
EV12,
|
||||||
|
LONTANO,
|
||||||
|
GENERICO,
|
||||||
|
COPPIA_A,
|
||||||
|
COPPIA_B,
|
||||||
|
ORA_ROTTA,
|
||||||
|
S24,
|
||||||
|
S12,
|
||||||
|
S6,
|
||||||
|
S_TARDI,
|
||||||
|
S_FORSE,
|
||||||
|
S_FUORI,
|
||||||
|
];
|
||||||
|
|
||||||
|
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 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 scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
|
||||||
|
const res = await rest(percorso, token, {
|
||||||
|
method: metodo,
|
||||||
|
body: corpo === undefined ? null : JSON.stringify(corpo),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc(nome: string, corpo: unknown = {}, token = SERVIZIO) {
|
||||||
|
return fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(corpo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function esegui(nome: string, corpo: unknown = {}) {
|
||||||
|
const res = await rpc(nome, corpo);
|
||||||
|
if (!res.ok) throw new Error(`${nome}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function creaUtente(email: string): Promise<{ id: string; token: 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: PASSWORD, email_confirm: true }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { id?: string };
|
||||||
|
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||||
|
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { apikey: ANON, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password: PASSWORD }),
|
||||||
|
});
|
||||||
|
const t = (await login.json()) as { access_token?: string };
|
||||||
|
if (!t.access_token) throw new Error("accesso fallito");
|
||||||
|
return { id: corpo.id, token: t.access_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data e ora dell'inizio tra `ore` ore, nel fuso Europe/Rome (convenzione di `eventi_app`). */
|
||||||
|
function tra(ore: number): { data: string; ora: string } {
|
||||||
|
const quando = new Date(Date.now() + ore * 60 * 60 * 1000);
|
||||||
|
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 gg = (iso: string) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
|
||||||
|
|
||||||
|
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
|
||||||
|
const notifiche = (evento: string, tipo?: string) =>
|
||||||
|
leggi<Riga>(
|
||||||
|
`notifiche_utente?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}` +
|
||||||
|
`&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id,tipo`,
|
||||||
|
);
|
||||||
|
const destinatari = async (evento: string, tipo: string) =>
|
||||||
|
(await notifiche(evento, tipo)).map((r) => r.giocatore_id).sort();
|
||||||
|
const registro = (evento: string, tipo?: string) =>
|
||||||
|
leggi<{ giocatore_id: string; tipo: string }>(
|
||||||
|
`promemoria_eventi_generati?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}` +
|
||||||
|
`&select=giocatore_id,tipo&order=giocatore_id`,
|
||||||
|
);
|
||||||
|
const inCodaEmail = async (ids: string[]) =>
|
||||||
|
ids.length === 0
|
||||||
|
? []
|
||||||
|
: leggi<{ notifica_id: string }>(
|
||||||
|
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
|
||||||
|
);
|
||||||
|
const inCodaPush = async (ids: string[]) =>
|
||||||
|
ids.length === 0
|
||||||
|
? []
|
||||||
|
: leggi<{ notifica_id: string; stato: string }>(
|
||||||
|
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id,stato`,
|
||||||
|
);
|
||||||
|
|
||||||
|
type RigaEvento = {
|
||||||
|
id: string;
|
||||||
|
tipo: Evento["tipo"];
|
||||||
|
titolo: string;
|
||||||
|
luogo: string;
|
||||||
|
data: string;
|
||||||
|
ora: string;
|
||||||
|
convocati: string[];
|
||||||
|
};
|
||||||
|
/** Gli eventi del database come li vedrebbe il codice dell'app (solo i campi che servono). */
|
||||||
|
const eventiDelDatabase = async (): Promise<Evento[]> =>
|
||||||
|
(await leggi<RigaEvento>("eventi_app?select=id,tipo,titolo,luogo,data,ora,convocati")).map(
|
||||||
|
(e) => ({ ...e, note: "", campionato: false, casa: true, pagelleChiuse: false }),
|
||||||
|
);
|
||||||
|
const turniDelDatabase = async (): Promise<Record<string, string>> =>
|
||||||
|
Object.fromEntries(
|
||||||
|
(await leggi<{ evento_id: string; giocatore_id: string }>("turni_palloni?select=*")).map(
|
||||||
|
(t) => [t.evento_id, t.giocatore_id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const creaEvento = (
|
||||||
|
id: string,
|
||||||
|
tipo: string,
|
||||||
|
quando: { data: string; ora: string },
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) =>
|
||||||
|
scrivi("eventi_app", "POST", {
|
||||||
|
id,
|
||||||
|
tipo,
|
||||||
|
titolo: `${PREFISSO} ${id.slice(PREFISSO.length + 1)}`,
|
||||||
|
luogo: "PalaCRAP",
|
||||||
|
...quando,
|
||||||
|
convocati: [],
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const spostaEvento = (id: string, ore: number) =>
|
||||||
|
scrivi(`eventi_app?id=eq.${id}`, "PATCH", tra(ore));
|
||||||
|
const assegna = (evento: string, giocatore: string) =>
|
||||||
|
scrivi("turni_palloni", "POST", { evento_id: evento, giocatore_id: giocatore });
|
||||||
|
const cambiaTurno = (evento: string, giocatore: string) =>
|
||||||
|
scrivi(`turni_palloni?evento_id=eq.${evento}`, "PATCH", { giocatore_id: giocatore });
|
||||||
|
const rispondi = (evento: string, giocatore: string, stato: string) =>
|
||||||
|
scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", {
|
||||||
|
evento_id: evento,
|
||||||
|
giocatore_id: giocatore,
|
||||||
|
stato,
|
||||||
|
}).catch(async () =>
|
||||||
|
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", {
|
||||||
|
stato,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const idUtenti: string[] = [];
|
||||||
|
let tokenAdmin = "";
|
||||||
|
const originali = new Map<string, Record<string, unknown>>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Preparazione: un admin per toccare gli slot, un allenatore e un giocatore inattivo ---
|
||||||
|
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
|
||||||
|
const allenatore = await creaUtente(`${PREFISSO}-allenatore-${Date.now()}@example.test`);
|
||||||
|
idUtenti.push(admin.id, allenatore.id);
|
||||||
|
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
|
||||||
|
tokenAdmin = admin.token;
|
||||||
|
for (const id of [ALLENATORE, INATTIVO]) {
|
||||||
|
const [riga] = await leggi<Record<string, unknown>>(
|
||||||
|
`giocatori_squadra?id=eq.${id}&select=tipo,attivo,auth_user_id`,
|
||||||
|
);
|
||||||
|
originali.set(id, riga ?? { tipo: "giocatore", attivo: true, auth_user_id: null });
|
||||||
|
}
|
||||||
|
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
|
||||||
|
await scrivi(
|
||||||
|
`giocatori_squadra?id=eq.${ALLENATORE}`,
|
||||||
|
"PATCH",
|
||||||
|
{ tipo: "allenatore", auth_user_id: allenatore.id },
|
||||||
|
tokenAdmin,
|
||||||
|
);
|
||||||
|
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin);
|
||||||
|
|
||||||
|
// Palloni. Le ore scelte stanno a più di mezz'ora dai confini delle finestre.
|
||||||
|
await creaEvento(PASSATO, "partita", tra(-60));
|
||||||
|
await creaEvento(PREC, "allenamento", tra(-30));
|
||||||
|
await creaEvento(MAIN, "allenamento", tra(2.5));
|
||||||
|
await creaEvento(EV6, "partita", tra(4.5));
|
||||||
|
await creaEvento(EV12, "allenamento", tra(9));
|
||||||
|
await creaEvento(LONTANO, "allenamento", tra(40));
|
||||||
|
await creaEvento(GENERICO, "evento", tra(2.2));
|
||||||
|
await assegna(PREC, G5);
|
||||||
|
await assegna(MAIN, G4);
|
||||||
|
await assegna(EV6, G7);
|
||||||
|
await assegna(EV12, G8);
|
||||||
|
await assegna(GENERICO, G4); // extra-campo: il turno non conta
|
||||||
|
|
||||||
|
// --- Turno palloni: finestre e destinatari ---------------------------------------------
|
||||||
|
await prova("ogni evento riceve l'avviso della sua fascia e di nessun'altra", async () => {
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4, G5]);
|
||||||
|
assert.deepEqual(await destinatari(EV6, "turno_palloni_6h"), [G4, G7]);
|
||||||
|
assert.deepEqual(await destinatari(EV12, "turno_palloni_12h"), [G7, G8]);
|
||||||
|
for (const [evento, altri] of [
|
||||||
|
[MAIN, ["turno_palloni_6h", "turno_palloni_12h"]],
|
||||||
|
[EV6, ["turno_palloni_3h", "turno_palloni_12h"]],
|
||||||
|
[EV12, ["turno_palloni_3h", "turno_palloni_6h"]],
|
||||||
|
] as const) {
|
||||||
|
for (const tipo of altri) {
|
||||||
|
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"nessun avviso per eventi lontani, passati, extra-campo o senza incaricato",
|
||||||
|
async () => {
|
||||||
|
assert.equal((await notifiche(LONTANO)).length, 0, "tra 40 ore: fuori da ogni fascia");
|
||||||
|
assert.equal((await notifiche(PREC)).length, 0, "già iniziato");
|
||||||
|
assert.equal((await notifiche(GENERICO)).length, 0, "un evento extra-campo non ha palloni");
|
||||||
|
assert.equal((await registro(GENERICO)).length, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"il testo è quello di «incarico assegnato» e «riconsegna», identico al codice",
|
||||||
|
async () => {
|
||||||
|
const eventi = await eventiDelDatabase();
|
||||||
|
const turni = await turniDelDatabase();
|
||||||
|
for (const [evento, tipo] of [
|
||||||
|
[MAIN, "turno_palloni_3h"],
|
||||||
|
[EV6, "turno_palloni_6h"],
|
||||||
|
[EV12, "turno_palloni_12h"],
|
||||||
|
] as const) {
|
||||||
|
const attesi = avvisiPalloniEvento(turni, eventi, evento);
|
||||||
|
assert.equal(attesi.length, 2, evento);
|
||||||
|
const righe = await notifiche(evento, tipo);
|
||||||
|
for (const atteso of attesi) {
|
||||||
|
const r = righe.find((x) => x.giocatore_id === atteso.giocatoreId);
|
||||||
|
assert.ok(r, `${evento}: manca l'avviso a ${atteso.giocatoreId}`);
|
||||||
|
assert.equal(r.titolo, atteso.titolo, `${evento} titolo`);
|
||||||
|
assert.equal(r.corpo, atteso.testo, `${evento} corpo`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [incarico] = (await notifiche(MAIN, "turno_palloni_3h")).filter(
|
||||||
|
(r) => r.giocatore_id === G4,
|
||||||
|
);
|
||||||
|
const eventiEv6 = eventi.find((e) => e.id === EV6)!;
|
||||||
|
const main = eventi.find((e) => e.id === MAIN)!;
|
||||||
|
assert.equal(
|
||||||
|
incarico?.corpo,
|
||||||
|
[
|
||||||
|
`Evento: ${PREFISSO} main`,
|
||||||
|
`Data: ${gg(main.data)}, ore ${main.ora}`,
|
||||||
|
"Incarico: custodia dei palloni al termine dell'evento",
|
||||||
|
`Riconsegna: ${gg(eventiEv6.data)}`,
|
||||||
|
].join("\n"),
|
||||||
|
"la riconsegna è la data dell'evento successivo",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("registro e canali: push in coda, nessuna email", async () => {
|
||||||
|
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2);
|
||||||
|
const righe = [
|
||||||
|
...(await notifiche(MAIN, "turno_palloni_3h")),
|
||||||
|
...(await notifiche(EV6, "turno_palloni_6h")),
|
||||||
|
...(await notifiche(EV12, "turno_palloni_12h")),
|
||||||
|
];
|
||||||
|
const ids = righe.map((r) => r.id);
|
||||||
|
assert.equal(ids.length, 6);
|
||||||
|
assert.equal((await inCodaPush(ids)).length, 6, "la push la manda il worker");
|
||||||
|
assert.equal((await inCodaEmail(ids)).length, 0, "i palloni non mandano email (DD-040)");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un secondo giro non duplica nulla; eliminata, l'avviso non torna", async () => {
|
||||||
|
const prima = await notifiche(MAIN, "turno_palloni_3h");
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.deepEqual(
|
||||||
|
(await notifiche(MAIN, "turno_palloni_3h")).map((r) => r.id),
|
||||||
|
prima.map((r) => r.id),
|
||||||
|
);
|
||||||
|
const mia = prima.find((r) => r.giocatore_id === G5)!;
|
||||||
|
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4], "g5 non la riavrà");
|
||||||
|
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2, "il registro la ricorda");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"un incaricato non più attivo o un evento con ora illeggibile non avvisano",
|
||||||
|
async () => {
|
||||||
|
await creaEvento(ORA_ROTTA, "allenamento", { data: tra(24 * 60).data, ora: "boh" });
|
||||||
|
await assegna(ORA_ROTTA, G4);
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.equal(
|
||||||
|
(await notifiche(ORA_ROTTA)).length,
|
||||||
|
0,
|
||||||
|
"ora non valida: escluso, senza errori",
|
||||||
|
);
|
||||||
|
|
||||||
|
await scrivi(`turni_palloni?evento_id=eq.${EV12}`, "PATCH", { giocatore_id: INATTIVO });
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.ok(
|
||||||
|
!(await destinatari(EV12, "turno_palloni_12h")).includes(INATTIVO),
|
||||||
|
"chi non è più attivo non riceve nulla",
|
||||||
|
);
|
||||||
|
await cambiaTurno(EV12, G8);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Revoca dell'incarico ---------------------------------------------------------------
|
||||||
|
await prova(
|
||||||
|
"incaricato cambiato: il nuovo riceve l'avviso, il vecchio una sola revoca",
|
||||||
|
async () => {
|
||||||
|
await cambiaTurno(MAIN, G6);
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.deepEqual(
|
||||||
|
await destinatari(MAIN, "turno_palloni_3h"),
|
||||||
|
[G4, G6],
|
||||||
|
"g4 aveva già il suo; g6 è nuovo; g5 aveva eliminato il proprio e non lo riavrà",
|
||||||
|
);
|
||||||
|
const revoche = await notifiche(MAIN, "turno_palloni_revocato");
|
||||||
|
assert.deepEqual(
|
||||||
|
revoche.map((r) => r.giocatore_id),
|
||||||
|
[G4],
|
||||||
|
"solo chi aveva l'incarico e non ce l'ha più; g5 è ancora il turno precedente",
|
||||||
|
);
|
||||||
|
assert.equal(revoche[0]!.titolo, "Turno palloni: incarico revocato");
|
||||||
|
const main = (await eventiDelDatabase()).find((e) => e.id === MAIN)!;
|
||||||
|
assert.equal(
|
||||||
|
revoche[0]!.corpo,
|
||||||
|
[
|
||||||
|
`Evento: ${PREFISSO} main`,
|
||||||
|
`Data: ${gg(main.data)}, ore ${main.ora}`,
|
||||||
|
"Incarico: non più a tuo carico per questo evento",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.equal(
|
||||||
|
(await notifiche(MAIN, "turno_palloni_revocato")).length,
|
||||||
|
1,
|
||||||
|
"la revoca parte una volta sola",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("la revoca ha push in coda e nessuna email", async () => {
|
||||||
|
const [revoca] = await notifiche(MAIN, "turno_palloni_revocato");
|
||||||
|
assert.equal((await inCodaPush([revoca!.id])).length, 1);
|
||||||
|
assert.equal((await inCodaEmail([revoca!.id])).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("riassegnato al vecchio incaricato: nessun secondo avviso", async () => {
|
||||||
|
await cambiaTurno(MAIN, G4);
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.deepEqual(
|
||||||
|
(await notifiche(MAIN)).filter((r) => r.giocatore_id === G4).map((r) => r.tipo),
|
||||||
|
["turno_palloni_3h", "turno_palloni_revocato"],
|
||||||
|
"il registro ha già generato l'avviso di quella fascia: resta la revoca come ultima",
|
||||||
|
);
|
||||||
|
await cambiaTurno(MAIN, G4);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"turno tolto: chi era stato avvisato, incaricato o riconsegna, viene revocato",
|
||||||
|
async () => {
|
||||||
|
// La notifica dell'avviso può essere stata eliminata: conta il registro.
|
||||||
|
const [avvisoG7] = (await notifiche(EV6, "turno_palloni_6h")).filter(
|
||||||
|
(r) => r.giocatore_id === G7,
|
||||||
|
);
|
||||||
|
await scrivi(`notifiche_utente?id=eq.${avvisoG7!.id}`, "DELETE");
|
||||||
|
await scrivi(`turni_palloni?evento_id=eq.${EV6}`, "DELETE");
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
const revocati = await destinatari(EV6, "turno_palloni_revocato");
|
||||||
|
assert.ok(revocati.includes(G7), "g7 aveva eliminato l'avviso: conta il registro");
|
||||||
|
assert.ok(!revocati.includes(G8), "g8 non c'entra con questo evento");
|
||||||
|
// Senza incaricato non parte nulla: nemmeno un nuovo avviso di fascia per nessuno.
|
||||||
|
assert.equal((await notifiche(EV6, "turno_palloni_3h")).length, 0);
|
||||||
|
await assegna(EV6, G7);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("gli avvisi del pulsante (`turno_palloni`) non provocano revoche", async () => {
|
||||||
|
await scrivi("notifiche_utente", "POST", {
|
||||||
|
giocatore_id: G8,
|
||||||
|
tipo: "turno_palloni",
|
||||||
|
titolo: "manuale",
|
||||||
|
corpo: "proposta non confermata",
|
||||||
|
evento_id: MAIN,
|
||||||
|
});
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.ok(
|
||||||
|
!(await destinatari(MAIN, "turno_palloni_revocato")).includes(G8),
|
||||||
|
"la proposta del pulsante può non essere un turno confermato",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un evento già iniziato non genera la revoca", async () => {
|
||||||
|
await scrivi("promemoria_eventi_generati", "POST", {
|
||||||
|
giocatore_id: G4,
|
||||||
|
evento_id: PREC,
|
||||||
|
tipo: "turno_palloni_3h",
|
||||||
|
});
|
||||||
|
await esegui("genera_avvisi_palloni");
|
||||||
|
assert.equal((await notifiche(PREC, "turno_palloni_revocato")).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Stesso ordine del codice: precedente e successivo ---------------------------------
|
||||||
|
await prova("precedente e successivo seguono lo stesso ordine di palloni-core.ts", async () => {
|
||||||
|
const ora = "20:00";
|
||||||
|
const data = tra(24 * 8).data;
|
||||||
|
await creaEvento(COPPIA_B, "partita", { data, ora });
|
||||||
|
await creaEvento(COPPIA_A, "allenamento", { data, ora });
|
||||||
|
await assegna(COPPIA_A, G4);
|
||||||
|
await assegna(COPPIA_B, G5);
|
||||||
|
const eventi = await eventiDelDatabase();
|
||||||
|
const turni = await turniDelDatabase();
|
||||||
|
assert.equal(
|
||||||
|
eventoPrecedente(eventi, COPPIA_B)?.id,
|
||||||
|
COPPIA_A,
|
||||||
|
"a parità di data e ora: per id",
|
||||||
|
);
|
||||||
|
assert.equal(eventoSuccessivo(eventi, COPPIA_A)?.id, COPPIA_B);
|
||||||
|
|
||||||
|
// Solo i due eventi della coppia cadono nella fascia: 8 giorni ± un giorno.
|
||||||
|
await esegui("genera_avvisi_palloni_fascia", {
|
||||||
|
p_tipo: "turno_palloni_12h",
|
||||||
|
p_da: `${24 * 7} hours`,
|
||||||
|
p_a: `${24 * 9} hours`,
|
||||||
|
});
|
||||||
|
for (const evento of [COPPIA_A, COPPIA_B]) {
|
||||||
|
const righe = await notifiche(evento, "turno_palloni_12h");
|
||||||
|
const attesi = avvisiPalloniEvento(turni, eventi, evento);
|
||||||
|
assert.equal(righe.length, attesi.length, evento);
|
||||||
|
for (const a of attesi) {
|
||||||
|
const r = righe.find((x) => x.giocatore_id === a.giocatoreId);
|
||||||
|
assert.ok(r, `${evento}: ${a.giocatoreId}`);
|
||||||
|
assert.equal(r.titolo, a.titolo);
|
||||||
|
assert.equal(r.corpo, a.testo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Solleciti presenze -----------------------------------------------------------------
|
||||||
|
await creaEvento(S24, "evento", tra(18), { convocati: [G4, G5, G7, INATTIVO, ALLENATORE] });
|
||||||
|
await creaEvento(S12, "evento", tra(9));
|
||||||
|
await creaEvento(S6, "evento", tra(4));
|
||||||
|
await creaEvento(S_FUORI, "evento", tra(30));
|
||||||
|
await rispondi(S24, G7, "presente");
|
||||||
|
await rispondi(S24, G5, "forse");
|
||||||
|
await rispondi(S12, G4, "assente");
|
||||||
|
await rispondi(S12, G5, "forse");
|
||||||
|
await rispondi(S6, G4, "ritardo");
|
||||||
|
|
||||||
|
const tuttiGiocatoriAttivi = async () =>
|
||||||
|
(await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&tipo=eq.giocatore&select=id"))
|
||||||
|
.map((g) => g.id)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"ogni evento riceve il sollecito della sua fascia, a chi deve rispondere",
|
||||||
|
async () => {
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.deepEqual(
|
||||||
|
await destinatari(S24, "sollecita_presenze_24h"),
|
||||||
|
[G4, G5],
|
||||||
|
"convocati senza risposta (g4) o «forse» (g5): g7 ha risposto, inattivo e allenatore esclusi",
|
||||||
|
);
|
||||||
|
const senzaRisposta = (await tuttiGiocatoriAttivi()).filter((g) => g !== G4);
|
||||||
|
assert.deepEqual(
|
||||||
|
await destinatari(S12, "sollecita_presenze_12h"),
|
||||||
|
senzaRisposta,
|
||||||
|
"convocati vuoto: tutta la rosa attiva, meno chi ha risposto; g5 («forse») resta",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await destinatari(S6, "sollecita_presenze_6h"),
|
||||||
|
(await tuttiGiocatoriAttivi()).filter((g) => g !== G4),
|
||||||
|
);
|
||||||
|
assert.equal((await notifiche(S_FUORI)).length, 0, "tra 30 ore: fuori da ogni fascia");
|
||||||
|
for (const [evento, altri] of [
|
||||||
|
[S24, ["sollecita_presenze_12h", "sollecita_presenze_6h"]],
|
||||||
|
[S12, ["sollecita_presenze_24h", "sollecita_presenze_6h"]],
|
||||||
|
[S6, ["sollecita_presenze_24h", "sollecita_presenze_12h"]],
|
||||||
|
] as const) {
|
||||||
|
for (const tipo of altri) {
|
||||||
|
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("mai all'allenatore né a chi non è attivo", async () => {
|
||||||
|
for (const evento of [S24, S12, S6]) {
|
||||||
|
const tutti = (await notifiche(evento)).map((r) => r.giocatore_id);
|
||||||
|
assert.ok(!tutti.includes(ALLENATORE), `${evento}: allenatore`);
|
||||||
|
assert.ok(!tutti.includes(INATTIVO), `${evento}: inattivo`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il testo è quello del sollecito, identico al codice", async () => {
|
||||||
|
const eventi = await eventiDelDatabase();
|
||||||
|
const squadra = await leggi<{
|
||||||
|
id: string;
|
||||||
|
attivo: boolean;
|
||||||
|
tipo: "giocatore" | "allenatore";
|
||||||
|
}>("giocatori_squadra?select=id,attivo,tipo");
|
||||||
|
for (const [id, tipo] of [
|
||||||
|
[S24, "sollecita_presenze_24h"],
|
||||||
|
[S12, "sollecita_presenze_12h"],
|
||||||
|
[S6, "sollecita_presenze_6h"],
|
||||||
|
] as const) {
|
||||||
|
const evento = eventi.find((e) => e.id === id)!;
|
||||||
|
const risposte = await leggi<{ giocatore_id: string; stato: string }>(
|
||||||
|
`risposte_presenze?evento_id=eq.${id}&select=giocatore_id,stato`,
|
||||||
|
);
|
||||||
|
const attesi = destinatariSollecito(squadra, risposte, evento.convocati);
|
||||||
|
assert.deepEqual(await destinatari(id, tipo), [...attesi].sort(), `${id}: destinatari`);
|
||||||
|
for (const r of await notifiche(id, tipo)) {
|
||||||
|
const { titolo, testo } = testoSollecito(
|
||||||
|
evento,
|
||||||
|
risposte.find((x) => x.giocatore_id === r.giocatore_id)?.stato,
|
||||||
|
);
|
||||||
|
assert.equal(r.titolo, titolo, `${id} titolo`);
|
||||||
|
assert.equal(r.corpo, testo, `${id} corpo di ${r.giocatore_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [g5] = (await notifiche(S24, "sollecita_presenze_24h")).filter(
|
||||||
|
(r) => r.giocatore_id === G5,
|
||||||
|
);
|
||||||
|
assert.match(g5!.corpo, /Risposta attuale: forse/);
|
||||||
|
assert.ok(!g5!.corpo.includes("Richiesta di"), "l'automatico non ha un mittente");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il sollecito ha push in coda ed email", async () => {
|
||||||
|
const ids = (await notifiche(S24, "sollecita_presenze_24h")).map((r) => r.id);
|
||||||
|
assert.equal((await inCodaPush(ids)).length, ids.length);
|
||||||
|
assert.equal((await inCodaEmail(ids)).length, ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un secondo giro non duplica; eliminato, il sollecito non torna", async () => {
|
||||||
|
const prima = await notifiche(S24, "sollecita_presenze_24h");
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.equal((await notifiche(S24, "sollecita_presenze_24h")).length, prima.length);
|
||||||
|
await scrivi(`notifiche_utente?id=eq.${prima[0]!.id}`, "DELETE");
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.equal((await notifiche(S24, "sollecita_presenze_24h")).length, prima.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"chi risponde smette di riceverli, chi resta su «forse» li riceve tutti e tre",
|
||||||
|
async () => {
|
||||||
|
await creaEvento(S_FORSE, "evento", tra(18), { convocati: [G4, G5, G8] });
|
||||||
|
await rispondi(S_FORSE, G5, "forse");
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_24h"), [G4, G5, G8]);
|
||||||
|
|
||||||
|
await rispondi(S_FORSE, G4, "presente"); // risponde dopo il primo sollecito
|
||||||
|
await spostaEvento(S_FORSE, 9); // entra nella fascia delle 12 ore
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.deepEqual(
|
||||||
|
await destinatari(S_FORSE, "sollecita_presenze_12h"),
|
||||||
|
[G5, G8],
|
||||||
|
"g4 ha risposto: niente secondo sollecito; «forse» e nessuna risposta sì",
|
||||||
|
);
|
||||||
|
|
||||||
|
await rispondi(S_FORSE, G8, "assente");
|
||||||
|
await spostaEvento(S_FORSE, 4);
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_6h"), [G5]);
|
||||||
|
assert.equal(
|
||||||
|
(await notifiche(S_FORSE)).filter((r) => r.giocatore_id === G5).length,
|
||||||
|
3,
|
||||||
|
"g5, sempre «forse»: 24, 12 e 6 ore",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("un evento creato tardi riceve solo il sollecito della sua fascia", async () => {
|
||||||
|
await creaEvento(S_TARDI, "evento", tra(5), { convocati: [G4] });
|
||||||
|
await esegui("genera_solleciti_presenze");
|
||||||
|
assert.deepEqual(
|
||||||
|
(await notifiche(S_TARDI)).map((r) => r.tipo),
|
||||||
|
["sollecita_presenze_6h"],
|
||||||
|
"non tre solleciti insieme",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il sollecito del pulsante e quello automatico convivono", async () => {
|
||||||
|
await scrivi("notifiche_utente", "POST", {
|
||||||
|
giocatore_id: G5,
|
||||||
|
tipo: "sollecita_presenze",
|
||||||
|
titolo: "manuale",
|
||||||
|
corpo: "manuale",
|
||||||
|
evento_id: S24,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
(await notifiche(S24)).filter((r) => r.giocatore_id === G5).map((r) => r.tipo),
|
||||||
|
["sollecita_presenze", "sollecita_presenze_24h"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Schema, permessi, pianificazione ---------------------------------------------------
|
||||||
|
await prova("le funzioni dei job sono riservate alla service role", async () => {
|
||||||
|
for (const nome of [
|
||||||
|
"genera_avvisi_palloni",
|
||||||
|
"genera_solleciti_presenze",
|
||||||
|
"genera_revoche_palloni",
|
||||||
|
]) {
|
||||||
|
assert.ok(!(await rpc(nome, {}, ANON)).ok, `${nome}: anon`);
|
||||||
|
assert.ok(!(await rpc(nome, {}, admin.token)).ok, `${nome}: utente autenticato`);
|
||||||
|
}
|
||||||
|
for (const nome of ["genera_avvisi_palloni_fascia", "genera_solleciti_presenze_fascia"]) {
|
||||||
|
const corpo = { p_tipo: "turno_palloni_3h", p_da: "0", p_a: "1 hour" };
|
||||||
|
assert.ok(!(await rpc(nome, corpo, ANON)).ok, `${nome}: anon`);
|
||||||
|
assert.ok(!(await rpc(nome, corpo, admin.token)).ok, `${nome}: utente autenticato`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il registro accetta solo i tipi automatici", async () => {
|
||||||
|
const res = await rest("promemoria_eventi_generati", SERVIZIO, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ giocatore_id: G4, evento_id: MAIN, tipo: "turno_palloni" }),
|
||||||
|
});
|
||||||
|
assert.ok(!res.ok, "`turno_palloni` è il tipo del pulsante: non entra nel registro");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("ogni tipo nuovo è accettato da notifiche_utente", async () => {
|
||||||
|
for (const tipo of [
|
||||||
|
"turno_palloni_12h",
|
||||||
|
"turno_palloni_6h",
|
||||||
|
"turno_palloni_3h",
|
||||||
|
"turno_palloni_revocato",
|
||||||
|
"sollecita_presenze_24h",
|
||||||
|
"sollecita_presenze_12h",
|
||||||
|
"sollecita_presenze_6h",
|
||||||
|
]) {
|
||||||
|
const res = await rest("notifiche_utente", SERVIZIO, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
giocatore_id: G4,
|
||||||
|
tipo,
|
||||||
|
titolo: "x",
|
||||||
|
corpo: "y",
|
||||||
|
evento_id: LONTANO,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(res.ok, `${tipo}: ${res.status}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("i due job sono pianificati ogni 15 minuti", async () => {
|
||||||
|
const contenitore = spawnSync(
|
||||||
|
"docker",
|
||||||
|
["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
)
|
||||||
|
.stdout.trim()
|
||||||
|
.split("\n")[0];
|
||||||
|
assert.ok(contenitore, "container del database locale non trovato");
|
||||||
|
const esito = spawnSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
contenitore,
|
||||||
|
"psql",
|
||||||
|
"-U",
|
||||||
|
"postgres",
|
||||||
|
"-At",
|
||||||
|
"-F",
|
||||||
|
"|",
|
||||||
|
"-c",
|
||||||
|
"select jobname, schedule, command from cron.job where jobname in " +
|
||||||
|
"('avvisi-palloni-automatici','solleciti-presenze-automatici') order by jobname",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
assert.equal(esito.status, 0, esito.stderr);
|
||||||
|
const righe = esito.stdout.trim().split("\n");
|
||||||
|
assert.equal(righe.length, 2);
|
||||||
|
assert.match(
|
||||||
|
righe[0]!,
|
||||||
|
/^avvisi-palloni-automatici\|\*\/15 \* \* \* \*\|.*genera_avvisi_palloni\(\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
righe[1]!,
|
||||||
|
/^solleciti-presenze-automatici\|\*\/15 \* \* \* \*\|.*genera_solleciti_presenze\(\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("cancellare l'evento toglie registro e notifiche collegate", async () => {
|
||||||
|
assert.ok((await registro(MAIN)).length > 0);
|
||||||
|
await scrivi(`eventi_app?id=eq.${MAIN}`, "DELETE");
|
||||||
|
assert.equal((await registro(MAIN)).length, 0);
|
||||||
|
assert.equal((await notifiche(MAIN)).length, 0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
for (const id of TUTTI) {
|
||||||
|
await rest(`notifiche_utente?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await rest(`promemoria_eventi_generati?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await rest(`turni_palloni?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await rest(`risposte_presenze?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
if (tokenAdmin) {
|
||||||
|
for (const [id, valori] of originali) {
|
||||||
|
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(valori),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of idUtenti) {
|
||||||
|
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
riepilogo("notifiche-automatiche");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,20 +217,39 @@ if (!locale) {
|
|||||||
assert.equal(riga?.inviata_il, null);
|
assert.equal(riga?.inviata_il, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
await prova("ogni tipo di notifica esistente viene accodato", async () => {
|
await prova("ogni tipo di notifica con email viene accodato", async () => {
|
||||||
for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) {
|
for (const tipo of [
|
||||||
|
"evento_promemoria_24h",
|
||||||
|
"evento_promemoria_3h",
|
||||||
|
"sollecita_presenze",
|
||||||
|
"sollecita_presenze_24h",
|
||||||
|
"sollecita_presenze_12h",
|
||||||
|
"sollecita_presenze_6h",
|
||||||
|
"sondaggio_cacche",
|
||||||
|
]) {
|
||||||
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
|
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
|
||||||
creati.push(id);
|
creati.push(id);
|
||||||
assert.equal((await coda(id))?.stato, "in_coda", tipo);
|
assert.equal((await coda(id))?.stato, "in_coda", tipo);
|
||||||
}
|
}
|
||||||
const id = await creaNotifica(SLOT_COLLEGATO, {
|
|
||||||
tipo: "sollecita_presenze",
|
|
||||||
evento_id: EVENTO_TIPI,
|
|
||||||
});
|
|
||||||
creati.push(id);
|
|
||||||
assert.equal((await coda(id))?.stato, "in_coda", "sollecita_presenze");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"il turno palloni non genera email, in nessuna delle sue forme (DD-040)",
|
||||||
|
async () => {
|
||||||
|
for (const tipo of [
|
||||||
|
"turno_palloni",
|
||||||
|
"turno_palloni_12h",
|
||||||
|
"turno_palloni_6h",
|
||||||
|
"turno_palloni_3h",
|
||||||
|
"turno_palloni_revocato",
|
||||||
|
]) {
|
||||||
|
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
|
||||||
|
creati.push(id);
|
||||||
|
assert.equal(await coda(id), undefined, `${tipo}: nessuna riga nella coda email`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await prova("segnare come letta non rimette in coda una mail già inviata", async () => {
|
await prova("segnare come letta non rimette in coda una mail già inviata", async () => {
|
||||||
const id = await creaNotifica(SLOT_COLLEGATO);
|
const id = await creaNotifica(SLOT_COLLEGATO);
|
||||||
creati.push(id);
|
creati.push(id);
|
||||||
@@ -243,7 +262,7 @@ if (!locale) {
|
|||||||
|
|
||||||
await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => {
|
await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => {
|
||||||
const id = await creaNotifica(SLOT_COLLEGATO, {
|
const id = await creaNotifica(SLOT_COLLEGATO, {
|
||||||
tipo: "turno_palloni",
|
tipo: "sollecita_presenze",
|
||||||
evento_id: EVENTO_UPSERT,
|
evento_id: EVENTO_UPSERT,
|
||||||
});
|
});
|
||||||
creati.push(id);
|
creati.push(id);
|
||||||
@@ -251,7 +270,36 @@ if (!locale) {
|
|||||||
await esito(id, "inviata");
|
await esito(id, "inviata");
|
||||||
assert.equal((await coda(id))?.stato, "inviata");
|
assert.equal((await coda(id))?.stato, "inviata");
|
||||||
|
|
||||||
// Come `promemoria-palloni.ts` e `sollecita-presenze.ts`: stesso (giocatore, evento, tipo).
|
// Come `sollecita-presenze.ts`: stesso (giocatore, evento, tipo).
|
||||||
|
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
giocatore_id: SLOT_COLLEGATO,
|
||||||
|
tipo: "sollecita_presenze",
|
||||||
|
titolo: `${PREFISSO} aggiornato`,
|
||||||
|
corpo: "nuovo testo",
|
||||||
|
evento_id: EVENTO_UPSERT,
|
||||||
|
letta: false,
|
||||||
|
creato_il: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(res.ok, `upsert: ${res.status}`);
|
||||||
|
const riga = await coda(id);
|
||||||
|
assert.equal(riga?.stato, "in_coda");
|
||||||
|
assert.equal(riga?.tentativi, 0);
|
||||||
|
assert.equal(riga?.inviata_il, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il rinvio con upsert del turno palloni non accoda nessuna mail", async () => {
|
||||||
|
const id = await creaNotifica(SLOT_COLLEGATO, {
|
||||||
|
tipo: "turno_palloni",
|
||||||
|
evento_id: EVENTO_UPSERT,
|
||||||
|
});
|
||||||
|
creati.push(id);
|
||||||
|
assert.equal(await coda(id), undefined);
|
||||||
|
|
||||||
|
// Come `promemoria-palloni.ts`: stesso (giocatore, evento, tipo), `creato_il` riscritto.
|
||||||
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
|
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
||||||
@@ -266,10 +314,7 @@ if (!locale) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
assert.ok(res.ok, `upsert: ${res.status}`);
|
assert.ok(res.ok, `upsert: ${res.status}`);
|
||||||
const riga = await coda(id);
|
assert.equal(await coda(id), undefined, "nemmeno dopo il rinvio");
|
||||||
assert.equal(riga?.stato, "in_coda");
|
|
||||||
assert.equal(riga?.tentativi, 0);
|
|
||||||
assert.equal(riga?.inviata_il, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prova(
|
await prova(
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ if (!locale) {
|
|||||||
const EVENTO = `${PREFISSO}-a`;
|
const EVENTO = `${PREFISSO}-a`;
|
||||||
const EVENTO_LONTANO = `${PREFISSO}-lontano`;
|
const EVENTO_LONTANO = `${PREFISSO}-lontano`;
|
||||||
const EVENTO_CASCATA = `${PREFISSO}-cascata`;
|
const EVENTO_CASCATA = `${PREFISSO}-cascata`;
|
||||||
|
const EVENTO_DOMANI = `${PREFISSO}-domani`;
|
||||||
|
const EVENTO_OGGI = `${PREFISSO}-oggi`;
|
||||||
|
const EVENTO_SENZA_LUOGO = `${PREFISSO}-senza-luogo`;
|
||||||
const G4 = "g4";
|
const G4 = "g4";
|
||||||
const G5 = "g5";
|
const G5 = "g5";
|
||||||
const G6 = "g6";
|
const G6 = "g6";
|
||||||
@@ -201,6 +204,99 @@ if (!locale) {
|
|||||||
assert.ok(!inserimento.ok, "anon non può scrivere nel registro");
|
assert.ok(!inserimento.ok, "anon non può scrivere nel registro");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Testo (DD-040) ------------------------------------------------------------------
|
||||||
|
// «domani» se la data dell'evento è quella di domani a Roma, «oggi» altrimenti. Per non
|
||||||
|
// dipendere dall'ora in cui gira il test: un evento domani a mezzanotte cade sempre nella
|
||||||
|
// finestra delle 24 ore, uno oggi alle 23:59 pure.
|
||||||
|
const oggiRoma = tra(0).data;
|
||||||
|
const domaniRoma = new Date(new Date(`${oggiRoma}T12:00:00Z`).getTime() + 24 * 3600 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
const gg = (iso: string) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
|
||||||
|
const testoDi = async (evento: string, tipo: string) => {
|
||||||
|
const [n] = await leggi<{ titolo: string; corpo: string }>(
|
||||||
|
`notifiche_utente?evento_id=eq.${evento}&tipo=eq.${tipo}&giocatore_id=eq.${G4}&select=titolo,corpo`,
|
||||||
|
);
|
||||||
|
assert.ok(n, `promemoria ${tipo} di ${evento} non generato`);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prova("evento di domani: titolo e data dicono «domani», con ora e luogo", async () => {
|
||||||
|
await scrivi("eventi_app", "POST", {
|
||||||
|
id: EVENTO_DOMANI,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: `${PREFISSO} cena`,
|
||||||
|
data: domaniRoma,
|
||||||
|
ora: "00:00",
|
||||||
|
luogo: " PalaCRAP ",
|
||||||
|
convocati: [G4],
|
||||||
|
});
|
||||||
|
await cron("evento_promemoria_24h");
|
||||||
|
const n = await testoDi(EVENTO_DOMANI, "evento_promemoria_24h");
|
||||||
|
assert.equal(n.titolo, `Promemoria evento di domani: ${PREFISSO} cena`);
|
||||||
|
assert.equal(
|
||||||
|
n.corpo,
|
||||||
|
`Data: domani, ${gg(domaniRoma)}\nOra: 00:00\nLuogo: PalaCRAP`,
|
||||||
|
"il luogo è ripulito dagli spazi",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("evento di oggi: titolo e data dicono «oggi»", async () => {
|
||||||
|
await scrivi("eventi_app", "POST", {
|
||||||
|
id: EVENTO_OGGI,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: `${PREFISSO} oggi`,
|
||||||
|
data: oggiRoma,
|
||||||
|
ora: "23:59",
|
||||||
|
luogo: "Palestra",
|
||||||
|
convocati: [G4],
|
||||||
|
});
|
||||||
|
await cron("evento_promemoria_24h");
|
||||||
|
const n = await testoDi(EVENTO_OGGI, "evento_promemoria_24h");
|
||||||
|
assert.equal(n.titolo, `Promemoria evento di oggi: ${PREFISSO} oggi`);
|
||||||
|
assert.equal(n.corpo, `Data: oggi, ${gg(oggiRoma)}\nOra: 23:59\nLuogo: Palestra`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("lo stesso testo vale per il promemoria a 3 ore", async () => {
|
||||||
|
const { data, ora } = tra(2);
|
||||||
|
await scrivi("eventi_app", "POST", {
|
||||||
|
id: EVENTO_SENZA_LUOGO,
|
||||||
|
tipo: "evento",
|
||||||
|
titolo: `${PREFISSO} senza luogo`,
|
||||||
|
data,
|
||||||
|
ora,
|
||||||
|
convocati: [G4],
|
||||||
|
});
|
||||||
|
await cron("evento_promemoria_3h");
|
||||||
|
const n = await testoDi(EVENTO_SENZA_LUOGO, "evento_promemoria_3h");
|
||||||
|
const parola = data === domaniRoma ? "domani" : "oggi";
|
||||||
|
assert.equal(
|
||||||
|
n.titolo,
|
||||||
|
`Promemoria evento di ${parola}: ${PREFISSO} senza luogo`,
|
||||||
|
"anche il promemoria a 3 ore di un evento dopo mezzanotte dice «domani»",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
n.corpo,
|
||||||
|
`Data: ${parola}, ${gg(data)}\nOra: ${ora}`,
|
||||||
|
"senza luogo la riga «Luogo» manca",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"il promemoria nuovo parte come push e come email, con lo stesso testo",
|
||||||
|
async () => {
|
||||||
|
const [n] = await leggi<{ id: string }>(
|
||||||
|
`notifiche_utente?evento_id=eq.${EVENTO_DOMANI}&tipo=eq.evento_promemoria_24h&giocatore_id=eq.${G4}&select=id`,
|
||||||
|
);
|
||||||
|
assert.ok(n);
|
||||||
|
assert.equal((await inCoda([n.id])).length, 1, "email in coda");
|
||||||
|
const push = await leggi<{ notifica_id: string }>(
|
||||||
|
`notifiche_push_coda?notifica_id=eq.${n.id}&select=notifica_id`,
|
||||||
|
);
|
||||||
|
assert.equal(push.length, 1, "push in coda");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await prova("cancellare l'evento toglie anche il registro", async () => {
|
await prova("cancellare l'evento toglie anche il registro", async () => {
|
||||||
const { data, ora } = { data: tra(2).data, ora: tra(2).ora };
|
const { data, ora } = { data: tra(2).data, ora: tra(2).ora };
|
||||||
await scrivi("eventi_app", "POST", {
|
await scrivi("eventi_app", "POST", {
|
||||||
@@ -217,7 +313,14 @@ if (!locale) {
|
|||||||
assert.equal((await registro(EVENTO_CASCATA)).length, 0);
|
assert.equal((await registro(EVENTO_CASCATA)).length, 0);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
for (const id of [EVENTO, EVENTO_LONTANO, EVENTO_CASCATA]) {
|
for (const id of [
|
||||||
|
EVENTO,
|
||||||
|
EVENTO_LONTANO,
|
||||||
|
EVENTO_CASCATA,
|
||||||
|
EVENTO_DOMANI,
|
||||||
|
EVENTO_OGGI,
|
||||||
|
EVENTO_SENZA_LUOGO,
|
||||||
|
]) {
|
||||||
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
riepilogo("promemoria-eventi");
|
riepilogo("promemoria-eventi");
|
||||||
|
|||||||
@@ -217,6 +217,31 @@ if (!locale) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"le notifiche automatiche di palloni e solleciti (DD-040) vanno in coda push; i pulsanti no",
|
||||||
|
async () => {
|
||||||
|
const automatiche = [
|
||||||
|
"turno_palloni_12h",
|
||||||
|
"turno_palloni_6h",
|
||||||
|
"turno_palloni_3h",
|
||||||
|
"turno_palloni_revocato",
|
||||||
|
"sollecita_presenze_24h",
|
||||||
|
"sollecita_presenze_12h",
|
||||||
|
"sollecita_presenze_6h",
|
||||||
|
];
|
||||||
|
for (const tipo of automatiche) {
|
||||||
|
const riga = await coda(await creaNotifica(G4, tipo, EVENTI[2]));
|
||||||
|
assert.equal(riga?.stato, "in_coda", tipo);
|
||||||
|
assert.equal(riga?.tentativi, 0, tipo);
|
||||||
|
}
|
||||||
|
// `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti: la push la manda
|
||||||
|
// la route, e una seconda dal worker sarebbe un doppione.
|
||||||
|
for (const tipo of ["turno_palloni", "sollecita_presenze"]) {
|
||||||
|
assert.equal(await coda(await creaNotifica(G4, tipo, EVENTI[2])), undefined, tipo);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await prova(
|
await prova(
|
||||||
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
|
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user