diff --git a/supabase/migrations/20260930120000_m26_notifiche_automatiche.sql b/supabase/migrations/20260930120000_m26_notifiche_automatiche.sql new file mode 100644 index 0000000..7a2ccbc --- /dev/null +++ b/supabase/migrations/20260930120000_m26_notifiche_automatiche.sql @@ -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: », 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; +$$; diff --git a/test/integration/notifiche-automatiche.test.ts b/test/integration/notifiche-automatiche.test.ts new file mode 100644 index 0000000..c7b2245 --- /dev/null +++ b/test/integration/notifiche-automatiche.test.ts @@ -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(percorso: string): Promise { + 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( + `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 => + (await leggi("eventi_app?select=id,tipo,titolo,luogo,data,ora,convocati")).map( + (e) => ({ ...e, note: "", campionato: false, casa: true, pagelleChiuse: false }), + ); + const turniDelDatabase = async (): Promise> => + 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 = {}, + ) => + 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>(); + + 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>( + `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"); + } +} diff --git a/test/integration/notifiche-email.test.ts b/test/integration/notifiche-email.test.ts index 8a7861a..9c330be 100644 --- a/test/integration/notifiche-email.test.ts +++ b/test/integration/notifiche-email.test.ts @@ -217,20 +217,39 @@ if (!locale) { assert.equal(riga?.inviata_il, null); }); - await prova("ogni tipo di notifica esistente viene accodato", async () => { - for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) { + await prova("ogni tipo di notifica con email viene accodato", async () => { + 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 }); creati.push(id); 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 () => { const id = await creaNotifica(SLOT_COLLEGATO); creati.push(id); @@ -243,7 +262,7 @@ if (!locale) { await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => { const id = await creaNotifica(SLOT_COLLEGATO, { - tipo: "turno_palloni", + tipo: "sollecita_presenze", evento_id: EVENTO_UPSERT, }); creati.push(id); @@ -251,7 +270,36 @@ if (!locale) { await esito(id, "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, { method: "POST", headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, @@ -266,10 +314,7 @@ if (!locale) { }), }); 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); + assert.equal(await coda(id), undefined, "nemmeno dopo il rinvio"); }); await prova( diff --git a/test/integration/promemoria-eventi.test.ts b/test/integration/promemoria-eventi.test.ts index 2346661..7fcdb29 100644 --- a/test/integration/promemoria-eventi.test.ts +++ b/test/integration/promemoria-eventi.test.ts @@ -27,6 +27,9 @@ if (!locale) { const EVENTO = `${PREFISSO}-a`; const EVENTO_LONTANO = `${PREFISSO}-lontano`; 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 G5 = "g5"; const G6 = "g6"; @@ -201,6 +204,99 @@ if (!locale) { 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 () => { const { data, ora } = { data: tra(2).data, ora: tra(2).ora }; await scrivi("eventi_app", "POST", { @@ -217,7 +313,14 @@ if (!locale) { assert.equal((await registro(EVENTO_CASCATA)).length, 0); }); } 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" }); } riepilogo("promemoria-eventi"); diff --git a/test/integration/push-promemoria.test.ts b/test/integration/push-promemoria.test.ts index 19894b0..57f0bae 100644 --- a/test/integration/push-promemoria.test.ts +++ b/test/integration/push-promemoria.test.ts @@ -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( "il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22", async () => {