Notifica di compleanno: auguri al festeggiato, avviso agli altri (DD-047)

Ogni giorno, dalle 8:00 (Europe/Rome), un job pg_cron orario manda a chi compie
gli anni «Buon compleanno, <Nome>! 🎂» e a tutti gli altri membri attivi,
allenatori compresi, «Oggi è il compleanno di <Nome>» (con più compleanni lo
stesso giorno un solo messaggio a testa, nomi in ordine alfabetico). Solo
titolo, senza corpo né età. Canali: push, centro notifiche ed email.

- migration m31: tipi `compleanno` e `compleanno_auguri`, registro
  `compleanni_notificati` (una sola volta per destinatario e giorno, anche se la
  notifica viene eliminata), funzione `genera_avvisi_compleanno()` e job
  `avvisi-compleanno`, trigger push esteso ai nuovi tipi. Non ancora applicata
  in produzione.
- 29 febbraio non gestito: chi è nato quel giorno è avvisato solo negli anni
  bisestili (limite noto, documentato).
- test: integrazione `notifiche-compleanno` (ora e ora legale, destinatari,
  più compleanni, registro, coda push/email, 29 febbraio, job pianificato);
  unit sui nuovi tipi e sul corpo vuoto del service worker.
- documentazione: DD-047, catalogo e testi delle notifiche, DATABASE, CHANGELOG,
  ROADMAP, PROJECT_STATE.

Chiude la issue #11.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-06 10:18:22 +02:00
1 parent 7347fdba6e
commit 985fc2fcc1
15 files changed
+706 -43

No files matched your search

