Promemoria senza ritorni e tre canali per ogni notifica (DD-037, DD-038)

m23: un registro (promemoria_eventi_generati) ricorda i promemoria a 24 e 3 ore
già generati, così eliminare la notifica non li fa rigenerare dal cron né
riaccodare la mail a ogni giro.

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 14:19:54 +02:00
1 parent 58eba642f7
commit e9013b703b
27 files changed
+2076 -92

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
node_modules
.env
.env.*
!.env.example
README.md
+6
View File
@@ -8,6 +8,12 @@ SUPABASE_SERVICE_ROLE_KEY=
SMTP_USER=crapp.notifiche@gmail.com
SMTP_PASS=
# Chiavi VAPID per la push dei promemoria (M24): le stesse VAPID_* dell'app su Vercel, altrimenti i
# dispositivi rifiutano la push. Vanno impostate insieme; se mancano il worker manda solo le email.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:tuo@indirizzo
# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail
APP_URL=https://<dominio-dell-app>
+7 -3
View File
@@ -1,7 +1,11 @@
# Contesto di build: la radice del repository (vedi docker-compose.yml e Dockerfile.dockerignore).
# Il layout dentro l'immagine rispecchia quello del repository, così mailer.mjs importa
# ../src/lib/webpush.server.ts allo stesso modo in locale e nel container.
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock bunfig.toml ./
WORKDIR /app/mailer
COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
RUN bun install --frozen-lockfile --production
COPY mailer.mjs mailer-core.ts healthcheck.mjs ./
COPY mailer/mailer.mjs mailer/mailer-core.ts mailer/healthcheck.mjs ./
COPY src/lib/webpush.server.ts /app/src/lib/webpush.server.ts
USER bun
CMD ["bun", "mailer.mjs"]
+11
View File
@@ -0,0 +1,11 @@
# Il contesto di build è la radice del repository (serve src/lib/webpush.server.ts, lo stesso modulo
# push dell'app): si esclude tutto e si ammettono solo i file che l'immagine usa. Così non finiscono
# nel contesto né il .env né il resto del progetto.
*
!mailer/package.json
!mailer/bun.lock
!mailer/bunfig.toml
!mailer/mailer.mjs
!mailer/mailer-core.ts
!mailer/healthcheck.mjs
!src/lib/webpush.server.ts
+3 -1
View File
@@ -1,6 +1,8 @@
services:
mailer:
build: .
build:
context: ..
dockerfile: mailer/Dockerfile
restart: unless-stopped
env_file: .env
healthcheck:
+145
View File
@@ -39,6 +39,8 @@ export type Config = {
from: string;
fromName: string;
appUrl: string;
/** Chiavi VAPID per la push dei promemoria (M24); `null` = solo email. */
vapid: { publicKey: string; privateKey: string; subject: string | null } | null;
pollSecondi: number;
limiteGiorno: number;
lotto: number;
@@ -78,6 +80,11 @@ export function leggiConfig(env: Record<string, string | undefined>): LetturaCon
if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme");
const from = testo("MAIL_FROM") || user;
if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)");
const vapidPubblica = testo("VAPID_PUBLIC_KEY");
const vapidPrivata = testo("VAPID_PRIVATE_KEY");
if (Boolean(vapidPubblica) !== Boolean(vapidPrivata)) {
errori.push("VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme");
}
const porta = intero("SMTP_PORT", 587);
const pollSecondi = intero("POLL_SECONDI", 30);
const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400);
@@ -97,6 +104,14 @@ export function leggiConfig(env: Record<string, string | undefined>): LetturaCon
from,
fromName: testo("MAIL_FROM_NAME") || "CrAPP",
appUrl,
vapid:
vapidPubblica && vapidPrivata
? {
publicKey: vapidPubblica,
privateKey: vapidPrivata,
subject: testo("VAPID_SUBJECT") || null,
}
: null,
pollSecondi,
limiteGiorno,
lotto,
@@ -334,3 +349,133 @@ export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise<Risu
}
return risultato;
}
// --- Push dei promemoria (M24) ----------------------------------------------------------------
/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
/** Riga restituita da `prendi_push_promemoria()`. */
export type PushDaInviare = {
id_notifica: string;
oggetto: string;
testo: string;
tentativi_fatti: number;
iscrizioni: IscrizionePush[];
};
export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata";
export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente";
/**
* Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410
* iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403,
* 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido.
*/
export function classificaStatoPush(stato: number): EsitoStatoPush {
if (stato >= 200 && stato < 300) return "ok";
if (stato === 404 || stato === 410) return "scaduta";
if (stato === 429 || stato >= 500) return "temporaneo";
return "permanente";
}
export type DipendenzePush = {
prendi: (max: number) => Promise<PushDaInviare[]>;
segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise<void>;
invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>;
elimina: (endpoint: string) => Promise<void>;
adesso: () => Date;
log: (messaggio: string) => void;
};
export type RisultatoCicloPush = {
inviate: number;
riprovate: number;
fallite: number;
saltate: number;
iscrizioniRimosse: number;
};
/**
* Un giro delle push dei promemoria: prende un lotto e manda ogni notifica a tutti i dispositivi
* del giocatore. L'esito è uno solo per notifica: `inviata` se almeno un dispositivo ha accettato;
* altrimenti `riprova` (con backoff, poi `fallita`) se qualcuno ha dato un errore temporaneo o è
* fallita la rete; `fallita` se c'è stato solo un rifiuto permanente; `saltata` se le iscrizioni
* erano tutte scadute. I log riportano solo id e numeri: mai gli endpoint, che identificano il
* dispositivo.
*/
export async function elaboraCicloPush(
d: DipendenzePush,
opzioni: { lotto: number },
): Promise<RisultatoCicloPush> {
const risultato: RisultatoCicloPush = {
inviate: 0,
riprovate: 0,
fallite: 0,
saltate: 0,
iscrizioniRimosse: 0,
};
for (const push of await d.prendi(opzioni.lotto)) {
let accettate = 0;
let temporanei = 0;
let permanenti = 0;
const codici: string[] = [];
for (const iscrizione of push.iscrizioni) {
let stato: number | null = null;
try {
stato = (await d.invia(iscrizione, push.oggetto, push.testo)).stato;
} catch {
temporanei += 1;
codici.push("rete");
continue;
}
const tipo = classificaStatoPush(stato);
if (tipo === "ok") accettate += 1;
else {
codici.push(String(stato));
if (tipo === "temporaneo") temporanei += 1;
else if (tipo === "permanente") permanenti += 1;
else {
try {
await d.elimina(iscrizione.endpoint);
risultato.iscrizioniRimosse += 1;
} catch {
d.log(`iscrizione scaduta non eliminata (notifica ${push.id_notifica})`);
}
}
}
}
const motivo = codici.join(",") || undefined;
if (accettate > 0) {
await d.segna(push.id_notifica, "inviata");
risultato.inviate += 1;
d.log(
`push inviata ${push.id_notifica} (${accettate}/${push.iscrizioni.length} dispositivi)`,
);
} else if (temporanei > 0) {
const ritardo = ritardoRiprova(push.tentativi_fatti);
if (ritardo === null) {
await d.segna(push.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`push fallita ${push.id_notifica} (tentativi finiti, ${motivo})`);
} else {
await d.segna(push.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
risultato.riprovate += 1;
d.log(`push da riprovare ${push.id_notifica} tra ${ritardo} min (${motivo})`);
}
} else if (permanenti > 0) {
await d.segna(push.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`push fallita ${push.id_notifica} (${motivo})`);
} else {
await d.segna(push.id_notifica, "saltata", motivo);
risultato.saltate += 1;
d.log(`push saltata ${push.id_notifica} (nessun dispositivo utile)`);
}
}
return risultato;
}
+50 -4
View File
@@ -1,9 +1,11 @@
// Worker delle notifiche email (M22, DD-036): legge la coda su Supabase e invia via SMTP.
// Solo chiamate in uscita, nessuna porta da aprire. Uso: bun mailer/mailer.mjs
// La logica sta in mailer-core.ts; qui c'è solo la colla con rete e SMTP.
// Worker delle notifiche (M22 email, M24 push dei promemoria; DD-036, DD-038): legge le code su
// Supabase e invia via SMTP e via Web Push. Solo chiamate in uscita, nessuna porta da aprire.
// Uso: bun mailer/mailer.mjs. La logica sta in mailer-core.ts; qui c'è solo la colla con rete,
// SMTP e il modulo push dell'app (src/lib/webpush.server.ts, lo stesso usato dalle route).
import nodemailer from "nodemailer";
import { writeFile } from "node:fs/promises";
import { elaboraCiclo, leggiConfig } from "./mailer-core.ts";
import { inviaPush } from "../src/lib/webpush.server.ts";
import { elaboraCiclo, elaboraCicloPush, leggiConfig } from "./mailer-core.ts";
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
@@ -32,6 +34,22 @@ async function rpc(nome, corpo) {
return testo ? JSON.parse(testo) : null;
}
/** Elimina un'iscrizione push scaduta (il servizio push ha risposto 404 o 410). */
async function eliminaIscrizione(endpoint) {
const risposta = await fetch(
`${config.supabaseUrl}/rest/v1/push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
{
method: "DELETE",
headers: {
apikey: config.serviceRoleKey,
Authorization: `Bearer ${config.serviceRoleKey}`,
},
signal: AbortSignal.timeout(15_000),
},
);
if (!risposta.ok) throw new Error(`delete push_subscriptions: HTTP ${risposta.status}`);
}
const trasporto = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
@@ -64,6 +82,21 @@ const dipendenze = {
log,
};
const dipendenzePush = {
prendi: (max) => rpc("prendi_push_promemoria", { p_max: max }),
segna: (id, esito, errore, prossimo) =>
rpc("esito_push_promemoria", {
p_id: id,
p_esito: esito,
p_errore: errore ?? null,
p_prossimo: prossimo ? prossimo.toISOString() : null,
}),
invia: (iscrizione, titolo, testo) => inviaPush(iscrizione, titolo, testo),
elimina: eliminaIscrizione,
adesso: () => new Date(),
log,
};
const opzioni = {
limiteGiorno: config.limiteGiorno,
lotto: config.lotto,
@@ -76,6 +109,11 @@ for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo =
const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms));
log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`);
log(
config.vapid
? "push dei promemoria: attiva"
: "push dei promemoria: DISATTIVATA (VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY assenti), solo email",
);
trasporto.verify().then(
() => log("SMTP raggiungibile e credenziali accettate"),
(errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`),
@@ -90,6 +128,14 @@ while (!fermo) {
} catch (errore) {
console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`);
}
if (config.vapid) {
// Un errore delle push non deve fermare le email né il segnale di salute del worker.
try {
await elaboraCicloPush(dipendenzePush, { lotto: config.lotto });
} catch (errore) {
console.error(`${new Date().toISOString()} ciclo push fallito: ${errore?.message ?? errore}`);
}
}
await attendi(config.pollSecondi * 1000);
}
log("arrestato");