@@ -0,0 +1,399 @@
/**
* Notifica di compleanno (M31, DD-047, issue #11): `bun test/integration/notifiche-compleanno.test.ts`.
*
* Esegue il job vero del database (`genera_avvisi_compleanno`) simulando date diverse con il
* parametro `p_adesso`, e verifica:
*
* - l'ora: prima delle 8:00 (fuso Europe/Rome, anche con l'ora legale) non parte nulla;
* - un compleanno: auguri al festeggiato, avviso con il solo nome a tutti gli altri membri attivi
* (allenatore compreso), niente a chi non è attivo;
* - più compleanni lo stesso giorno: un solo messaggio per destinatario, con i nomi in ordine
* alfabetico («A e B», «A, B e C»); ogni festeggiato riceve solo i propri auguri;
* - una sola generazione per destinatario e giorno, anche se la notifica viene eliminata;
* - il canale: push ed email in coda;
* - il 29 febbraio: avvisato solo negli anni bisestili (limite noto, DD-047);
* - che il job sia pianificato ogni ora.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4`, `g5`, `g6`, `g9` (inattivo) e
* `g10` (allenatore) della rosa seed, ripristinati alla fine, e date simulate nel 2027/2028 in cui
* nessun altro slot compie gli anni (lo verifica il test stesso).
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche di compleanno", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-compleanno");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`notifiche di compleanno su ${URL_BASE}`);
const PASSWORD = "prova-notifiche-compleanno-123";
const G4 = "g4";
const G5 = "g5";
const G6 = "g6";
const INATTIVO = "g9";
const ALLENATORE = "g10";
const TIPI = "compleanno,compleanno_auguri";
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()}`);
}
/** Il job, come se «adesso» fosse `quando` (istante UTC). */
async function gira(quando: string) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ p_adesso: quando }),
});
if (!res.ok) throw new Error(`genera_avvisi_compleanno: ${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 };
}
type Slot = { id: string; nome: string; nascita: string | null; attivo: boolean };
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
const slot = () => leggi<Slot>("giocatori_squadra?select=id,nome,nascita,attivo&order=id");
const notifiche = () =>
leggi<Riga>(
`notifiche_utente?tipo=in.(${TIPI})&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id`,
);
const registro = (giorno: string) =>
leggi<{ giocatore_id: string }>(
`compleanni_notificati?giorno=eq.${giorno}&select=giocatore_id&order=giocatore_id`,
);
const impostaNascita = (id: string, nascita: string | null) =>
scrivi(`giocatori_squadra?id=eq.${id}`, "PATCH", { nascita }, tokenAdmin);
const svuota = async () => {
await rest(`notifiche_utente?tipo=in.(${TIPI})`, SERVIZIO, { method: "DELETE" });
await rest("compleanni_notificati?giorno=gte.2000-01-01", SERVIZIO, { method: "DELETE" });
};
/** Chi compie gli anni nel giorno `iso` secondo il database: attivo e con lo stesso giorno e mese. */
const festeggiatiDi = async (iso: string) =>
(await slot())
.filter((s) => s.attivo && s.nascita?.slice(5) === iso.slice(5))
.map((s) => s.id)
.sort();
const attivi = async () =>
(await slot())
.filter((s) => s.attivo)
.map((s) => s.id)
.sort();
let tokenAdmin = "";
const idUtenti: string[] = [];
const originali = new Map<string, Record<string, unknown>>();
// Orari UTC corrispondenti a 07:30 e 08:30 a Roma: l'inverno è UTC+1, l'estate UTC+2.
const INVERNO = { prima: "T06:30:00Z", dopo: "T07:30:00Z" };
const ESTATE = { prima: "T05:30:00Z", dopo: "T06:30:00Z" };
try {
const admin = await creaUtente(`test-compleanno-admin-${Date.now()}@example.test`);
const allenatore = await creaUtente(`test-compleanno-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 [G4, G5, G6, INATTIVO, ALLENATORE]) {
const [riga] = await leggi<Record<string, unknown>>(
`giocatori_squadra?id=eq.${id}&select=nascita,tipo,attivo,auth_user_id`,
);
originali.set(id, riga ?? { nascita: null, tipo: "giocatore", attivo: true });
}
// 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);
// Nessuno dei cinque slot di prova parte con una nascita che possa confondere i casi.
for (const id of [G4, G5, G6, INATTIVO]) await impostaNascita(id, null);
await svuota();
const tutti = await slot();
const nome = (id: string) => tutti.find((s) => s.id === id)!.nome;
const attiviIds = await attivi();
assert.ok(attiviIds.includes(ALLENATORE), "l'allenatore è attivo");
assert.ok(!attiviIds.includes(INATTIVO), "g9 è inattivo");
// --- Un compleanno ---------------------------------------------------------------------
// 15 marzo 2027, orario invernale: 07:30 a Roma è prima, 08:30 è dopo.
const GIORNO1 = "2027-03-15";
await impostaNascita(G4, "1995-03-15");
await prova("prima delle 8:00 a Roma non parte nulla, il registro resta vuoto", async () => {
assert.deepEqual(await festeggiatiDi(GIORNO1), [G4], "solo g4 compie gli anni quel giorno");
await gira(`${GIORNO1}${INVERNO.prima}`);
assert.equal((await notifiche()).length, 0);
assert.equal((await registro(GIORNO1)).length, 0);
});
await prova(
"dalle 8:00: auguri al festeggiato, avviso con il solo nome a tutti gli altri",
async () => {
await gira(`${GIORNO1}${INVERNO.dopo}`);
const righe = await notifiche();
const mie = righe.filter((r) => r.giocatore_id === G4);
assert.equal(mie.length, 1);
assert.equal(mie[0]!.tipo, "compleanno_auguri");
assert.equal(mie[0]!.titolo, `Buon compleanno, ${nome(G4)}! 🎂`);
assert.equal(mie[0]!.corpo, "", "solo il titolo");
const altri = righe.filter((r) => r.giocatore_id !== G4);
assert.deepEqual(
altri.map((r) => r.giocatore_id).sort(),
attiviIds.filter((id) => id !== G4),
"tutti gli altri membri attivi, allenatore compreso, e nessun inattivo",
);
for (const r of altri) {
assert.equal(r.tipo, "compleanno");
assert.equal(r.titolo, `Oggi è il compleanno di ${nome(G4)}`);
assert.equal(r.corpo, "", "solo il titolo, niente età");
}
assert.equal(righe.length, attiviIds.length, "una notifica a testa");
assert.deepEqual(
(await registro(GIORNO1)).map((r) => r.giocatore_id),
attiviIds,
);
},
);
await prova("l'allenatore riceve l'avviso, l'inattivo niente", async () => {
const righe = await notifiche();
assert.equal(righe.filter((r) => r.giocatore_id === ALLENATORE).length, 1);
assert.equal(righe.filter((r) => r.giocatore_id === INATTIVO).length, 0);
});
await prova("push ed email vanno in coda per ogni notifica", async () => {
const ids = (await notifiche()).map((r) => r.id);
const mail = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
assert.equal(mail.length, ids.length, "una mail per notifica");
assert.equal(push.length, ids.length, "una push per notifica");
});
await prova("un secondo giro lo stesso giorno non genera doppioni", async () => {
await gira(`${GIORNO1}T09:30:00Z`);
assert.equal((await notifiche()).length, attiviIds.length);
});
await prova("eliminare la notifica non la fa tornare", async () => {
const [prima] = await notifiche();
await scrivi(`notifiche_utente?id=eq.${prima!.id}`, "DELETE");
await gira(`${GIORNO1}T10:30:00Z`);
assert.equal((await notifiche()).length, attiviIds.length - 1);
});
await prova("un giorno senza compleanni non genera nulla, né registro", async () => {
await svuota();
const giorno = "2027-03-20";
assert.deepEqual(await festeggiatiDi(giorno), [], "nessuno compie gli anni il 20 marzo");
await gira(`${giorno}${INVERNO.dopo}`);
assert.equal((await notifiche()).length, 0);
assert.equal((await registro(giorno)).length, 0);
});
// --- Ora legale ------------------------------------------------------------------------
await prova("con l'ora legale le 8:00 di Roma sono le 6:00 UTC", async () => {
await svuota();
const giorno = "2027-07-15";
await impostaNascita(G4, "1995-07-15");
assert.deepEqual(await festeggiatiDi(giorno), [G4]);
await gira(`${giorno}${ESTATE.prima}`); // 07:30 a Roma
assert.equal((await notifiche()).length, 0, "alle 7:30 a Roma non parte ancora");
await gira(`${giorno}${ESTATE.dopo}`); // 08:30 a Roma
assert.equal((await notifiche()).length, attiviIds.length);
});
// --- Più compleanni lo stesso giorno ---------------------------------------------------
await prova(
"due compleanni: un solo messaggio a testa, nomi in ordine alfabetico",
async () => {
await svuota();
const giorno = "2027-03-16";
await impostaNascita(G4, "1995-03-16");
await impostaNascita(G5, "1990-03-16");
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5]);
await gira(`${giorno}${INVERNO.dopo}`);
const righe = await notifiche();
assert.equal(righe.length, attiviIds.length, "un solo messaggio per destinatario");
const nomi = [nome(G4), nome(G5)].sort((a, b) => a.localeCompare(b, "it"));
for (const id of [G4, G5]) {
const r = righe.find((x) => x.giocatore_id === id)!;
assert.equal(r.tipo, "compleanno_auguri");
assert.equal(r.titolo, `Buon compleanno, ${nome(id)}! 🎂`);
assert.equal(r.corpo, "", "solo il titolo");
assert.ok(!r.titolo.includes(nome(id === G4 ? G5 : G4)), "niente nomi degli altri");
}
const altri = righe.filter((r) => ![G4, G5].includes(r.giocatore_id));
assert.equal(altri.length, attiviIds.length - 2);
for (const r of altri) {
assert.equal(r.tipo, "compleanno");
assert.equal(r.titolo, `Oggi è il compleanno di ${nomi[0]} e ${nomi[1]}`);
assert.equal(r.corpo, "", "solo il titolo");
}
},
);
await prova("tre compleanni: «A, B e C»", async () => {
await svuota();
const giorno = "2027-03-17";
await impostaNascita(G4, "1995-03-17");
await impostaNascita(G5, "1990-03-17");
await impostaNascita(G6, "2000-03-17");
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5, G6]);
await gira(`${giorno}${INVERNO.dopo}`);
const nomi = [nome(G4), nome(G5), nome(G6)].sort((a, b) => a.localeCompare(b, "it"));
const altro = (await notifiche()).find((r) => ![G4, G5, G6].includes(r.giocatore_id))!;
assert.equal(altro.titolo, `Oggi è il compleanno di ${nomi[0]}, ${nomi[1]} e ${nomi[2]}`);
assert.equal(altro.corpo, "", "solo il titolo");
});
await prova("un festeggiato inattivo non conta: né avviso né auguri", async () => {
await svuota();
const giorno = "2027-03-18";
for (const id of [G4, G5, G6]) await impostaNascita(id, null);
await impostaNascita(INATTIVO, "2003-03-18");
assert.deepEqual(await festeggiatiDi(giorno), [], "g9 è inattivo");
await gira(`${giorno}${INVERNO.dopo}`);
assert.equal((await notifiche()).length, 0);
});
// --- 29 febbraio: limite noto (DD-047) -------------------------------------------------
await prova("chi è nato il 29 febbraio è avvisato solo negli anni bisestili", async () => {
await svuota();
await impostaNascita(INATTIVO, null);
await impostaNascita(G4, "2000-02-29");
// 2027 non è bisestile: né il 28 febbraio né il 1° marzo.
await gira(`2027-02-28${INVERNO.dopo}`);
await gira(`2027-03-01${INVERNO.dopo}`);
assert.equal((await notifiche()).length, 0, "negli anni normali non parte nulla");
// 2028 è bisestile: il 29 febbraio sì.
assert.deepEqual(await festeggiatiDi("2028-02-29"), [G4]);
await gira(`2028-02-29${INVERNO.dopo}`);
const righe = await notifiche();
assert.equal(righe.length, attiviIds.length);
assert.equal(righe.find((r) => r.giocatore_id === G4)!.tipo, "compleanno_auguri");
});
// --- Pianificazione --------------------------------------------------------------------
await prova("il job è pianificato ogni ora", 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",
"-c",
"select schedule from cron.job where jobname = 'avvisi-compleanno'",
],
{ encoding: "utf8" },
);
assert.equal(esito.status, 0, esito.stderr);
assert.equal(esito.stdout.trim(), "0 * * * *");
});
await prova("il job non è chiamabile dai client", async () => {
for (const token of [ANON, admin.token]) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
method: "POST",
headers: {
apikey: ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: "{}",
});
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterlo eseguire`);
}
});
} finally {
await svuota();
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-compleanno");
}
}