17 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 ded71f567b Merge develop in main: versione 1.2.0
Notifiche email via worker Docker su tre canali e promemoria senza ritorni (DD-036..DD-039).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:26:44 +02:00
davideandClaude Sonnet 5.5 b6dee87eb3 Porta la versione a 1.2.0: notifiche su tre canali e promemoria senza ritorni
Changelog dalla 1.1.1: notifiche email con worker Docker e interruttore Email,
push dei promemoria, sondaggio in-app ed email, l'allenatore senza promemoria
degli eventi e la correzione dei promemoria che ricomparivano.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:07:47 +02:00
davideandClaude Sonnet 5.5 4760e8650c L'allenatore non riceve i promemoria degli eventi; test dei destinatari per ruolo (DD-039)
m25: giocatori_destinatari_evento() include solo i giocatori attivi, quindi
l'allenatore non riceve più i promemoria a 24 e 3 ore, per nessun tipo di
evento (fino alla m24 li riceveva sempre, DD-034). Riceve ancora i messaggi
dello staff.

Test: nuovo destinatari-notifiche.test.ts (14 controlli sulle route vere con un
servizio push finto: promemoria, messaggio dello staff, sondaggio, sollecito,
turno palloni, permessi di allenatore e giocatore) e aggiornato
permessi-allenatore. Documentazione: DD-039, tabella delle notifiche
dell'allenatore, catalogo e tabella dei test che lo verificano.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:48:30 +02:00
davideandClaude Sonnet 5.5 e9013b703b 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>
2026-09-29 14:19:54 +02:00
davideandClaude Sonnet 5.5 58eba642f7 Documenta l'installazione e la gestione del worker email
Nuovo docs/WORKER_EMAIL.md (password per app, installazione, gestione,
diagnosi, prova in locale, spostamento su un altro host); il modulo
notifiche-email.md resta la sola specifica funzionale. Allineati
DD-036, CHANGELOG, PROJECT_STATE, indice e portabilità: migration m22
e worker in produzione dal 29/09/2026.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 12:22:46 +02:00
davideandClaude Sonnet 5.5 969dc1400c Riallinea la formattazione Prettier di due documenti dei moduli
Solo spaziatura delle tabelle in collegamento-csi.md e obiettivi-squadra.md,
nessun cambio di contenuto.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:48:12 +02:00
davideandClaude Sonnet 5.5 b7d3fde225 Implementa le notifiche email via Gmail da un worker Docker (DD-036)
Migration m22: coda notifiche_email_coda, preferenze_utente, trigger che
accoda ogni notifica di notifiche_utente e tre funzioni RPC riservate
alla service role. Worker in mailer/ (bun + nodemailer, solo polling in
uscita) con backoff, tetto giornaliero e healthcheck. Interruttore
«Email» in Profilo → Opzioni, acceso di default. Test unit e di
integrazione; documentazione aggiornata (DD-036 accettata).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:07:24 +02:00
davideandClaude Sonnet 5.5 2a0b611459 Documenta le notifiche email via Gmail da un worker Docker (DD-036)
Specifica del modulo, decisione DD-036 e voce in roadmap. Nessun codice:
ogni notifica di notifiche_utente diventa anche una mail, con coda su
tabella dedicata e preferenza per account in preferenze_utente.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:04:04 +02:00
davideandClaude Sonnet 5.5 cf667453a7 Ignora la cartella ops/ (playbook Ansible locale per il backup cifrato)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:31:23 +02:00
davideandClaude Sonnet 5 39918f2eb7 Scheda di dettaglio per gli eventi extra-campo, senza infortunato tra le risposte
Gli eventi generici (es. cena di squadra) avevano solo il conteggio presenze in
card, senza modo di vedere chi ha risposto. Aggiunta la route /evento/$id sul
modello di allenamento/partita, con RosaPresenze per l'elenco nominativo.

Lo stato "infortunato" non è una risposta pertinente per questi eventi: escluso
da badge, gruppi e bottoni di risposta sia in RosaPresenze che in EventoCard,
tramite la funzione condivisa includeInfortunato() in presenze.ts.

Documentazione dei moduli (calendario, presenze, palloni, badge) allineata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:01:46 +02:00
davideandClaude Sonnet 5 a72eb087d1 Esclude gli eventi generici dal turno palloni
eventiPalloni() ora include solo allenamenti e partite: una cena di
squadra o altro evento generico non genera più proposte automatiche,
promemoria push o conteggi per il turno palloni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 10:40:16 +02:00
davideandClaude Sonnet 5 34edfc662e Il precedente fix dello swipe non bastava: guarda anche offset.y
dragDirectionLock non ha effetto quando drag è già vincolato a un solo
asse ("x"): il gesto veniva comunque interpretato come swipe orizzontale
sulla sola velocity.x, rumorosa nei flick verticali veloci (bug visto
sia verso Obiettivi che verso Rosa, segno che il rumore cambiava segno).
Ora onDragEnd ignora il gesto se lo spostamento verticale (offset.y) è
maggiore di quello orizzontale (offset.x): uno scroll resta sempre
dominante in verticale, uno swipe intenzionale in orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:23:06 +02:00
davideandClaude Sonnet 5 e0a9d19a02 Porta la versione a 1.1.1: fix swipe tab e keepalive/backup già rilasciati
Aggiunge dragDirectionLock alla tab bar (Squadra > Stats non salta più su
Obiettivi durante uno scroll verticale veloce) e aggiorna il changelog
con quanto già presente su main dalla 1.1.0: keepalive giornaliero,
cifratura di default del backup e la card allenatore uniformata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:19:25 +02:00
davideandClaude Sonnet 5 3c2f9cf876 Uniforma la riga collassata della rosa tra allenatore e giocatori
La card dell'allenatore mostrava subito nome, ruolo e data di nascita,
mentre quella dei giocatori mostra solo nome e ruolo lasciando i
dettagli (nascita, statistiche) dietro l'espansione. Ora anche
l'allenatore è una card espandibile identica, con la data di nascita
visibile solo aprendola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:13:10 +02:00
davideandClaude Sonnet 5 93d70f4879 Usa bun invece di node per lo step di download storage nel backup
download-storage.mjs importa storage-core.ts, che node non può caricare
senza un loader TS; bun lo supporta nativamente ed è già una dipendenza
del progetto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 18:51:23 +02:00
davideandClaude Sonnet 5 20ca6ba5a6 Aggiunge un keepalive giornaliero per evitare la pausa Supabase free tier
Il progetto Supabase free va in pausa dopo 7 giorni senza richieste API.
Un Vercel Cron Job chiama /api/public/keepalive una volta al giorno, che
fa una lettura minima per tenere il progetto attivo senza servizi esterni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:43:28 +02:00
davideandClaude Sonnet 5 342b457ee7 Cifra il backup di default e aggiunge tar.gz + istruzioni decompressione
Prima --encrypt era opt-in; dato che i dump contengono dati sensibili
(hash password, documenti d'identità) la cifratura ora è il comportamento
di default, disattivabile con --no-encrypt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:34:57 +02:00
64 changed files with 5556 additions and 225 deletions

No files matched your search

+3 -1
View File
@@ -48,4 +48,6 @@ backups/*
!backups/backup.sh
!backups/download-storage.mjs
!backups/storage-core.ts
!backups/README.md
!backups/README.md
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
ops/
+36 -7
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 27/09/2026
Ultimo aggiornamento: 29/09/2026
## Stato generale
@@ -10,8 +10,9 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0
con il ruolo allenatore, è pronta ma non ancora pubblicata (vedi `docs/CHANGELOG.md`).
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0, ha
portato il ruolo allenatore. La versione corrente è la 1.2.0: notifiche su tre canali (push, in-app
ed email) e promemoria che non si ripetono (vedi `docs/CHANGELOG.md`).
Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane
da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
@@ -24,8 +25,14 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte
applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026)
- 36 migration in `supabase/migrations/`, fino a `m25_promemoria_solo_giocatori` (29/09/2026).
Le prime 33 (fino a `m22_notifiche_email`) sono applicate in produzione, verificato con
`npx supabase migration list` il 29/09/2026; **`m23`, `m24` e `m25` sono verificate solo in locale
e non ancora applicate in produzione**
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
su `develop`, non ancora su `main`
- Sviluppo locale verificato con il nuovo Supabase
---
@@ -55,6 +62,28 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
in `npx supabase migration list` del 24/09/2026)
- Migration `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()`
include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi
(li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con
`npx supabase db reset` e i test di integrazione, **non ancora in produzione**
- Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda`
con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo
`sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email).
Additiva; verificata in locale con `npx supabase db reset`, i test di integrazione e una prova
end-to-end del worker con un servizio push finto, **non ancora in produzione**. Richiede il
worker ricostruito e le chiavi VAPID nel suo `.env` (vedi `docs/WORKER_EMAIL.md`)
- Migration `m23_promemoria_gia_generati` (29/09/2026, DD-037): tabella
`promemoria_eventi_generati` e nuova `genera_promemoria_eventi()`, così un promemoria a 24 o 3
ore eliminato dal centro notifiche non viene più rigenerato dal cron (e non riaccoda mail).
Corregge un difetto presente dalla `m17`, emerso con l'arrivo delle email. Additiva; verificata
in locale con `npx supabase db reset` e `test/integration/promemoria-eventi.test.ts`, **non
ancora in produzione**
- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e
`preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni
RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema
esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e
una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal
29/09/2026 (`supabase db push`) con il worker in funzione
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
@@ -178,8 +207,8 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Niente di assegnato: l'avviso certificati in scadenza (DD-035) è implementato e versione
1.1.0 (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0) e le notifiche su tre
canali (DD-036–DD-039, 1.2.0) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
---
+33 -12
View File
@@ -35,7 +35,7 @@ Da fare una volta sola per host (poi il backup si rilancia semplicemente con
Project Settings → API. La **service role key** è quella con permessi pieni
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
esposta lato client né committata.
5. **(Solo se userai `--encrypt`) Verifica che gpg sia installato**:
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
```bash
gpg --version
```
@@ -46,15 +46,21 @@ A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
## Uso
```bash
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/ con tutto dentro
./backups/backup.sh --encrypt # come sopra, poi comprime e cifra in un unico
# backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
# passphrase) e cancella la cartella in chiaro
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
# passphrase a te scelta) e cancella cartella+tar.gz
# in chiaro
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
# (nessuna passphrase richiesta)
```
Ogni esecuzione crea una cartella nuova con timestamp: non sovrascrive backup
precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a mano
quando non servono più.
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
mano quando non servono più.
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
@@ -70,6 +76,23 @@ Il backup contiene:
| `data_auth.sql` | utenti reali, incluso l'hash della password |
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
## Decomprimere
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
del backup:
```bash
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
```
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
Archivio non cifrato (generato con `--no-encrypt`):
```bash
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
```
## Ripristino
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
@@ -83,8 +106,6 @@ Il backup contiene:
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
GitHub e Gitea).
Per un archivio cifrato: `gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz`.
## Cosa NON è incluso
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
@@ -98,8 +119,8 @@ personali reali. Per questo:
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
quelli sì restano in git) — **non committare mai i dati generati**;
- se sposti un backup fuori da questo host, usa sempre `--encrypt` o cifra tu
l'archivio a mano;
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
invece del tar.gz in chiaro generato con `--no-encrypt`;
- cancella i backup in chiaro quando non ti servono più.
## File di questa cartella
+16 -9
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
# Uso:
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/ con dentro tutto
# ./backups/backup.sh --encrypt come sopra, poi comprime e cifra con gpg (chiede una passphrase)
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
#
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
# in questo repo) e avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY.
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
# installato (esegue download-storage.mjs, che importa un modulo .ts).
set -euo pipefail
@@ -29,15 +30,21 @@ echo "==> Dump dati auth (utenti)"
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
echo "==> Download file storage (bucket)"
node "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
echo "==> Backup completato: $RUN_DIR"
if [[ "${1:-}" == "--encrypt" ]]; then
ARCHIVE="$RUN_DIR.tar.gz.gpg"
echo "==> Comprimo e cifro in $ARCHIVE (verrà chiesta una passphrase)"
tar -C "$SCRIPT_DIR" -czf - "$STAMP" | gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE"
rm -rf "$RUN_DIR"
TARBALL="$RUN_DIR.tar.gz"
echo "==> Comprimo in $TARBALL"
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
if [[ "${1:-}" == "--no-encrypt" ]]; then
echo "==> Archivio pronto: $TARBALL"
else
ARCHIVE="$TARBALL.gpg"
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
rm -rf "$RUN_DIR" "$TARBALL"
echo "==> Archivio cifrato pronto: $ARCHIVE"
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
fi
+82
View File
@@ -13,6 +13,88 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [1.2.0] - 2026-09-29
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
Le migration `m22`–`m25` sono già in produzione dal 29/09/2026 e il worker delle email è in funzione;
questa versione porta il codice dell'app che le usa.
### Aggiunto
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, promemoria
a 24 e 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita) arriva anche per email
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
`m22_notifiche_email`) e invia tramite un account Gmail dedicato, con nuovi tentativi in caso di
errore e un tetto giornaliero sotto il limite di Gmail. In Profilo → Opzioni c'è l'interruttore
«Email», acceso di default, per account (tabella `preferenze_utente`; DD-036).
- **Push dei promemoria** — i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email,
partono ora anche come push a tutti i dispositivi iscritti del giocatore. Nascono nel database,
che non può firmare una push, quindi la manda lo stesso worker con lo stesso modulo dell'app
(migration `m24_push_promemoria_e_sondaggio`, DD-038). Le iscrizioni scadute vengono eliminate e
una push rimasta in coda oltre 3 ore si scarta. Il worker va ricostruito e configurato con le chiavi
VAPID dell'app (vedi [WORKER_EMAIL.md](WORKER_EMAIL.md)); senza, manda solo le email.
- **Sondaggio pre-partita nel centro notifiche e per email** — prima era solo una push. Ora compare
anche tra le notifiche in-app e arriva per email ai giocatori attivi, allenatori esclusi (DD-038).
- **Documentazione e test** — nuovo [WORKER_EMAIL.md](WORKER_EMAIL.md) (installazione, gestione,
diagnosi, prova in locale, spostamento su un altro host); catalogo delle notifiche in
[notifiche.md](modules/notifiche.md), con cosa le fa partire, a chi arrivano e su quali canali;
tabella delle notifiche dell'allenatore in [allenatore.md](modules/allenatore.md); decisioni
DD-036, DD-037, DD-038 e DD-039. Nuovi test di integrazione per la coda delle email, il registro
dei promemoria, la coda push, il sondaggio e i destinatari per ruolo (promemoria, messaggio dello
staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), più i test unitari del
worker.
### Modificato
- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non
c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di
squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration
`m25_promemoria_solo_giocatori`.
- **Messaggio del pulsante «Sondaggio»** — se nessun dispositivo ha le notifiche attive dice che
l'avviso è stato inviato in app e per email, invece di «Nessun dispositivo con le notifiche
attive».
- **`ops/` fuori da git** — il playbook Ansible locale per il backup cifrato non è più tracciato.
### Corretto
- **I promemoria dell'evento non ricompaiono più se li elimini** — il promemoria a 24 e a 3 ore
eliminato dal centro notifiche veniva rigenerato dal job automatico al giro successivo, come non
letto e, con le email, con una nuova mail a ogni giro fino all'inizio dell'evento. Ora il job
ricorda ciò che ha già generato (migration `m23_promemoria_gia_generati`, DD-037). Il difetto
esisteva dal centro notifiche (`m17`) e si è notato con l'arrivo delle email.
## [1.1.1] - 2026-09-28
### Aggiunto
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
pausa dopo 7 giorni senza richieste API.
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
pertinente.
### Modificato
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
di decompressione nel README.
### Corretto
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
mostrare subito tutto.
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
il cambio tab verso Obiettivi.
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
sono allenamenti o partite.
## [1.1.0] - 2026-09-27
### Aggiunto
+14 -6
View File
@@ -19,6 +19,10 @@ nessuna di esse da M4 (DD-011).
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
@@ -71,12 +75,16 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| Tabella | Scopo | Note |
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. |
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. |
## Funzioni speciali
+246 -1
View File
@@ -51,6 +51,10 @@ Serve a rispondere a domande del tipo:
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
| [DD-037](#dd-037--i-promemoria-evento-ricordano-di-essere-già-stati-generati) | Promemoria evento senza ritorni |
| [DD-038](#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker) | Tre canali per ogni notifica |
| [DD-039](#dd-039--lallenatore-non-riceve-i-promemoria-degli-eventi) | Niente promemoria all'allenatore |
**In valutazione**
@@ -1446,7 +1450,8 @@ collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando
immediato e non aspetta la query dei ruoli.
- L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra.
- In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`;
riceve i promemoria degli eventi e può sollecitare le presenze, non il turno palloni.
riceve i promemoria degli eventi (dal 29/09/2026 non più: DD-039) e può sollecitare le presenze,
non il turno palloni.
- Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata.
**Riesame**
@@ -1510,3 +1515,243 @@ certificato viene rinnovato. Specifica completa in
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7
giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile.
### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
vedi i limiti in `docs/modules/notifiche.md`) e nel centro notifiche in-app, che si vede solo
aprendo l'app. Chi non ha attivato la push o ha un telefono aggressivo sul risparmio
energetico non riceve nulla ad app chiusa. Serve un canale che arrivi comunque, **gratis**, ai
giocatori registrati, che sono tutti su Gmail perché l'accesso è con Google. L'app gira su
Vercel, che non esegue container né processi sempre attivi.
**Decisione**
Le email partono da un **worker Docker sull'host di casa** che legge una coda su Supabase e
invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per app.
- La coda è `notifiche_email_coda`, riempita da un trigger su `notifiche_utente`: ogni
notifica già esistente, di qualunque tipo e per qualunque destinatario (admin compresi,
con la stessa logica degli altri), diventa anche una mail. Le sorgenti di M17 non cambiano,
non ne nascono di nuove e l'app non conosce le email. Il certificato in scadenza resta
fuori: è una card in Home, non una notifica (DD-035).
- Il worker fa solo polling **in uscita** con la service role: nessuna porta aperta sull'host.
- La coda è una tabella a parte, con RLS senza policy, perché la policy `UPDATE` di
`notifiche_utente` lascerebbe a un client la possibilità di rimettere in coda una mail.
- Canale **opt-out**: acceso di default per tutti, con un interruttore per account in
Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga
per account (non per slot); l'assenza di riga vale «acceso».
- Specifica completa in [notifiche-email.md](modules/notifiche-email.md); installazione e
gestione in [WORKER_EMAIL.md](WORKER_EMAIL.md).
**Alternative scartate**
- Inviare direttamente dal proprio IP con un MTA (Postfix) → scartata: porta 25 bloccata dai
provider residenziali, IP nelle blocklist, niente reverse DNS; le mail finirebbero in spam o
sarebbero rifiutate.
- Relay Brevo o Resend → scartati per ora: il piano gratuito funziona ma per una buona
consegna richiede un dominio con SPF/DKIM/DMARC, che oggi non c'è. Restano l'alternativa
se servirà un mittente della squadra.
- Supabase Edge Function con `pg_cron` e un relay → scartata su richiesta esplicita di un
servizio dockerizzato sull'host; resta la strada senza host acceso, ma dipende comunque da un
relay esterno.
- Vercel Cron → scartato: sul piano gratuito gira al massimo una volta al giorno, troppo poco
per i promemoria a 24h e 3h.
- Chiamare l'host dall'app (webhook verso casa) → scartato: richiederebbe un IP pubblico o un
tunnel e una superficie d'attacco in più; il polling in uscita non ne ha.
- Colonne di stato sulla riga di `notifiche_utente` → scartate per la RLS spiegata sopra.
**Conseguenze**
- Costo zero, ma il canale email dipende da un host acceso: se è spento le mail arrivano in
ritardo (mai perse). Consegna _almeno una volta_: in caso di crash tra invio e
registrazione può arrivare un doppione.
- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite
reale: con molte squadre o riepiloghi frequenti andrebbe rivisto.
- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola
dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host).
L'app non guadagna nessuna dipendenza e le route di M17 non cambiano.
- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non
passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte.
- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default,
sotto i circa 500 di Gmail).
- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e
l'indicazione in ogni mail su come disattivarlo.
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
preferenze per tipo di notifica senza toccare `giocatori_squadra`.
- In produzione dal 29/09/2026 (migration e worker su un Raspberry Pi). Dalla prova reale: la
prima mail, da un account Gmail nuovo, è arrivata ma nella cartella spam, e la password per app
non era disponibile finché l'account aveva le sole passkey come secondo passaggio (dettagli in
[WORKER_EMAIL.md](WORKER_EMAIL.md#account-gmail-e-password-per-app)).
**Riesame**
Se serve un mittente col dominio della squadra, se le mail finiscono in spam anche dopo qualche
giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco
affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron`
al posto del container.
### DD-037 — I promemoria evento ricordano di essere già stati generati
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
I promemoria a 24 e a 3 ore (M17, DD-030) evitavano i doppioni con `ON CONFLICT DO NOTHING` sul
vincolo `UNIQUE (giocatore_id, evento_id, tipo)` di `notifiche_utente`: la riga della notifica
era l'unica traccia che il promemoria fosse già stato generato. Il giocatore però può eliminarla
(swipe o ×, DD-030). Senza più la riga, al giro successivo del job (ogni ora per il promemoria a
24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il promemoria veniva
rigenerato come non letto. Con il canale email (DD-036) ogni rigenerazione accodava anche una
mail nuova: un giocatore che eliminava un promemoria ne riceveva una ogni ora fino all'inizio
dell'evento. Il difetto è stato trovato controllando la logica il 29/09/2026, il giorno in cui il
worker email è entrato in produzione, e riprodotto sul database locale.
**Decisione**
Un registro separato, `promemoria_eventi_generati` (migration `m23_promemoria_gia_generati`), con
una riga per `(giocatore_id, evento_id, tipo)` scritta dalla funzione `genera_promemoria_eventi()`.
La notifica in-app si crea solo per le righe **appena registrate**, quindi eliminarla non fa
ripartire nulla. Il registro è indipendente da `notifiche_utente`, senza policy per i client, e
sparisce con l'evento o con il giocatore (`ON DELETE CASCADE`). I promemoria già presenti al
momento della migration sono stati registrati come generati, altrimenti la prima notifica
eliminata dopo la migration sarebbe tornata come prima. Il vincolo `UNIQUE` di `notifiche_utente`
resta come seconda difesa. Cron, criteri di selezione, testi e destinatari sono invariati.
**Alternative scartate**
- Non eliminare davvero la notifica (nasconderla con un flag) → scartata: cambia il significato
dell'eliminazione per tutte le sorgenti, tocca la RLS e le query del client, e la tabella
crescerebbe di righe che il giocatore non vede più.
- Togliere l'eliminazione dai soli promemoria, lasciando «segna come letta» → scartata: a un
giocatore che vuole ripulire l'elenco resterebbero notifiche non eliminabili, incoerenti con
le altre.
- Un flag «già generato» sulla riga dell'evento → scartata: il promemoria è per giocatore, e chi
viene aggiunto ai convocati dopo deve comunque riceverlo.
- Confrontare `creato_il` o lo stato «letta» per capire se ricrearlo → scartata: non distingue
una notifica mai generata da una eliminata.
**Conseguenze**
- Eliminare un promemoria lo fa sparire per sempre, e non parte nessuna mail in più. Il numero
massimo di mail automatiche per giocatore ed evento resta due (24 e 3 ore).
- Una tabella in più, che cresce di una riga per giocatore, evento e tipo e si svuota con
l'evento.
- Se un evento viene spostato dopo che il promemoria è partito, non ne parte uno nuovo per la
data nuova: è lo stesso comportamento di prima, ora esplicito.
- `test/integration/promemoria-eventi.test.ts` è la definizione eseguibile: il promemoria
eliminato non ricompare, non riaccoda mail, i due tipi sono indipendenti, un convocato aggiunto
dopo lo riceve comunque, il registro non è leggibile né scrivibile da un client e sparisce con
l'evento. Sul database con la funzione vecchia il test fallisce.
**Riesame**
Se gli eventi potessero essere spostati con frequenza e servisse un nuovo promemoria per la data
nuova, il registro va agganciato anche alla data di inizio, oltre a giocatore, evento e tipo.
### DD-038 — Ogni notifica ha tre canali: la push dei promemoria parte dal worker
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
Il requisito è che ogni notifica arrivi su tutti e tre i canali: push, centro notifiche in-app ed
email. Due casi non lo rispettavano ([catalogo](modules/notifiche.md#catalogo-delle-notifiche)):
- i **promemoria a 24 e 3 ore** nascono dentro il database (job `pg_cron`, M17), che non può
firmare né cifrare una push Web (VAPID, RFC 8291): arrivavano solo in-app e per email;
- il **sondaggio pre-partita** partiva solo come push e non scriveva in `notifiche_utente`, quindi
non compariva in-app e, dato che le email nascono da quella tabella (DD-036), non arrivava per
email.
Le altre notifiche (messaggio dello staff, turno palloni, sollecito presenze) partono dall'app su
Vercel e avevano già i tre canali.
**Decisione**
1. **Sondaggio pre-partita:** la route `apri-sondaggio` scrive anche `notifiche_utente` (tipo
nuovo `sondaggio_cacche`, un `upsert` per giocatore ed evento come le altre), per i giocatori
attivi che non sono allenatori. La mail parte dal trigger di M22 senza altro lavoro. La push
resta com'è. Il vincolo `CHECK` sul tipo si estende con la migration `m24`.
2. **Push dei promemoria:** la manda il **worker `mailer/`**, con un secondo canale accanto alle
email. Un trigger accoda ogni promemoria a 24 o 3 ore in `notifiche_push_coda` (stessa
struttura di `notifiche_email_coda`); il worker prende il lotto con `FOR UPDATE SKIP LOCKED`,
manda la push a tutti i dispositivi iscritti del giocatore e registra l'esito. Usa **lo stesso
modulo dell'app** (`src/lib/webpush.server.ts`, senza dipendenze) invece di una copia: il
container si costruisce dalla radice del repository. Le iscrizioni che rispondono 404 o 410
vengono eliminate, come nelle route. Le push in coda da oltre 3 ore si scartano: un promemoria
arrivato a evento iniziato è solo rumore.
3. La push dei promemoria non dipende dall'interruttore «Email»: dipende solo dai dispositivi
iscritti, come le altre push.
**Alternative scartate**
- Far chiamare all'app dal database (`pg_net`, già abilitata) una route che manda la push →
scartata: il database dipenderebbe dall'indirizzo pubblico di Vercel e da un segreto condiviso,
e `pg_net` è un'estensione del provider (regola 4 di [PORTABILITA.md](PORTABILITA.md)).
- Vercel Cron → scartato: sul piano gratuito gira una volta al giorno, come già notato in DD-036.
- Copiare il modulo push nel worker → scartata: due versioni da tenere allineate per codice che
cifra e firma.
- Mandare dal worker la push di tutte le notifiche → scartata: le altre partono già dall'app, si
avrebbero push doppie.
- Un unico stato di coda per email e push → scartata: le due consegne hanno esiti, tentativi e
destinatari diversi (una push per dispositivo, un'email per giocatore).
**Conseguenze**
- Il worker non fa più solo email: gestisce due canali (il documento operativo resta
[WORKER_EMAIL.md](WORKER_EMAIL.md)). Serve la chiave VAPID privata anche sull'host, un segreto in
più. Senza le chiavi il worker parte lo stesso e manda solo le email, segnalandolo nel log.
- La push dei promemoria dipende dall'host di casa acceso, come le email: se è spento parte alla
ripartenza, ma non oltre le 3 ore dalla creazione.
- Una tabella nuova (`notifiche_push_coda`) e una migration (`m24_push_promemoria_e_sondaggio`).
- Il sondaggio compare nel centro notifiche e arriva per email ai giocatori attivi, non agli
allenatori; la push continua ad andare a tutti i dispositivi iscritti tranne quelli degli
allenatori, anche di giocatori non più attivi.
- Le altre notifiche non cambiano canali e non ricevono push doppie.
**Riesame**
Se l'host di casa si rivela poco affidabile per una notifica sensibile al tempo come la push, o se
il database dovesse comunque chiamare l'app per altro, valutare di nuovo la chiamata dal database
all'app.
### DD-039 — L'allenatore non riceve i promemoria degli eventi
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
DD-034 stabilì che l'allenatore riceve i promemoria a 24 e 3 ore prima di un evento, sempre, convocato
o no (`giocatori_destinatari_evento()` riscritta da M21). Ma un promemoria serve a ricordare di
esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i
partecipanti (DD-034). L'incoerenza si vede negli eventi extra-campo, come una cena di squadra:
riceveva l'avviso (in-app, per email e, dopo M24, come push) senza poter dire se ci sarebbe stato né
comparire nell'elenco.
**Decisione**
I destinatari dei promemoria sono i **giocatori attivi**: i convocati, o tutta la rosa se
`convocati` è vuoto. L'allenatore non li riceve, per nessun tipo di evento. Cambia solo la funzione
SQL (migration `m25_promemoria_solo_giocatori`, `g.tipo = 'giocatore'` al posto di «o allenatore
sempre»); cron, criteri di selezione, registro dei promemoria (DD-037), code email e push e testi
restano invariati.
**Alternative scartate**
- Toglierli solo per gli eventi extra-campo → scartata su indicazione esplicita di toglierli per
gli eventi in generale: anche per un allenamento o una partita l'allenatore non è tra i
partecipanti.
- Farlo rispondere agli eventi extra-campo, così da tenere il promemoria → scartata: servono
risposte presenze e regole del database per una categoria nuova, e il conteggio dei presenti.
**Conseguenze**
- Le notifiche che l'allenatore riceve dal server sono i **messaggi dello staff** e nient'altro
(sollecito, turno palloni, sondaggio e notifiche smart non lo riguardano già).
- I promemoria già generati per un allenatore prima della migration non si toccano.
- Un allenatore non è avvisato dell'inizio di un evento che gestisce: sa quando è perché lo ha
creato lui o lo vede in Calendario.
- Sostituisce la parte di DD-034 sui promemoria; il resto di DD-034 resta valido.
**Riesame**
Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme
alla sua possibilità di rispondere.
+10 -9
View File
@@ -5,15 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
## Stato attuale
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche
+1
View File
@@ -14,6 +14,7 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
+3
View File
@@ -41,6 +41,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036)
## Prossimo
+246
View File
@@ -0,0 +1,246 @@
# Worker email: installazione e gestione
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
per email e la push dei promemoria a 24 e 3 ore. Cosa fa il canale email sta in
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa)
e [DD-038](DESIGN_DECISIONS.md#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker).
Il nome del documento resta quello delle email, che sono il canale principale.
**Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026,
su un Raspberry Pi con Docker. La push dei promemoria (`m24`) richiede le chiavi VAPID nel `.env` del
worker e la ricostruzione del container.
## Cosa serve
| Requisito | Note |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. |
| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). |
| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. |
| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). |
| Chiavi VAPID | Le stesse `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` dell'app su Vercel: per la push dei promemoria. |
Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Si
costruisce dalla **radice del repository** (`docker-compose.yml` imposta `context: ..`), perché include
anche `src/lib/webpush.server.ts`, lo stesso modulo push dell'app: `docker compose up -d --build`
va comunque lanciato dentro `mailer/`. `mailer/Dockerfile.dockerignore` ammette nel contesto solo i
file usati, quindi nemmeno il `.env` finisce nell'immagine.
## Account Gmail e password per app
Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**.
1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo.
2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso).
3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu.
4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di
16 caratteri, a gruppi di quattro: copiala.
5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come
script di shell, si rompe. Non va mai incollata in chat né nel repository.
Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account.
**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:**
| Causa | Cosa fare |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). |
| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. |
| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. |
Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un
dominio proprio (vedi DD-036).
## Installazione
1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: non modifica nulla
di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le
nuove notifiche si accodano: **avvia il worker subito**, nella stessa sessione. Le notifiche
accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie
(il 29/09/2026 sono partiti così due messaggi di prova dell'admin).
2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila:
| Variabile | Default | Contenuto |
| --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
| `SUPABASE_URL` | — | URL del progetto |
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
| `APP_URL` | — | base dei link nelle mail, in https e senza slash finale |
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
| `POLL_SECONDI` | 30 | intervallo di polling |
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail |
| `MAIL_LOTTO` | 10 | mail prese a ogni giro |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | — | chiavi per la push dei promemoria (vanno insieme; senza, solo email) |
| `VAPID_SUBJECT` | `mailto:crapp@crapvolley.it` | contatto nell'intestazione VAPID, come nell'app |
Le chiavi VAPID devono essere **le stesse dell'app**: le iscrizioni push dei dispositivi sono
legate alla chiave pubblica con cui sono nate, e con chiavi diverse i servizi push rifiutano l'invio
(403). Se mancano il worker parte lo stesso e nel log compare «push dei promemoria: DISATTIVATA».
`SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un
server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il
worker con l'elenco degli errori. Il `.env` è ignorato da git.
**`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch
(`…-git-<branch>-….vercel.app`). L'indirizzo di un singolo deploy (`…-<codice>-….vercel.app`)
cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la
Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a
Vercel.
3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate» e
«push dei promemoria: attiva»;
dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore
dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi.
## Gestione
| Cosa | Comando (dentro `mailer/`) |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Vedere il log in diretta | `docker compose logs -f` |
| Ultime righe del log | `docker compose logs --tail 50` |
| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) |
| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) |
| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` |
| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) |
| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. |
Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`,
`ciclo fermato`), mai indirizzi né testi.
## Vedere cosa è partito
- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il
riscontro più sicuro.
- **SQL Editor di Supabase**, per stato e contenuto:
```sql
select c.stato, c.inviata_il, c.tentativi, c.errore,
g.nome, g.cognome, n.tipo, n.titolo, n.corpo
from notifiche_email_coda c
join notifiche_utente n on n.id = c.notifica_id
join giocatori_squadra g on g.id = n.giocatore_id
order by n.creato_il desc
limit 50;
```
Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Lo stesso per le push dei
promemoria, con la tabella `notifiche_push_coda` (colonne `stato`, `tentativi`, `errore`, `inviata_il`).
Il testo del messaggio sta in `titolo` (oggetto) e `corpo`.
## Spostare il worker su un altro host
Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in
Supabase. Cambiare host non fa perdere né duplicare mail.
1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera.
L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare.
2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella
`mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola.
3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da
chat né da git: contiene la service role e la password per app.
4. **Spegni il vecchio:** `docker compose down` sul vecchio host.
5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP
raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`.
Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un
po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa
`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log.
Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del
nuovo worker.
Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La
password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`,
controlla la posta dell'account mittente e conferma l'accesso.
## Diagnosi
| Sintomo | Causa probabile | Cosa fare |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. |
| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. |
| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. |
| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. |
| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. |
| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. |
| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. |
| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. |
| Il log dice «push dei promemoria: DISATTIVATA» | Mancano `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` nel `.env` | Aggiungile (le stesse dell'app) e ricrea il container. |
| Push dei promemoria in `fallita` con errore `403` | Chiavi VAPID diverse da quelle dell'app, o payload rifiutato | Controlla le chiavi; la query sotto mostra l'errore. |
| Push dei promemoria `saltata` | Il giocatore non ha dispositivi iscritti, o il promemoria aveva oltre 3 ore | Normale se nessuno ha attivato le notifiche o il worker è stato fermo a lungo. |
## Prova in locale, senza toccare la produzione
`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`),
con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può
lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`.
1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con
`npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si
usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale.
Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la
porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si
omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa.
2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con
`email`. In locale:
```bash
. <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')
UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H 'content-type: application/json' \
-d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
docker exec supabase_db_<project_id> psql -U postgres -c \
"set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='<slot>';"
```
`session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la
service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella
tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed,
si usa quello slot invece di impostarlo su un secondo.
3. **Una notifica di prova:**
```bash
docker exec supabase_db_<project_id> psql -U postgres -c \
"insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('<slot>','admin','Prova CrAPP','Messaggio di prova');"
```
Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella).
4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito.
Per provare anche l'immagine Docker, il container si collega alla rete di Supabase
(`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come
`SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP.
Per provare la **push dei promemoria** senza un telefono: si genera una coppia di chiavi VAPID di
prova, si avvia un piccolo server HTTP locale che finge di essere il servizio push (risponde 201 su un
percorso e 410 su un altro), si inseriscono in `push_subscriptions` due iscrizioni con chiavi ECDH
valide che puntano a quel server e si crea un promemoria in `notifiche_utente`. Il worker lo cifra, lo
spedisce con `Authorization: vapid …`, `Content-Encoding: aes128gcm`, `Urgency: high` e `TTL`, segna
la riga `inviata` ed elimina l'iscrizione che ha risposto 410. Il 29/09/2026 la prova è stata fatta
così.
L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite
non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node:
`bun --bun x vite dev --port 8080` (con `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` e
`SUPABASE_SERVICE_ROLE_KEY` dello stack locale) e poi `BASE_URL=http://localhost:8080` davanti ai
test. I test del database e del worker non ne hanno bisogno.
## Sicurezza
- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat.
- La service role non passa da Vercel: sta solo sull'host del worker. Anche la chiave VAPID
**privata** sta sull'host, oltre che su Vercel: stessa attenzione, mai in git né in chat.
- Il log delle push riporta solo id notifica e numeri: mai gli endpoint, che identificano il
dispositivo del giocatore.
- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e
dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`.
+49 -30
View File
@@ -122,47 +122,65 @@ risposte presenze altrui, gestione dei ruoli.
## Notifiche
L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche
in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()`
lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio
Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che
riguardano chi gioca.
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
[notifiche.md](notifiche.md#catalogo-delle-notifiche)):
| Notifica | L'allenatore la riceve? | Note |
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito. |
| Turno palloni | no | I turni riguardano solo i giocatori. |
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app
funziona come per i giocatori, con i soli messaggi dello staff.
## Esclusione dalla rosa
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
(`useAnagraficaRosa({ conAllenatori: true })`), i destinatari dei promemoria evento e dei
messaggi admin, che lo includono.
(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo
includono.
| Livello | Dove si esclude l'allenatore |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial` |
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
| Livello | Dove si esclude l'allenatore |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) |
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
giocatore).
## Permessi (riepilogo)
| Azione | Giocatore | Allenatore | Admin |
| ----------------------------------------------- | ---------- | ------------------ | ------------------ |
| Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato, foto tessera | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì |
| Promemoria palloni, notifica personalizzata | no | no | sì |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
| Azione | Giocatore | Allenatore | Admin |
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
| Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato, foto tessera | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì |
| Promemoria palloni, notifica personalizzata | no | no | sì |
| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot |
| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot |
| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa |
| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve
secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot.
## Implementazione
@@ -174,7 +192,7 @@ giocatore).
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
allenatore; presenze, cacche e voti chiusi all'allenatore; promemoria evento aperti.
allenatore; presenze, cacche e voti chiusi all'allenatore.
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
@@ -183,7 +201,8 @@ giocatore).
## Scelte confermate
- Riceve i promemoria degli eventi: sì.
- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i
messaggi dello staff.
- Sollecita le presenze: sì. Promemoria palloni: no.
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
+6 -5
View File
@@ -408,11 +408,12 @@ meccanismo:
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
test esistente ma leggendo perché quel test lo dimostrava apposta.
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
---
+11 -3
View File
@@ -4,7 +4,8 @@
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
(card condivisa)
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
---
@@ -44,8 +45,15 @@ Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 mi
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
eventi virtuali (compleanni).
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`.
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
## Lettura lato server
+21 -21
View File
@@ -31,14 +31,14 @@ un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
| Cosa | Valore |
| ---------------------- | ---------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
@@ -61,11 +61,11 @@ verificato chiamando l'endpoint direttamente, che oggi restituisce sia
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
@@ -104,11 +104,11 @@ Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `mat
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
| Endpoint | Formato | Uso |
| -------------------------------------------- | ------- | ------------------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
@@ -262,7 +262,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
@@ -289,7 +289,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
errore solo se *sia* la classifica *sia* le partite sono vuote insieme
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
@@ -312,7 +312,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
errore SQL del loro backend in chiaro al posto del JSON
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
+242
View File
@@ -0,0 +1,242 @@
# Modulo — Notifiche email
**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`)
**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`,
`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`,
`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx`
(interruttore «Email»)
Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si
diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in
[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
## Obiettivo
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è
registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche in-app
([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda per email
**ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per qualunque
destinatario.
## Architettura
```
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
▲
polling in uscita │ RPC con service role
│
Container Docker sull'host di casa (mailer)
│ SMTP 587 (STARTTLS)
▼
smtp.gmail.com ──▶ casella Gmail del giocatore
```
- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**:
interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico,
nessun tunnel.
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come prima:
le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e
partono alla ripartenza: consegna in ritardo, mai persa.
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron `pg_cron`
compresi.
- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una
sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST,
senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli
dell'app, che non guadagna nessuna dipendenza.
- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato
nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta
entrambe (verificato sul progetto di produzione).
## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a
3 ore, turno palloni, sollecito presenze, sondaggio pre-partita. Un tipo nuovo che in futuro finisse in quella tabella
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
**non** vengono inviate: il trigger non ha backfill.
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
arrivano anche per email (DD-038).
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio
slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il
sollecito, il turno palloni e il sondaggio (DD-034, DD-038).
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
`notifiche_utente`, da decidere a parte.
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
## Mittente
Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con
una **password per app**. Come crearla e i problemi noti stanno in
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app).
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome
visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account
gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai
direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS
le mail finiscono in spam o vengono rifiutate.
## Dati
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
funzionale.
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie
anche la mail non ancora partita).
| Colonna | Note |
| -------------------- | ----------------------------------------------------------------------- |
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per
il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La
coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la
legge e la scrive solo la service role, cioè il worker.
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora
con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la
riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo
la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può
modificare solo in parte (DD-016).
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
| Funzione | Cosa fa |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
## Flusso
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
`notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
dei cron non toccano quella colonna e non accodano nulla.
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una.
Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme
`in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario
o con le email spente.
4. Ogni invio registra l'esito con `esito_notifica_email`.
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
ferma anche le mail già in coda.
## Affidabilità
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
| Tipo | Esempi | Cosa succede |
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
Altre situazioni:
| Situazione | Comportamento |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
messaggi che l'account dedicato potesse mandare per altre vie.
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza
all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato
dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps`
mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto
giornaliero è normale e resta sano.
## Contenuto delle mail
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante
«Apri CrAPP» verso `<APP_URL>/evento/<id>` quando la notifica riguarda un evento e verso la home
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
## Interfaccia
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta)
che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche»
(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account.
Nessuna schermata nuova.
## Privacy e sicurezza
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da
Vercel.
- La password per app si può revocare da Google senza toccare l'account.
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come
per la push (nessuna preferenza per tipo).
## Test
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
«acceso» senza riga.
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari.
L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti).
## Limiti noti
- Il canale email dipende da un host acceso: non c'è alta disponibilità.
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole
una.
- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un
account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla
configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora
segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail
va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni).
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato
che `notifiche_utente` ha una riga per `giocatore_id`.
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
`inviata` non si toccano.
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
spariscono quando la notifica viene eliminata.
- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia
dell'app è in produzione, le mail partono ma non si possono spegnere dall'app.
## Evoluzioni possibili
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail
restano in spam o si supera il tetto di Gmail.
- Riepilogo settimanale unico al posto di una mail per notifica.
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
+138 -9
View File
@@ -14,7 +14,8 @@ centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato.
@@ -25,6 +26,90 @@ particolare il certificato in scadenza **non** genera push né voci nel centro n
---
## Catalogo delle notifiche
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, quindi
la colonna Email coincide con la colonna In-app).
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | ----- |
| 1 | **Promemoria a 24 ore** — «Promemoria: _titolo_», corpo «data alle ora» | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì |
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Tocca a te prendere i palloni» / «Porta i palloni» | Un admin, con il pulsante nella pagina dell'evento. Solo eventi di tipo allenamento o partita. Route `promemoria-palloni`, tipo `turno_palloni`. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | sì |
| 5 | **Sollecito presenze** — «Manca la tua risposta» | Un admin o un allenatore, con il pulsante nella pagina dell'evento. Route `sollecita-presenze`, tipo `sollecita_presenze`. | I giocatori attivi (mai gli allenatori) che non hanno ancora risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
Tutte le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038). La
differenza sta in come parte la push: quelle dei pulsanti (3-6) la mandano le route dell'app, quelle
dei promemoria (1 e 2) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Le righe 4 e 5 riscrivono la notifica esistente se l'admin preme di nuovo il pulsante
per lo stesso evento, e lo stesso fa il sondaggio (vedi «Centro notifiche in-app»).
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
### Cosa non genera nessuna notifica
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
dell'inizio (DD-030).
- L'arrivo di un risultato, di una pagella o di un voto MVP.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
### Comportamenti da conoscere
Sono l'effetto attuale della logica, non scelte documentate altrove:
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
allenamenti e partite.
- **Un evento creato a poche ore dall'inizio riceve subito i promemoria che gli spettano:** se
mancano meno di 24 ore parte il promemoria «24 ore» al primo giro del job orario, e se ne
mancano meno di 3 anche quello «3 ore», a distanza di pochi minuti.
- **Ogni promemoria si genera una sola volta per giocatore, evento e tipo, anche se il giocatore
lo elimina** (M23, DD-037): il job registra ciò che ha generato in `promemoria_eventi_generati`,
una tabella indipendente da `notifiche_utente`. Eliminare la notifica dal centro notifiche non
la fa tornare e non accoda una nuova mail. Prima di M23 tornava: la riga eliminata veniva
rigenerata al giro successivo del job, con una nuova mail a ogni giro fino all'inizio
dell'evento.
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
- **Le notifiche 3, 4 e 5, una volta eliminate, non tornano da sole:** riappaiono solo se un admin
rimanda il messaggio o ripreme il pulsante.
- **Un evento già iniziato non genera più promemoria.**
- **Quattro notifiche su sei dipendono da un gesto manuale** (3, 4, 5 e 6): se nessuno preme il
pulsante, nessuno viene avvisato. Gli unici automatismi sono i due promemoria.
- **Per ogni evento i giocatori ricevono al massimo due mail automatiche** (24 e 3 ore prima),
più quelle dei pulsanti manuali.
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
### Test che verificano il catalogo
| Cosa | Test |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
codice; l'unico pezzo puro, `inRosa`, è coperto.
---
## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
@@ -62,9 +147,10 @@ copre in particolare le sessioni lunghe della webapp (vedi il
- **`push-config`** — espone la sola chiave pubblica VAPID.
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi
[Scout Live](scout-live.md)).
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi
iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in
`notifiche_utente` per i giocatori attivi, così compare nel centro notifiche e parte per email
(DD-038; vedi [Scout Live](scout-live.md)).
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
@@ -117,6 +203,13 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto.
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**:
`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore
letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude
gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e
`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la
notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche).
---
## Centro notifiche in-app (M17)
@@ -131,23 +224,27 @@ non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico per
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
Quattro sorgenti scrivono in `notifiche_utente`, mai il client:
Cinque sorgenti scrivono in `notifiche_utente`, mai il client:
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata da un vincolo
`UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda
doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
da `avvisiPalloniEvento()`, in parallelo alla push.
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
Turno palloni e sollecito presenze usano un `upsert` su `(giocatore_id, evento_id, tipo)`
Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
@@ -164,6 +261,38 @@ sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta.
## Push dei promemoria (M24)
I promemoria a 24 e 3 ore nascono nel database (job `pg_cron`), che non può firmare né cifrare una
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
DD-038).
1. Quando il job crea un promemoria, un trigger lo accoda in `notifiche_push_coda` (solo i tipi
`evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano già la push
dall'app, e una seconda sarebbe un doppione).
2. Il worker, a ogni giro, chiama `prendi_push_promemoria()`, che restituisce testo e dispositivi
iscritti del giocatore, e manda la push a **tutti** i suoi dispositivi con lo stesso modulo
dell'app (`src/lib/webpush.server.ts`), quindi con le stesse regole di cifratura, urgenza e TTL.
3. Un solo esito per notifica: `inviata` se almeno un dispositivo accetta; `riprova` con backoff
1 · 5 · 30 · 120 minuti sugli errori temporanei (429, 5xx, rete), poi `fallita`; `fallita`
subito su un rifiuto permanente (400, 401, 403, 413: tipicamente chiavi VAPID sbagliate);
`saltata` se le iscrizioni erano tutte scadute o il giocatore non ne ha.
4. Le iscrizioni che rispondono 404 o 410 vengono eliminate da `push_subscriptions`, come fanno le
route.
5. Una push in coda da oltre 3 ore dalla creazione del promemoria si scarta (`saltata`): a evento
ormai iniziato sarebbe solo rumore. Vale se il worker resta fermo a lungo.
La push dei promemoria dipende solo dai dispositivi iscritti, non dall'interruttore «Email». Se il
worker non ha le chiavi VAPID parte lo stesso e manda solo le email, segnalandolo nel log.
## Canale email (M22)
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
indipendenti e le sorgenti scrivono `notifiche_utente` come prima. Il certificato in
scadenza resta una card in Home e non genera mail (DD-035).
## Limiti noti
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
+21 -21
View File
@@ -24,7 +24,7 @@ ordina mettendo i completati in coda e gli altri per progresso decrescente.
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle
esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
cancellazione: risolto a livello database con un trigger (migration
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
@@ -37,18 +37,18 @@ corrente dinamico (vedi sotto).
## Obiettivi definiti
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
@@ -104,7 +104,7 @@ regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocat
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
comunque `200` con `partite: []`.
@@ -144,13 +144,13 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
| Obiettivi | Unit test | Integration test |
| ------------ | -------------------------------- | ------------------------------------------------------------ |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
| Obiettivi | Unit test | Integration test |
| ---------- | ----------------------------- | -------------------------------------------------------- |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
+16 -10
View File
@@ -11,7 +11,8 @@
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
giorno stesso.
giorno stesso. Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
compleanni non c'entrano: non richiedono palloni.
---
@@ -25,11 +26,14 @@ compaiono.
## Implementazione
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
turno già salvato, il candidato con meno turni fatti, poi quello che non lo fa da più
tempo, poi per ordine alfabetico — un algoritmo greedy, non un ordine fisso né solo per
data. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
- `eventiPalloni()` (`palloni-core.ts`) è il filtro comune a tutta la pipeline (proposte,
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
scarta eventi extra-campo e compleanni.
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
il candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data. Gli **allenamenti**
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
incaricato in `TurnoPalloni` (scelta della squadra).
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
upsert su `evento_id`.
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
@@ -71,10 +75,12 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
"assente" o "infortunato" per quell'evento.
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
dedicato in `palloni-core.test.ts`.
---
+8 -4
View File
@@ -24,8 +24,11 @@ attuale.
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
risposto". Sulla card in home/calendario (`EventoCard`) lo stato `infortunato` è offerto solo
per partite e allenamenti; sugli eventi extra-campo non è selezionabile (non pertinente).
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
(`RosaPresenze`, prop `tipo`).
---
@@ -41,8 +44,9 @@ risposte_presenze (Supabase)
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
↓ legge tutta la tabella)
RosaPresenze → src/components/crapp/RosaPresenze.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
allenamento.$id.tsx / partita.$id.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
partita.$id.tsx / evento.$id.tsx
EventoCard → src/components/crapp/EventoCard.tsx
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
+28
View File
@@ -0,0 +1,28 @@
# Copia in .env (permessi 600) e compila. Il .env non va mai committato.
# Progetto Supabase e chiave service role (salta la RLS: trattala come una password)
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=
# Account Gmail dedicato con verifica in due passaggi e una password per app
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>
# Facoltative (valori di default)
# MAIL_FROM_NAME=CrAPP
# MAIL_FROM= # default: SMTP_USER
# POLL_SECONDI=30
# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail
# MAIL_LOTTO=10
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false # true solo per la porta 465
+11
View File
@@ -0,0 +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/mailer
COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
RUN bun install --frozen-lockfile --production
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
+15
View File
@@ -0,0 +1,15 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "crapp-mailer",
"dependencies": {
"nodemailer": "^10.0.11",
},
},
},
"packages": {
"nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="],
}
}
+7
View File
@@ -0,0 +1,7 @@
[install]
saveTextLockfile = true
# 24h supply-chain guard: skip package versions published less than a day ago.
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = []
+18
View File
@@ -0,0 +1,18 @@
services:
mailer:
build:
context: ..
dockerfile: mailer/Dockerfile
restart: unless-stopped
env_file: .env
healthcheck:
test: ["CMD", "bun", "healthcheck.mjs"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"
+11
View File
@@ -0,0 +1,11 @@
// Healthcheck del container: sano se l'ultimo ciclo riuscito è recente (vedi mailer.mjs).
import { readFile } from "node:fs/promises";
import { staSano } from "./mailer-core.ts";
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
const MAX_MS = 5 * 60_000;
const ultimo = await readFile(FILE_SALUTE, "utf8")
.then((t) => Number(t))
.catch(() => null);
process.exit(staSano(Number.isFinite(ultimo) ? ultimo : null, Date.now(), MAX_MS) ? 0 : 1);
+481
View File
@@ -0,0 +1,481 @@
/**
* Logica pura del worker email (M22, DD-036), separata da rete e SMTP per poterla testare:
* configurazione, classificazione degli errori SMTP, backoff, composizione della mail e il
* ciclo di invio con le dipendenze iniettate. `mailer.mjs` fa solo da colla.
*/
/** Riga restituita da `prendi_notifiche_email()`. */
export type NotificaEmail = {
id_notifica: string;
destinatario: string;
oggetto: string;
testo: string;
id_evento: string | null;
tipo_notifica: string;
tentativi_fatti: number;
};
export type EsitoInvio = "inviata" | "riprova" | "differita" | "fallita";
export type MailComposta = {
to: string;
subject: string;
text: string;
html: string;
messageId: string;
};
// --- Configurazione ---------------------------------------------------------------------------
export type Config = {
supabaseUrl: string;
serviceRoleKey: string;
smtp: {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string } | null;
};
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;
};
export type LetturaConfig = { config: Config } | { errori: string[] };
/**
* Legge la configurazione dalle variabili d'ambiente. Torna tutti gli errori insieme (mancanti
* e non validi) per correggere il `.env` in un solo giro. `SMTP_USER`/`SMTP_PASS` vanno o
* entrambi o nessuno dei due: senza servono solo per un server di prova locale (Mailpit).
*/
export function leggiConfig(env: Record<string, string | undefined>): LetturaConfig {
const errori: string[] = [];
const testo = (nome: string): string => (env[nome] ?? "").trim();
const obbligatoria = (nome: string): string => {
const valore = testo(nome);
if (!valore) errori.push(`${nome} mancante`);
return valore;
};
const intero = (nome: string, predefinito: number): number => {
const grezzo = testo(nome);
if (!grezzo) return predefinito;
const n = Number(grezzo);
if (!Number.isInteger(n) || n <= 0) {
errori.push(`${nome} deve essere un intero positivo`);
return predefinito;
}
return n;
};
const supabaseUrl = obbligatoria("SUPABASE_URL").replace(/\/+$/, "");
const serviceRoleKey = obbligatoria("SUPABASE_SERVICE_ROLE_KEY");
const appUrl = obbligatoria("APP_URL").replace(/\/+$/, "");
const user = testo("SMTP_USER");
const pass = testo("SMTP_PASS");
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);
const lotto = intero("MAIL_LOTTO", 10);
if (errori.length > 0) return { errori };
return {
config: {
supabaseUrl,
serviceRoleKey,
smtp: {
host: testo("SMTP_HOST") || "smtp.gmail.com",
port: porta,
secure: testo("SMTP_SECURE") === "true",
auth: user ? { user, pass } : null,
},
from,
fromName: testo("MAIL_FROM_NAME") || "CrAPP",
appUrl,
vapid:
vapidPubblica && vapidPrivata
? {
publicKey: vapidPubblica,
privateKey: vapidPrivata,
subject: testo("VAPID_SUBJECT") || null,
}
: null,
pollSecondi,
limiteGiorno,
lotto,
},
};
}
// --- Errori SMTP e backoff --------------------------------------------------------------------
export type TipoErrore = "temporaneo" | "permanente" | "configurazione" | "limite";
type ErroreSmtp = { code?: unknown; responseCode?: unknown; response?: unknown; message?: unknown };
/**
* Distingue cosa fare dopo un invio fallito:
* - `limite`: tetto giornaliero di Gmail (`550 5.4.5 Daily user sending quota exceeded`, che
* sarebbe un 5xx "permanente" ma non dipende dalla mail): si rimanda senza contare un tentativo;
* - `configurazione`: credenziali SMTP rifiutate; stessa cosa, e il worker si ferma;
* - `permanente`: 5xx sul destinatario o sul messaggio, inutile riprovare;
* - `temporaneo`: 4xx e errori di rete, si riprova con backoff.
*/
export function classificaErroreSmtp(errore: unknown): TipoErrore {
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
const codice = typeof e.code === "string" ? e.code : "";
const risposta = typeof e.responseCode === "number" ? e.responseCode : 0;
const testo = `${typeof e.response === "string" ? e.response : ""} ${
typeof e.message === "string" ? e.message : ""
}`.toLowerCase();
if (/5\.4\.5|quota exceeded|sending limit/.test(testo)) return "limite";
if (codice === "EAUTH" || [530, 534, 535].includes(risposta)) return "configurazione";
if (codice === "EENVELOPE" || risposta >= 500) return "permanente";
return "temporaneo";
}
const RITARDI_MINUTI = [1, 5, 30, 120] as const;
/** Numero massimo di invii per notifica: il primo più un tentativo per ogni ritardo. */
export const MAX_TENTATIVI = RITARDI_MINUTI.length + 1;
/**
* Minuti da attendere dopo l'invio fallito numero `tentativiFatti + 1`; `null` quando i
* tentativi sono finiti e la mail va segnata come fallita.
*/
export function ritardoRiprova(tentativiFatti: number): number | null {
return RITARDI_MINUTI[tentativiFatti] ?? null;
}
/** Quante mail si possono ancora spedire rispetto al tetto delle ultime 24 ore. */
export function invioConsentiti(limiteGiorno: number, inviate24h: number, lotto: number): number {
return Math.max(0, Math.min(lotto, limiteGiorno - inviate24h));
}
/** Descrizione breve dell'errore, senza indirizzi né testi delle mail: va nei log. */
export function descriviErrore(errore: unknown): string {
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
const parti = [
typeof e.code === "string" ? e.code : "",
typeof e.responseCode === "number" ? String(e.responseCode) : "",
].filter(Boolean);
return parti.length > 0 ? parti.join(" ") : "errore sconosciuto";
}
// --- Composizione della mail ------------------------------------------------------------------
export function escapeHtml(testo: string): string {
return testo
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */
export function pulisciOggetto(titolo: string): string {
const pulito = titolo
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return (pulito || "Notifica CrAPP").slice(0, 200);
}
/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */
export function linkNotifica(appUrl: string, idEvento: string | null): string {
const base = appUrl.replace(/\/+$/, "");
return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base;
}
/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */
export function messageId(idNotifica: string, dominio: string): string {
return `<${idNotifica}@${dominio}>`;
}
const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email.";
export function componiMail(
n: NotificaEmail,
opzioni: { appUrl: string; dominioMessageId: string },
): MailComposta {
const link = linkNotifica(opzioni.appUrl, n.id_evento);
const subject = pulisciOggetto(n.oggetto);
const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE]
.filter(Boolean)
.join("\n\n");
const corpoHtml = n.testo.trim()
? `<p style="margin:0 0 16px">${escapeHtml(n.testo.trim()).replace(/\n/g, "<br>")}</p>`
: "";
const html = [
`<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:16px;line-height:1.5;color:#111;max-width:480px">`,
`<h2 style="margin:0 0 12px;font-size:18px">${escapeHtml(subject)}</h2>`,
corpoHtml,
`<p style="margin:0 0 24px"><a href="${escapeHtml(link)}" style="display:inline-block;padding:12px 20px;border-radius:12px;background:#111;color:#fff;text-decoration:none">Apri CrAPP</a></p>`,
`<p style="margin:0;font-size:12px;color:#666">${escapeHtml(AVVISO_DISATTIVAZIONE)}</p>`,
`</div>`,
].join("");
return {
to: n.destinatario,
subject,
text: testo,
html,
messageId: messageId(n.id_notifica, opzioni.dominioMessageId),
};
}
// --- Salute del container ---------------------------------------------------------------------
/** Il worker è sano se l'ultimo ciclo riuscito non è più vecchio di `maxMs`. */
export function staSano(ultimoCicloOk: number | null, adesso: number, maxMs: number): boolean {
return ultimoCicloOk !== null && adesso - ultimoCicloOk <= maxMs;
}
// --- Ciclo di invio ---------------------------------------------------------------------------
export type Dipendenze = {
prendi: (max: number) => Promise<NotificaEmail[]>;
segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise<void>;
invia: (mail: MailComposta) => Promise<void>;
inviateUltime24h: () => Promise<number>;
adesso: () => Date;
log: (messaggio: string) => void;
};
export type OpzioniCiclo = {
limiteGiorno: number;
lotto: number;
appUrl: string;
dominioMessageId: string;
};
export type RisultatoCiclo = {
inviate: number;
riprovate: number;
fallite: number;
differite: number;
/** Perché il ciclo si è interrotto prima di finire il lotto, se è successo. */
fermato: "limite" | "configurazione" | null;
};
const ATTESA_LIMITE_MINUTI = 60;
const ATTESA_CONFIGURAZIONE_MINUTI = 15;
const dopoMinuti = (adesso: Date, minuti: number) => new Date(adesso.getTime() + minuti * 60_000);
/**
* Un giro: prende fino a un lotto di mail entro il tetto giornaliero e le spedisce una a una.
* I log riportano solo id, tipo ed esito: mai indirizzi né testi.
*/
export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise<RisultatoCiclo> {
const risultato: RisultatoCiclo = {
inviate: 0,
riprovate: 0,
fallite: 0,
differite: 0,
fermato: null,
};
const consentite = invioConsentiti(o.limiteGiorno, await d.inviateUltime24h(), o.lotto);
if (consentite === 0) {
risultato.fermato = "limite";
return risultato;
}
const lotto = await d.prendi(consentite);
for (const [indice, notifica] of lotto.entries()) {
const mail = componiMail(notifica, o);
// Solo `invia` sta nel try: se fallisse la registrazione dell'esito non è un errore SMTP e
// non deve far ripartire una mail già consegnata (la riga resta 'in_invio' e viene
// recuperata dalla funzione SQL dopo 10 minuti: al peggio un doppione, mai una perdita).
let errore: unknown;
let consegnata = false;
try {
await d.invia(mail);
consegnata = true;
} catch (e) {
errore = e;
}
if (consegnata) {
await d.segna(notifica.id_notifica, "inviata");
risultato.inviate += 1;
d.log(`inviata ${notifica.id_notifica} (${notifica.tipo_notifica})`);
continue;
}
const tipo = classificaErroreSmtp(errore);
const motivo = descriviErrore(errore);
if (tipo === "limite" || tipo === "configurazione") {
// Non è colpa della mail: si rimanda questa e tutte le successive del lotto senza
// consumare tentativi, e il ciclo si ferma.
const prossimo = dopoMinuti(
d.adesso(),
tipo === "limite" ? ATTESA_LIMITE_MINUTI : ATTESA_CONFIGURAZIONE_MINUTI,
);
for (const restante of lotto.slice(indice)) {
await d.segna(restante.id_notifica, "differita", motivo, prossimo);
risultato.differite += 1;
}
risultato.fermato = tipo;
d.log(`ciclo fermato (${tipo}, ${motivo}): ${risultato.differite} mail rimandate`);
return risultato;
}
const ritardo = tipo === "temporaneo" ? ritardoRiprova(notifica.tentativi_fatti) : null;
if (ritardo === null) {
await d.segna(notifica.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`fallita ${notifica.id_notifica} (${notifica.tipo_notifica}, ${tipo}, ${motivo})`);
} else {
await d.segna(notifica.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
risultato.riprovate += 1;
d.log(`da riprovare ${notifica.id_notifica} tra ${ritardo} min (${motivo})`);
}
}
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;
}
+141
View File
@@ -0,0 +1,141 @@
// 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 { 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";
const lettura = leggiConfig(process.env);
if ("errori" in lettura) {
console.error(`Configurazione non valida:\n- ${lettura.errori.join("\n- ")}`);
process.exit(1);
}
const { config } = lettura;
const log = (messaggio) => console.log(`${new Date().toISOString()} ${messaggio}`);
async function rpc(nome, corpo) {
const risposta = await fetch(`${config.supabaseUrl}/rest/v1/rpc/${nome}`, {
method: "POST",
headers: {
apikey: config.serviceRoleKey,
Authorization: `Bearer ${config.serviceRoleKey}`,
"content-type": "application/json",
},
body: JSON.stringify(corpo),
signal: AbortSignal.timeout(15_000),
});
if (!risposta.ok) throw new Error(`rpc ${nome}: HTTP ${risposta.status}`);
const testo = await risposta.text();
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,
secure: config.smtp.secure,
...(config.smtp.auth ? { auth: config.smtp.auth } : {}),
});
const dominioMessageId = config.from.split("@")[1] ?? "crapp.local";
const dipendenze = {
prendi: (max) => rpc("prendi_notifiche_email", { p_max: max }),
segna: (id, esito, errore, prossimo) =>
rpc("esito_notifica_email", {
p_id: id,
p_esito: esito,
p_errore: errore ?? null,
p_prossimo: prossimo ? prossimo.toISOString() : null,
}),
invia: (mail) =>
trasporto.sendMail({
from: { name: config.fromName, address: config.from },
to: mail.to,
subject: mail.subject,
text: mail.text,
html: mail.html,
messageId: mail.messageId,
}),
inviateUltime24h: () => rpc("email_inviate_ultime_24h", {}),
adesso: () => new Date(),
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,
appUrl: config.appUrl,
dominioMessageId,
};
let fermo = false;
for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo = true));
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`),
);
while (!fermo) {
try {
const esito = await elaboraCiclo(dipendenze, opzioni);
// Un ciclo fermato dalle credenziali SMTP non segna il container come sano: dopo qualche
// minuto `docker ps` lo mostra "unhealthy" invece di lasciar credere che tutto funzioni.
if (esito.fermato !== "configurazione") await writeFile(FILE_SALUTE, String(Date.now()));
} 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");
+12
View File
@@ -0,0 +1,12 @@
{
"name": "crapp-mailer",
"private": true,
"type": "module",
"description": "Worker delle notifiche email di CrAPP (M22, DD-036)",
"scripts": {
"start": "bun mailer.mjs"
},
"dependencies": {
"nodemailer": "^10.0.11"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"sideEffects": false,
"type": "module",
@@ -153,6 +153,10 @@ export function BarraSottosezioni({
dragElastic={0.12}
dragMomentum={false}
onDragEnd={(_, info) => {
// Uno scroll verticale (anche veloce) ha sempre offset.y ben maggiore di
// offset.x: la velocity.x, invece, può essere rumorosa su gesti rapidi e da
// sola non basta a distinguere uno swipe reale da un semplice scroll in giù.
if (Math.abs(info.offset.x) <= Math.abs(info.offset.y)) return;
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) vaiA(indice + 1);
else if (arrivo > 60) vaiA(indice - 1);
+9 -12
View File
@@ -15,7 +15,7 @@ import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
@@ -26,9 +26,7 @@ const tipoMeta = {
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
} as const;
const statiSportivi: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
/** Eventi extra-campo (pizze, uscite…): l'infortunio non è una risposta pertinente. */
const statiEventoExtra: Stato[] = ["presente", "forse", "ritardo", "assente"];
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
const iconeStato: Record<Stato, typeof Check> = {
@@ -51,6 +49,9 @@ export function linkPerEvento(e: Evento) {
label: "Dettaglio allenamento",
};
}
if (e.tipo === "evento") {
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
}
return undefined;
}
@@ -87,15 +88,11 @@ export function EventoCard({
const isCompleanno = evento.tipo === "compleanno";
const passato = evento.data < dataOggi();
const cliccabile = Boolean(linkTo);
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
/** Anteprima delle note anche in card per gli eventi extra-campo (oltre al dettaglio). */
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
const stati =
evento.tipo === "evento"
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
stato === "infortunato"
? [...statiEventoExtra, "infortunato" as const]
: statiEventoExtra
: statiSportivi;
const stati = includeInfortunato(evento.tipo, stato)
? [...statiBase, "infortunato" as const]
: statiBase;
if (isCompleanno) {
return (
+15 -4
View File
@@ -6,6 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import type { EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
@@ -15,7 +16,16 @@ import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
export function RosaPresenze({
eventoId,
data,
tipo = "allenamento",
}: {
eventoId: string;
data: string;
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo;
}) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
@@ -32,6 +42,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
const risposteN = rosa.length - mancanti.length;
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine;
async function sollecita() {
setSollecito(true);
@@ -67,7 +78,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
<div className="mt-3 flex flex-wrap gap-1.5">
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const n = rosa.filter((g) => risposte[g.id] === s).length;
return (
<span
@@ -92,7 +103,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
La tua risposta
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const attivo = risposte[io.id] === s;
return (
<button
@@ -140,7 +151,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
</p>
) : null}
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const lista = rosa.filter((g) => risposte[g.id] === s);
if (lista.length === 0) return null;
return (
+1 -1
View File
@@ -69,7 +69,7 @@ export function SondaggioCacche({
toast.success(
dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con le notifiche attive",
: "Avviso inviato in app e per email (nessun dispositivo con le notifiche attive)",
);
} catch {
toast.error("Non sono riuscito a inviare la notifica");
+26
View File
@@ -1,7 +1,33 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { oggiISO } from "./palloni-core";
/**
* Avviso «sondaggio pre-partita aperto» (push, centro notifiche ed email, DD-038): stesso testo su
* tutti e tre i canali.
*/
export function avvisoSondaggio(partita: { titolo: string; ora: string }): {
titolo: string;
testo: string;
} {
return {
titolo: "💩 Sondaggio pre-partita aperto",
testo: `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`,
};
}
/**
* Chi riceve l'avviso in-app e per email: i giocatori attivi, mai gli allenatori, che il sondaggio
* non riguarda (DD-034). Non dipende dai dispositivi iscritti alla push: l'avviso in-app e la mail
* arrivano anche a chi non ha attivato le notifiche.
*/
export function destinatariSondaggio(
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
): string[] {
return squadra.filter(inRosa).map((g) => g.id);
}
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
export const ORA_APERTURA_SONDAGGIO = 8;
+3 -2
View File
@@ -4,7 +4,7 @@ import { useGiocatoreId } from "./user-store";
/**
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
* lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
* lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
* parallelo alla push esistente). Qui si legge e si segna come letto, con
@@ -16,7 +16,8 @@ export type TipoNotifica =
| "evento_promemoria_24h"
| "evento_promemoria_3h"
| "turno_palloni"
| "sollecita_presenze";
| "sollecita_presenze"
| "sondaggio_cacche";
export type NotificaUtente = {
id: string;
+3 -3
View File
@@ -8,10 +8,10 @@ export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: st
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
export type CandidatoTurno = { id: string; nome: string };
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
/** Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data. */
export function eventiPalloni(eventi: Evento[]): Evento[] {
return eventi
.filter((e) => e.tipo !== "compleanno")
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
.slice()
.sort((a, b) => a.data.localeCompare(b.data));
}
@@ -21,7 +21,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
* meno volte (a parità, chi non lo fa da più tempo).
*
* Gli **allenamenti** non vengono proposti: restano «da assegnare» finché qualcuno
* non conferma un incaricato a mano. Su partite ed eventi extra la rotazione resta.
* non conferma un incaricato a mano. Sulle partite la rotazione resta.
*/
export function completaTurni(
turni: Record<string, string>,
+60
View File
@@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import { useSessione } from "./auth";
/**
* Preferenze del singolo account (M22), non dello slot giocatore: oggi solo l'interruttore
* «Email» di Profilo → Opzioni (DD-036). Nessuna riga in `preferenze_utente` vuol dire
* «mail attive»: la riga nasce solo quando il giocatore tocca l'interruttore. Lettura e
* scrittura passano dalla RLS (ognuno vede e scrive solo la propria), senza route API.
*/
export type RigaPreferenze = { email_notifiche: boolean };
/** Assenza di riga = valori di default: le email sono attive. */
export function emailAttive(riga: RigaPreferenze | null | undefined): boolean {
return riga?.email_notifiche ?? true;
}
const chiavePreferenze = (utenteId: string | null) => ["preferenze-utente", utenteId] as const;
/** Stato dell'interruttore «Email» dell'account e mutazione per cambiarlo. */
export function useEmailNotifiche() {
const { utenteId } = useSessione();
const qc = useQueryClient();
const query = useQuery({
queryKey: chiavePreferenze(utenteId),
queryFn: async (): Promise<RigaPreferenze | null> => {
const { data, error } = await supabaseNuoveTabelle
.from("preferenze_utente")
.select("email_notifiche")
.eq("auth_user_id", utenteId)
.maybeSingle();
if (error) throw error;
return (data as RigaPreferenze | null) ?? null;
},
enabled: !!utenteId,
staleTime: 5 * 60_000,
});
const imposta = useMutation({
mutationFn: async (attive: boolean) => {
const { error } = await supabaseNuoveTabelle.from("preferenze_utente").upsert(
{
auth_user_id: utenteId,
email_notifiche: attive,
aggiornata_il: new Date().toISOString(),
},
{ onConflict: "auth_user_id" },
);
if (error) throw error;
return attive;
},
onSuccess: (attive) => {
const riga: RigaPreferenze = { email_notifiche: attive };
qc.setQueryData(chiavePreferenze(utenteId), riga);
},
});
return { attive: emailAttive(query.data), pronta: !query.isPending, imposta };
}
+11 -1
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import type { Evento, EventoTipo } from "./eventi";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { aggiornaSerie } from "./serie";
import { dataOggi } from "./scout-live";
@@ -71,6 +71,16 @@ export function totaliEventiGiocatore(
return eventiContanoPresenze(eventi, giocatoreId, oggi).length;
}
/**
* Se "infortunato" va offerto come risposta: sì per allenamenti e partite, no per gli
* eventi extra-campo (`"evento"`, es. cena di squadra), dove l'infortunio non è una
* risposta pertinente. Resta comunque visibile a chi ce l'ha già (dati storici, o un
* evento cambiato tipo dopo la risposta), così può ancora toglierla.
*/
export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null): boolean {
return tipo !== "evento" || statoAttuale === "infortunato";
}
/**
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
+42
View File
@@ -19,10 +19,12 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
import { Route as ScoutRouteImport } from './routes/scout'
import { Route as SquadraRouteImport } from './routes/squadra'
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
import { Route as EventoIdRouteImport } from './routes/evento.$id'
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
import { Route as ApiPublicKeepaliveRouteImport } from './routes/api/public/keepalive'
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
@@ -81,6 +83,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
path: '/allenamento/$id',
getParentRoute: () => rootRouteImport,
} as any)
const EventoIdRoute = EventoIdRouteImport.update({
id: '/evento/$id',
path: '/evento/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
id: '/partita-csi/$id',
path: '/partita-csi/$id',
@@ -101,6 +108,11 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
path: '/api/public/csi',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicKeepaliveRoute = ApiPublicKeepaliveRouteImport.update({
id: '/api/public/keepalive',
path: '/api/public/keepalive',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificaPersonalizzataRoute =
ApiPublicNotificaPersonalizzataRouteImport.update({
id: '/api/public/notifica-personalizzata',
@@ -152,10 +164,12 @@ export interface FileRoutesByFullPath {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/evento/$id': typeof EventoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
@@ -175,10 +189,12 @@ export interface FileRoutesByTo {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/evento/$id': typeof EventoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
@@ -199,10 +215,12 @@ export interface FileRoutesById {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/evento/$id': typeof EventoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
@@ -224,10 +242,12 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/evento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/keepalive'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
@@ -247,10 +267,12 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/evento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/keepalive'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
@@ -270,10 +292,12 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/evento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/keepalive'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
@@ -294,10 +318,12 @@ export interface RootRouteChildren {
ScoutRoute: typeof ScoutRoute
SquadraRoute: typeof SquadraRoute
AllenamentoIdRoute: typeof AllenamentoIdRoute
EventoIdRoute: typeof EventoIdRoute
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicKeepaliveRoute: typeof ApiPublicKeepaliveRoute
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
@@ -379,6 +405,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AllenamentoIdRouteImport
parentRoute: typeof rootRouteImport
}
'/evento/$id': {
id: '/evento/$id'
path: '/evento/$id'
fullPath: '/evento/$id'
preLoaderRoute: typeof EventoIdRouteImport
parentRoute: typeof rootRouteImport
}
'/partita-csi/$id': {
id: '/partita-csi/$id'
path: '/partita-csi/$id'
@@ -407,6 +440,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicCsiRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/keepalive': {
id: '/api/public/keepalive'
path: '/api/public/keepalive'
fullPath: '/api/public/keepalive'
preLoaderRoute: typeof ApiPublicKeepaliveRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifica-personalizzata': {
id: '/api/public/notifica-personalizzata'
path: '/api/public/notifica-personalizzata'
@@ -470,10 +510,12 @@ const rootRouteChildren: RootRouteChildren = {
ScoutRoute: ScoutRoute,
SquadraRoute: SquadraRoute,
AllenamentoIdRoute: AllenamentoIdRoute,
EventoIdRoute: EventoIdRoute,
PartitaCsiIdRoute: PartitaCsiIdRoute,
PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicKeepaliveRoute: ApiPublicKeepaliveRoute,
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
+1 -1
View File
@@ -91,7 +91,7 @@ function AllenamentoDetail() {
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} />
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
</Section>
</>
);
+23 -5
View File
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { avvisoSondaggio, destinatariSondaggio } from "@/lib/cacche";
import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server";
import { isAllenatore } from "@/lib/giocatori-squadra";
@@ -27,14 +29,30 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
const { data: tutte } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, p256dh, auth, giocatore_id");
const squadra = await leggiGiocatoriSquadra();
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
const allenatori = new Set(
(await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id),
);
const allenatori = new Set(squadra.filter(isAllenatore).map((g) => g.id));
const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id));
const titolo = "💩 Sondaggio pre-partita aperto";
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
const { titolo, testo } = avvisoSondaggio(partita);
// Storico in-app ed email (DD-038): indipendente dai dispositivi iscritti alla push. Un
// upsert perché ripremere il pulsante per la stessa partita deve riportare la notifica a
// non letta, non fallire per il vincolo UNIQUE; la mail riparte dal trigger di M22.
// `types.ts` non include ancora `notifiche_utente`, stesso aggiramento delle altre route.
const client = supabaseAdmin as unknown as SupabaseClient;
await client.from("notifiche_utente").upsert(
destinatariSondaggio(squadra).map((giocatoreId) => ({
giocatore_id: giocatoreId,
tipo: "sondaggio_cacche",
titolo,
corpo: testo,
evento_id: partita.id,
letta: false,
creato_il: new Date().toISOString(),
})),
{ onConflict: "giocatore_id,evento_id,tipo" },
);
let inviate = 0;
for (const iscrizione of iscrizioni) {
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
/**
* Chiamata dal Vercel Cron Job (vedi vercel.json) per tenere attivo il progetto Supabase
* free tier, che va in pausa dopo 7 giorni senza richieste API. Una lettura minima basta:
* nessun dato sensibile, nessun effetto collaterale, quindi nessuna autenticazione richiesta.
*/
export const Route = createFileRoute("/api/public/keepalive")({
server: {
handlers: {
GET: async () => {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { error } = await supabaseAdmin
.from("giocatori_squadra")
.select("id", { count: "exact", head: true })
.limit(1);
if (error) {
console.error("keepalive", error);
return new Response("Errore", { status: 500 });
}
return new Response("ok");
},
},
},
});
+95
View File
@@ -0,0 +1,95 @@
import { createFileRoute } from "@tanstack/react-router";
import { MapPin, Clock, Users, PartyPopper } from "lucide-react";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
import { usePresenzeEvento } from "@/lib/presenze";
export const Route = createFileRoute("/evento/$id")({
head: () => {
const titolo = "Dettaglio evento";
return {
meta: [
{ title: `${titolo} — CrAPP` },
{
name: "description",
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
},
{ property: "og:title", content: `${titolo} — CrAPP` },
{
property: "og:description",
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
],
};
},
component: EventoDetail,
});
function EventoDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const { risposte } = usePresenzeEvento(id);
const rosa = useRosa();
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
).length;
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
</div>
);
}
return (
<>
<PageHeader titolo="Evento" sottotitolo={formatData(evento.data)} />
<Section titolo={evento.titolo}>
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center gap-3">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-warning/15 text-warning">
<PartyPopper className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Evento extra-campo
</p>
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
</div>
{evento.note ? (
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
</div>
</div>
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
</Section>
</>
);
}
+1 -1
View File
@@ -295,7 +295,7 @@ function PartitaDetail() {
) : null}
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} />
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
</Section>
</>
);
+39 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Flame, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
import { Flame, Trash2, Bell, Mail, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
@@ -34,6 +34,7 @@ import {
} from "@/lib/giocatori-squadra";
import { esci } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { useEmailNotifiche } from "@/lib/preferenze-utente";
import { Reveal } from "@/components/motion/Reveal";
import { version as APP_VERSION } from "../../package.json";
@@ -255,6 +256,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true);
const email = useEmailNotifiche();
useEffect(() => {
setSupportate(pushSupportato());
@@ -283,6 +285,17 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
}
}
async function cambiaEmail() {
if (email.imposta.isPending) return;
const attive = !email.attive;
try {
await email.imposta.mutateAsync(attive);
toast.success(attive ? "Email attive" : "Email disattivate");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Impossibile salvare la preferenza");
}
}
async function logout() {
try {
await esci();
@@ -322,6 +335,31 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
<Bell className="h-4 w-4" />
</span>
</button>
<button
type="button"
onClick={cambiaEmail}
disabled={!email.pronta || email.imposta.isPending}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Email</span>
<span className="block text-xs text-muted-foreground">
{email.attive
? "Le notifiche arrivano anche via email"
: "Nessuna email, solo notifiche in app"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
email.attive
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Mail className="h-4 w-4" />
</span>
</button>
{admin ? (
<Link
to="/admin"
+40 -24
View File
@@ -130,31 +130,47 @@ function Squadra() {
const contenutoRosa = useMemo(
() => (
<div className="space-y-2">
{allenatori.map((a) => (
<article
key={a.id}
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
>
<Avatar
id={a.id}
fallback={inizialiDa(nomeCompleto(a))}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
{nomeCompleto(a)}
</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={ruoloVisibile(a)} />
{a.nascita ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
{allenatori.map((a) => {
const isOpen = aperto === a.id;
return (
<article key={a.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={() => setAperto(isOpen ? null : a.id)}
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
aria-expanded={isOpen}
>
<Avatar
id={a.id}
fallback={inizialiDa(nomeCompleto(a))}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
{nomeCompleto(a)}
</span>
) : null}
</span>
</span>
</article>
))}
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={ruoloVisibile(a)} />
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen && a.nascita ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
</p>
</div>
) : null}
</article>
);
})}
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = sonoAllenatore
@@ -0,0 +1,239 @@
-- M22 — Notifiche email (DD-036)
--
-- Ogni riga di `notifiche_utente` diventa anche una mail, spedita da un worker Docker esterno
-- a Supabase (cartella `mailer/`) che interroga il database e invia via SMTP. Nessuna sorgente
-- di M17 cambia: un trigger accoda le notifiche NUOVE, quelle già presenti non vengono mai
-- inviate (il trigger non ha un backfill).
--
-- Tre pezzi:
-- 1. `notifiche_email_coda`: stato d'invio di ogni notifica. Tabella a parte, con RLS senza
-- alcuna policy: la policy UPDATE di `notifiche_utente` lascerebbe a un client la
-- possibilità di riportare a 'in_coda' una mail già partita. Solo la service role (il
-- worker) la legge e la scrive.
-- 2. `preferenze_utente`: interruttore «Email» per ACCOUNT (non per slot giocatore).
-- Nessuna riga = mail attive; la riga nasce quando il giocatore tocca l'interruttore.
-- 3. Tre funzioni RPC riservate alla service role, con cui il worker prende un lotto,
-- registra l'esito e conta le mail delle ultime 24 ore (tetto giornaliero di Gmail).
-- --- Coda ------------------------------------------------------------------------------------
CREATE TABLE public.notifiche_email_coda (
notifica_id uuid PRIMARY KEY REFERENCES public.notifiche_utente(id) ON DELETE CASCADE,
stato text NOT NULL DEFAULT 'in_coda'
CHECK (stato IN ('in_coda', 'in_invio', 'inviata', 'fallita', 'saltata')),
tentativi integer NOT NULL DEFAULT 0,
prossimo_tentativo timestamptz NOT NULL DEFAULT now(),
errore text,
inviata_il timestamptz,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.notifiche_email_coda IS
'Coda delle mail per le notifiche in-app (M22): una riga per notifica, scritta solo dal trigger e dal worker.';
CREATE INDEX notifiche_email_coda_da_inviare_idx
ON public.notifiche_email_coda (prossimo_tentativo)
WHERE stato = 'in_coda';
CREATE INDEX notifiche_email_coda_inviate_idx
ON public.notifiche_email_coda (inviata_il)
WHERE stato = 'inviata';
ALTER TABLE public.notifiche_email_coda ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.notifiche_email_coda FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.notifiche_email_coda TO service_role;
-- --- Preferenze per account ------------------------------------------------------------------
CREATE TABLE public.preferenze_utente (
auth_user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email_notifiche boolean NOT NULL DEFAULT true,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.preferenze_utente IS
'Preferenze del singolo account (non dello slot giocatore). Nessuna riga = valori di default.';
REVOKE ALL ON public.preferenze_utente FROM PUBLIC, anon;
GRANT SELECT, INSERT, UPDATE ON public.preferenze_utente TO authenticated;
GRANT ALL ON public.preferenze_utente TO service_role;
ALTER TABLE public.preferenze_utente ENABLE ROW LEVEL SECURITY;
CREATE POLICY "L'utente legge le proprie preferenze" ON public.preferenze_utente
FOR SELECT TO authenticated
USING (auth_user_id = auth.uid());
CREATE POLICY "L'utente crea le proprie preferenze" ON public.preferenze_utente
FOR INSERT TO authenticated
WITH CHECK (auth_user_id = auth.uid());
CREATE POLICY "L'utente aggiorna le proprie preferenze" ON public.preferenze_utente
FOR UPDATE TO authenticated
USING (auth_user_id = auth.uid())
WITH CHECK (auth_user_id = auth.uid());
-- --- Trigger: ogni notifica nuova (o rinviata) finisce in coda ---------------------------------
--
-- `UPDATE OF creato_il` intercetta l'`upsert` di turno palloni e sollecito presenze, che
-- riscrive `creato_il`: è un rinvio voluto (testo fresco, `letta` a false) e rimette in coda
-- anche la mail. "Segna come letta" e l'ON CONFLICT DO NOTHING dei cron non toccano quella
-- colonna e non accodano nulla.
CREATE OR REPLACE FUNCTION public.accoda_notifica_email()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
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;
$$;
CREATE TRIGGER notifiche_utente_accoda_email
AFTER INSERT OR UPDATE OF creato_il ON public.notifiche_utente
FOR EACH ROW EXECUTE FUNCTION public.accoda_notifica_email();
-- --- RPC del worker ----------------------------------------------------------------------------
-- Prende fino a `p_max` mail da spedire e le marca 'in_invio'. `FOR UPDATE SKIP LOCKED`: due
-- worker avviati per errore non prendono mai la stessa riga. Prima ripulisce due casi:
-- * righe rimaste 'in_invio' da più di 10 minuti (worker morto a metà invio) tornano 'in_coda';
-- * righe senza destinatario (slot non collegato a un account, email assente, preferenza
-- spenta) diventano 'saltata', senza errore.
-- L'indirizzo è `giocatori_squadra.email`, la stessa che collega account e giocatore (DD-018).
CREATE OR REPLACE FUNCTION public.prendi_notifiche_email(p_max integer DEFAULT 20)
RETURNS TABLE (
id_notifica uuid,
destinatario text,
oggetto text,
testo text,
id_evento text,
tipo_notifica text,
tentativi_fatti integer
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.notifiche_email_coda c
SET stato = 'in_coda', aggiornata_il = now()
WHERE c.stato = 'in_invio'
AND c.aggiornata_il < now() - interval '10 minutes';
UPDATE public.notifiche_email_coda c
SET stato = 'saltata', aggiornata_il = now()
FROM public.notifiche_utente n
JOIN public.giocatori_squadra g ON g.id = n.giocatore_id
WHERE c.notifica_id = n.id
AND c.stato = 'in_coda'
AND c.prossimo_tentativo <= now()
AND (
g.auth_user_id IS NULL
OR coalesce(btrim(g.email), '') = ''
OR EXISTS (
SELECT 1 FROM public.preferenze_utente p
WHERE p.auth_user_id = g.auth_user_id AND NOT p.email_notifiche
)
);
RETURN QUERY
WITH scelte AS (
SELECT c.notifica_id
FROM public.notifiche_email_coda c
WHERE c.stato = 'in_coda' AND c.prossimo_tentativo <= now()
ORDER BY c.prossimo_tentativo
LIMIT greatest(p_max, 0)
FOR UPDATE SKIP LOCKED
),
presi AS (
UPDATE public.notifiche_email_coda c
SET stato = 'in_invio', aggiornata_il = now()
FROM scelte s
WHERE c.notifica_id = s.notifica_id
RETURNING c.notifica_id, c.tentativi
)
SELECT p.notifica_id, g.email, n.titolo, n.corpo, n.evento_id, n.tipo, p.tentativi
FROM presi p
JOIN public.notifiche_utente n ON n.id = p.notifica_id
JOIN public.giocatori_squadra g ON g.id = n.giocatore_id
ORDER BY n.creato_il;
END;
$$;
-- Registra l'esito di un invio. Agisce solo su righe 'in_invio', così un esito in ritardo o
-- ripetuto non sovrascrive uno stato già deciso.
-- 'inviata' → stato inviata
-- 'riprova' → torna in coda dopo `p_prossimo`, conta un tentativo
-- 'differita'→ torna in coda dopo `p_prossimo`, NON conta un tentativo (limite giornaliero
-- di Gmail o configurazione SMTP da correggere: non è colpa della mail)
-- 'fallita' → definitiva
CREATE OR REPLACE FUNCTION public.esito_notifica_email(
p_id uuid,
p_esito text,
p_errore text DEFAULT NULL,
p_prossimo timestamptz DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF p_esito NOT IN ('inviata', 'riprova', 'differita', 'fallita') THEN
RAISE EXCEPTION 'esito non valido: %', p_esito;
END IF;
UPDATE public.notifiche_email_coda c
SET stato = CASE p_esito
WHEN 'inviata' THEN 'inviata'
WHEN 'fallita' THEN 'fallita'
ELSE 'in_coda'
END,
tentativi = c.tentativi + CASE WHEN p_esito IN ('riprova', 'fallita') THEN 1 ELSE 0 END,
prossimo_tentativo = CASE
WHEN p_esito IN ('riprova', 'differita')
THEN coalesce(p_prossimo, now())
ELSE c.prossimo_tentativo
END,
errore = CASE WHEN p_esito = 'inviata' THEN NULL ELSE left(p_errore, 500) END,
inviata_il = CASE WHEN p_esito = 'inviata' THEN now() ELSE c.inviata_il END,
aggiornata_il = now()
WHERE c.notifica_id = p_id AND c.stato = 'in_invio';
END;
$$;
-- Mail inviate nelle ultime 24 ore: il worker la usa per restare sotto il tetto di Gmail.
CREATE OR REPLACE FUNCTION public.email_inviate_ultime_24h()
RETURNS integer
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT count(*)::integer
FROM public.notifiche_email_coda
WHERE stato = 'inviata' AND inviata_il > now() - interval '24 hours';
$$;
REVOKE ALL ON FUNCTION public.accoda_notifica_email() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.prendi_notifiche_email(integer) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.esito_notifica_email(uuid, text, text, timestamptz)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.email_inviate_ultime_24h() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.prendi_notifiche_email(integer) TO service_role;
GRANT EXECUTE ON FUNCTION public.esito_notifica_email(uuid, text, text, timestamptz)
TO service_role;
GRANT EXECUTE ON FUNCTION public.email_inviate_ultime_24h() TO service_role;
@@ -0,0 +1,72 @@
-- M23 — I promemoria evento non ricompaiono più se il giocatore elimina la notifica (DD-037)
--
-- Fino a M22 `genera_promemoria_eventi()` evitava i doppioni con `ON CONFLICT DO NOTHING` sul
-- vincolo UNIQUE (giocatore_id, evento_id, tipo) di `notifiche_utente`: la riga della notifica
-- era l'unica traccia che il promemoria fosse già stato generato. Ma il giocatore può eliminare
-- la notifica (swipe o ×, DD-030): senza più la riga, al giro successivo del job (ogni ora per
-- il promemoria a 24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il
-- promemoria veniva rigenerato come non letto e, dal trigger di M22, riaccodato per email.
--
-- Il registro `promemoria_eventi_generati` ricorda in modo indipendente che il promemoria è già
-- stato generato per (giocatore, evento, tipo). Eliminare la notifica non lo tocca: il
-- promemoria non torna, e non parte una nuova mail. Il registro sparisce con l'evento o con il
-- giocatore (ON DELETE CASCADE), come le altre tabelle collegate (DD-029).
CREATE TABLE public.promemoria_eventi_generati (
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
evento_id text NOT NULL REFERENCES public.eventi_app(id) ON DELETE CASCADE,
tipo text NOT NULL CHECK (tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h')),
generato_il timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (giocatore_id, evento_id, tipo)
);
COMMENT ON TABLE public.promemoria_eventi_generati IS
'Promemoria evento già generati per giocatore, evento e tipo: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M23, DD-037).';
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
ALTER TABLE public.promemoria_eventi_generati ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.promemoria_eventi_generati FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.promemoria_eventi_generati TO service_role;
-- I promemoria già presenti in `notifiche_utente` risultano generati. Senza questo, la prima
-- notifica eliminata dopo la migration verrebbe rigenerata come prima.
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, tipo
FROM public.notifiche_utente
WHERE tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h')
AND evento_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- Stessa firma e stessi criteri di M17 (i cron non cambiano). La differenza: prima si registra
-- il promemoria come generato, e la notifica si crea solo per le righe appena registrate. Il
-- vincolo UNIQUE su `notifiche_utente` resta come seconda difesa.
CREATE OR REPLACE FUNCTION public.genera_promemoria_eventi(p_tipo text, p_finestra interval)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
WITH da_generare AS (
SELECT d AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora
FROM public.eventi_app e
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
WHERE public.ora_evento_a_time(e.ora) IS NOT NULL
AND ((e.data + public.ora_evento_a_time(e.ora)) AT TIME ZONE 'Europe/Rome') - now()
BETWEEN interval '0' AND p_finestra
),
nuovi AS (
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
SELECT giocatore_id, evento_id, p_tipo FROM da_generare
ON CONFLICT DO NOTHING
RETURNING giocatore_id, evento_id
)
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
SELECT g.giocatore_id, p_tipo, 'Promemoria: ' || g.titolo,
to_char(g.data, 'DD/MM/YYYY') || ' alle ' || g.ora,
g.evento_id
FROM nuovi n
JOIN da_generare g ON g.giocatore_id = n.giocatore_id AND g.evento_id = n.evento_id
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
END;
$$;
@@ -0,0 +1,197 @@
-- M24 — Tre canali per ogni notifica: push dei promemoria e sondaggio in-app/email (DD-038)
--
-- Due lacune rispetto al requisito «ogni notifica arriva per push, in-app ed email»:
--
-- 1. I promemoria a 24 e 3 ore nascono nel database (job pg_cron, M17), che non può firmare né
-- cifrare una push Web: arrivavano solo in-app e per email. Qui si accodano in
-- `notifiche_push_coda`; la push la manda il worker `mailer/`, con lo stesso modulo
-- dell'app (`src/lib/webpush.server.ts`).
-- 2. Il sondaggio pre-partita partiva solo come push. La route `apri-sondaggio` ora scrive anche
-- `notifiche_utente` (tipo nuovo `sondaggio_cacche`): compare nel centro notifiche e la
-- mail parte dal trigger di M22. Serve solo estendere il vincolo CHECK sul tipo.
--
-- Come per le email (M22), la coda è una tabella a parte con RLS senza policy: la policy UPDATE
-- del giocatore su `notifiche_utente` gli permetterebbe di riportare a 'in_coda' una push già
-- partita. Solo la service role (il worker) la legge e la scrive.
-- --- Tipo `sondaggio_cacche` -------------------------------------------------------------------
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
tipo IN (
'admin',
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni',
'sollecita_presenze',
'sondaggio_cacche'
)
);
-- --- Coda delle push dei promemoria ------------------------------------------------------------
CREATE TABLE public.notifiche_push_coda (
notifica_id uuid PRIMARY KEY REFERENCES public.notifiche_utente(id) ON DELETE CASCADE,
stato text NOT NULL DEFAULT 'in_coda'
CHECK (stato IN ('in_coda', 'in_invio', 'inviata', 'fallita', 'saltata')),
tentativi integer NOT NULL DEFAULT 0,
prossimo_tentativo timestamptz NOT NULL DEFAULT now(),
errore text,
inviata_il timestamptz,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.notifiche_push_coda IS
'Coda delle push per i promemoria evento (M24): una riga per notifica, scritta dal trigger e dal worker. Le altre notifiche mandano la push dall''app.';
CREATE INDEX notifiche_push_coda_da_inviare_idx
ON public.notifiche_push_coda (prossimo_tentativo)
WHERE stato = 'in_coda';
ALTER TABLE public.notifiche_push_coda ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.notifiche_push_coda FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.notifiche_push_coda TO service_role;
-- Solo i promemoria: turno palloni, sollecito presenze, messaggio dello staff e sondaggio la push
-- la mandano già dall'app, e una seconda dal worker sarebbe un doppione. Le notifiche già
-- presenti alla migration non si accodano: il trigger non ha backfill.
CREATE OR REPLACE FUNCTION public.accoda_push_promemoria()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.notifiche_push_coda (notifica_id)
VALUES (NEW.id)
ON CONFLICT (notifica_id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
AFTER INSERT ON public.notifiche_utente
FOR EACH ROW
WHEN (NEW.tipo IN ('evento_promemoria_24h', 'evento_promemoria_3h'))
EXECUTE FUNCTION public.accoda_push_promemoria();
-- --- RPC del worker ----------------------------------------------------------------------------
-- Prende fino a `p_max` push da spedire, le marca 'in_invio' e restituisce per ciascuna il testo e
-- le iscrizioni (dispositivi) del giocatore. `FOR UPDATE SKIP LOCKED`: due worker avviati per
-- errore non prendono mai la stessa riga. Prima ripulisce tre casi:
-- * righe ferme 'in_invio' da più di 10 minuti (worker morto a metà) tornano 'in_coda';
-- * promemoria creati da più di 3 ore diventano 'saltata': una push arrivata a evento iniziato
-- è solo rumore (worker fermo a lungo);
-- * giocatori senza nessun dispositivo iscritto diventano 'saltata', senza errore.
CREATE OR REPLACE FUNCTION public.prendi_push_promemoria(p_max integer DEFAULT 20)
RETURNS TABLE (
id_notifica uuid,
oggetto text,
testo text,
tentativi_fatti integer,
iscrizioni jsonb
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.notifiche_push_coda c
SET stato = 'in_coda', aggiornata_il = now()
WHERE c.stato = 'in_invio'
AND c.aggiornata_il < now() - interval '10 minutes';
UPDATE public.notifiche_push_coda c
SET stato = 'saltata', aggiornata_il = now()
FROM public.notifiche_utente n
WHERE c.notifica_id = n.id
AND c.stato = 'in_coda'
AND c.prossimo_tentativo <= now()
AND (
n.creato_il < now() - interval '3 hours'
OR NOT EXISTS (
SELECT 1 FROM public.push_subscriptions s WHERE s.giocatore_id = n.giocatore_id
)
);
RETURN QUERY
WITH scelte AS (
SELECT c.notifica_id
FROM public.notifiche_push_coda c
WHERE c.stato = 'in_coda' AND c.prossimo_tentativo <= now()
ORDER BY c.prossimo_tentativo
LIMIT greatest(p_max, 0)
FOR UPDATE SKIP LOCKED
),
presi AS (
UPDATE public.notifiche_push_coda c
SET stato = 'in_invio', aggiornata_il = now()
FROM scelte s
WHERE c.notifica_id = s.notifica_id
RETURNING c.notifica_id, c.tentativi
)
SELECT p.notifica_id,
n.titolo,
n.corpo,
p.tentativi,
coalesce(
(SELECT jsonb_agg(jsonb_build_object(
'endpoint', s.endpoint, 'p256dh', s.p256dh, 'auth', s.auth))
FROM public.push_subscriptions s
WHERE s.giocatore_id = n.giocatore_id),
'[]'::jsonb
)
FROM presi p
JOIN public.notifiche_utente n ON n.id = p.notifica_id
ORDER BY n.creato_il;
END;
$$;
-- Registra l'esito. Agisce solo su righe 'in_invio', così un esito in ritardo non ne sovrascrive
-- uno già deciso.
-- 'inviata' → almeno un dispositivo ha accettato la push
-- 'riprova' → errore temporaneo, torna in coda dopo `p_prossimo`, conta un tentativo
-- 'fallita' → definitiva (rifiuto permanente o tentativi finiti)
-- 'saltata' → nessun dispositivo utile (tutte le iscrizioni erano scadute)
CREATE OR REPLACE FUNCTION public.esito_push_promemoria(
p_id uuid,
p_esito text,
p_errore text DEFAULT NULL,
p_prossimo timestamptz DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF p_esito NOT IN ('inviata', 'riprova', 'fallita', 'saltata') THEN
RAISE EXCEPTION 'esito non valido: %', p_esito;
END IF;
UPDATE public.notifiche_push_coda c
SET stato = CASE p_esito
WHEN 'riprova' THEN 'in_coda'
ELSE p_esito
END,
tentativi = c.tentativi + CASE WHEN p_esito IN ('riprova', 'fallita') THEN 1 ELSE 0 END,
prossimo_tentativo = CASE
WHEN p_esito = 'riprova' THEN coalesce(p_prossimo, now())
ELSE c.prossimo_tentativo
END,
errore = CASE WHEN p_esito = 'inviata' THEN NULL ELSE left(p_errore, 500) END,
inviata_il = CASE WHEN p_esito = 'inviata' THEN now() ELSE c.inviata_il END,
aggiornata_il = now()
WHERE c.notifica_id = p_id AND c.stato = 'in_invio';
END;
$$;
REVOKE ALL ON FUNCTION public.accoda_push_promemoria() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.prendi_push_promemoria(integer) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.esito_push_promemoria(uuid, text, text, timestamptz)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.prendi_push_promemoria(integer) TO service_role;
GRANT EXECUTE ON FUNCTION public.esito_push_promemoria(uuid, text, text, timestamptz)
TO service_role;
@@ -0,0 +1,25 @@
-- M25 — L'allenatore non riceve i promemoria degli eventi (DD-039)
--
-- La migration M21 (DD-034) aggiunse `g.tipo = 'allenatore'` ai destinatari dei promemoria a 24 e
-- 3 ore: l'allenatore attivo li riceveva sempre, convocato o no. Ma un promemoria chiede di
-- esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i
-- partecipanti: per un evento come la cena di squadra riceveva un avviso su cui non poteva fare
-- nulla. Da ora i destinatari sono solo i giocatori attivi: i convocati, o tutta la rosa se
-- `convocati` è vuoto.
--
-- Cambia solo questa funzione: `genera_promemoria_eventi()` (M17, M23), i job pg_cron, le code
-- email e push e il resto delle notifiche restano invariati. I promemoria già generati per un
-- allenatore prima della migration non vengono toccati. I messaggi dello staff continuano ad
-- arrivargli: non dipendono da questa funzione.
CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text)
RETURNS SETOF text
LANGUAGE sql STABLE AS $$
SELECT g.id
FROM public.eventi_app e
JOIN public.giocatori_squadra g
ON g.attivo = true
AND g.tipo = 'giocatore'
AND (cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
WHERE e.id = p_evento_id;
$$;
+20 -6
View File
@@ -24,12 +24,26 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Server di sviluppo con Node vecchio
I test che avviano il server (`api`, `notifiche-attive-route`, `notifiche-utente`, `permessi-route`,
`sondaggio-notifiche`) chiamano `npm run dev`, che richiede un Node recente: con Node 18 Vite non parte.
Si può avviare il server con bun, puntato allo stack locale, e dire ai test di usarlo:
```bash
SUPABASE_URL=… SUPABASE_PUBLISHABLE_KEY=… SUPABASE_SERVICE_ROLE_KEY=… bun --bun x vite dev --port 8080
BASE_URL=http://localhost:8080 npm run test:integration
```
(`npx supabase status -o env` dà i valori dello stack locale.) Il 29/09/2026 l'intera suite di
integrazione, 30 file, passa così.
## Database locale in Docker
@@ -0,0 +1,457 @@
/**
* Destinatari delle notifiche per ruolo: `bun test/integration/destinatari-notifiche.test.ts`.
*
* Verifica, chiamando le route vere, le regole del catalogo delle notifiche
* (docs/modules/notifiche.md) che finora erano coperte solo dalla lettura del codice:
*
* - i promemoria a 24 e 3 ore arrivano ai convocati (a tutti i giocatori attivi se `convocati`
* è vuoto), mai all'allenatore (DD-039);
* - la push del sondaggio pre-partita non arriva ai dispositivi dell'allenatore, quella del
* messaggio dello staff sì (DD-034, DD-038);
* - il sollecito presenze arriva a chi non ha risposto o ha risposto «forse», mai all'allenatore,
* ed è permesso ad allenatore e admin ma non a un giocatore;
* - il turno palloni arriva all'incaricato e a chi li aveva prima, solo per allenamenti e partite,
* ed è riservato agli admin.
*
* Le push vanno a un servizio push finto in locale: nessuna richiesta esce dalla macchina.
* Gira solo sullo stack locale (`npx supabase start`). Il server di sviluppo che riceve le
* chiamate deve avere chiavi VAPID (il test le imposta se avvia lui il server; con `BASE_URL`
* valgono quelle di quel server) e usa gli slot `g1`, `g2`, `g4`, `g5` e `g6` della rosa seed,
* ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { statoLocale } from "../helpers/locale";
import { avviaServer } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("destinatari notifiche", "stack locale non attivo (npx supabase start)");
riepilogo("destinatari-notifiche");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`destinatari notifiche su ${URL_BASE}`);
// Il server di sviluppo eredita queste: senza, le route punterebbero al progetto cloud.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const b64u = (dati: ArrayBuffer | Uint8Array) =>
Buffer.from(dati as ArrayBuffer).toString("base64url");
// Chiavi VAPID di prova (P-256), nel formato di `webpush.server.ts`: la chiave privata reale
// non deve mai servire ai test.
const vapid = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
"sign",
]);
const jwk = await crypto.subtle.exportKey("jwk", vapid.privateKey);
process.env["VAPID_PUBLIC_KEY"] = b64u(
new Uint8Array([
4,
...Buffer.from(jwk.x ?? "", "base64url"),
...Buffer.from(jwk.y ?? "", "base64url"),
]),
);
process.env["VAPID_PRIVATE_KEY"] = jwk.d ?? "";
async function chiaviDispositivo() {
const k = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
"deriveBits",
]);
return {
p256dh: b64u(await crypto.subtle.exportKey("raw", k.publicKey)),
auth: b64u(crypto.getRandomValues(new Uint8Array(16))),
};
}
const PREFISSO = "test-destinatari-notifiche";
const PASSWORD = "prova-destinatari-notifiche-123";
const G_UNO = "g1";
const G_DUE = "g2";
const G_QUATTRO = "g4";
const G_CINQUE = "g5";
const ALLENATORE = "g6";
const PARTITA = `${PREFISSO}-partita`;
const PRESENZE = `${PREFISSO}-presenze`;
const PALLONI_1 = `${PREFISSO}-palloni-1`;
const PALLONI_2 = `${PREFISSO}-palloni-2`;
const GENERICO = `${PREFISSO}-generico`;
const PROMEMORIA_CONVOCATI = `${PREFISSO}-promemoria-convocati`;
const PROMEMORIA_TUTTI = `${PREFISSO}-promemoria-tutti`;
const TITOLO_STAFF = `${PREFISSO} messaggio ${Date.now()}`;
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, token: string, metodo: string, corpo?: unknown) {
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 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 };
}
// --- Servizio push finto: registra a quale dispositivo arriva ogni push ------------------
const ricevute: string[] = [];
const servizioPush: Server = createServer((req, res) => {
req.resume();
req.on("end", () => {
ricevute.push((req.url ?? "").replace(/^\//, ""));
res.statusCode = 201;
res.end();
});
});
await new Promise<void>((ok) => servizioPush.listen(0, "127.0.0.1", ok));
const porta = (servizioPush.address() as AddressInfo).port;
const endpoint = (slot: string) => `http://127.0.0.1:${porta}/${slot}`;
const svuota = () => {
ricevute.length = 0;
};
/** A quali slot è arrivata una push dall'ultimo `svuota()`, senza ripetizioni e in ordine. */
const destinatariPush = () => [...new Set(ricevute)].sort();
type Notifica = { giocatore_id: string; titolo: string; tipo: string };
const notifiche = (filtro: string) =>
leggi<Notifica>(
`notifiche_utente?${filtro}&select=giocatore_id,titolo,tipo&order=giocatore_id`,
);
const slot = (righe: Notifica[]) => righe.map((r) => r.giocatore_id);
const idUtenti: string[] = [];
let server: { baseUrl: string; stop: () => void } | null = null;
let tokenAdmin = "";
const originali = new Map<string, { tipo: string; auth_user_id: string | null }>();
try {
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
const allenatore = await creaUtente(`${PREFISSO}-allenatore-${Date.now()}@example.test`);
const giocatore = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(admin.id, allenatore.id, giocatore.id);
await scrivi("user_roles", SERVIZIO, "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
const [g6] = await leggi<{ tipo: string; auth_user_id: string | null }>(
`giocatori_squadra?id=eq.${ALLENATORE}&select=tipo,auth_user_id`,
);
originali.set(ALLENATORE, g6 ?? { tipo: "giocatore", auth_user_id: null });
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin. Il
// trigger di M21 assegna il ruolo `allenatore` all'account collegato a uno slot allenatore.
await scrivi(`giocatori_squadra?id=eq.${ALLENATORE}`, tokenAdmin, "PATCH", {
tipo: "allenatore",
auth_user_id: allenatore.id,
});
for (const s of [G_QUATTRO, G_CINQUE, ALLENATORE]) {
await scrivi("push_subscriptions", SERVIZIO, "POST", {
giocatore_id: s,
endpoint: endpoint(s),
...(await chiaviDispositivo()),
});
}
for (const [id, tipo, data] of [
[PARTITA, "partita", "2031-04-01"],
[PRESENZE, "allenamento", "2031-04-02"],
[PALLONI_1, "allenamento", "2031-05-01"],
[PALLONI_2, "allenamento", "2031-05-08"],
[GENERICO, "evento", "2031-05-09"],
] as const) {
await scrivi("eventi_app", SERVIZIO, "POST", {
id,
tipo,
titolo: `${PREFISSO} ${id}`,
data,
ora: "20:00",
convocati: [],
});
}
// Promemoria: eventi che iniziano tra 2 ore, dentro le finestre dei job a 24 e a 3 ore.
const tra2Ore = new Date(Date.now() + 2 * 3_600_000);
const dataRoma = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(tra2Ore);
const oraRoma = new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(tra2Ore);
for (const [id, convocati] of [
[PROMEMORIA_CONVOCATI, [G_QUATTRO]],
[PROMEMORIA_TUTTI, []],
] as const) {
await scrivi("eventi_app", SERVIZIO, "POST", {
id,
tipo: "allenamento",
titolo: `${PREFISSO} ${id}`,
data: dataRoma,
ora: oraRoma,
convocati: [...convocati],
});
}
server = await avviaServer();
console.log(`route su ${server.baseUrl}`);
const chiama = (percorso: string, token: string, corpo: unknown) =>
fetch(`${server!.baseUrl}/api/public/${percorso}`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(corpo),
});
const attivi = (
await leggi<{ id: string; tipo: string }>("giocatori_squadra?attivo=eq.true&select=id,tipo")
).sort((a, b) => a.id.localeCompare(b.id));
const giocatoriAttivi = attivi.filter((g) => g.tipo === "giocatore").map((g) => g.id);
assert.ok(attivi.some((g) => g.id === ALLENATORE && g.tipo === "allenatore"));
// --- 0. Promemoria a 24 e 3 ore (generati dal database) --------------------------------
await prova(
"il promemoria arriva ai soli convocati: l'allenatore non lo riceve (DD-039)",
async () => {
for (const [tipo, finestra] of [
["evento_promemoria_24h", "24 hours"],
["evento_promemoria_3h", "3 hours"],
] as const) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_promemoria_eventi`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ p_tipo: tipo, p_finestra: finestra }),
});
assert.ok(res.ok, `genera_promemoria_eventi ${tipo}`);
const righe = await notifiche(`evento_id=eq.${PROMEMORIA_CONVOCATI}&tipo=eq.${tipo}`);
assert.deepEqual(slot(righe), [G_QUATTRO], tipo);
}
},
);
await prova(
"con convocati vuoto il promemoria arriva a tutti i giocatori attivi, mai all'allenatore",
async () => {
for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h"]) {
const righe = await notifiche(`evento_id=eq.${PROMEMORIA_TUTTI}&tipo=eq.${tipo}`);
assert.deepEqual(slot(righe), giocatoriAttivi, tipo);
}
},
);
// --- 1. Messaggio dello staff ---------------------------------------------------------
await prova(
"controllo: la push arriva al servizio finto (il server ha le chiavi VAPID)",
async () => {
svuota();
const res = await chiama("notifica-personalizzata", tokenAdmin, {
messaggio: `${TITOLO_STAFF} controllo`,
giocatoreId: G_QUATTRO,
});
assert.equal(res.status, 200);
assert.deepEqual(
destinatariPush(),
[G_QUATTRO],
"il server di prova non manda push: servono VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY, oppure non usare BASE_URL",
);
},
);
await prova(
"il messaggio dello staff a tutti raggiunge anche i dispositivi dell'allenatore",
async () => {
svuota();
const res = await chiama("notifica-personalizzata", tokenAdmin, {
messaggio: TITOLO_STAFF,
});
assert.equal(res.status, 200);
assert.deepEqual(destinatariPush(), [ALLENATORE, G_QUATTRO, G_CINQUE].sort());
const righe = await notifiche(`corpo=eq.${encodeURIComponent(TITOLO_STAFF)}&tipo=eq.admin`);
assert.deepEqual(
slot(righe),
attivi.map((g) => g.id),
"in-app: tutta la rosa attiva, allenatore compreso",
);
},
);
await prova("il messaggio a un solo giocatore non arriva agli altri dispositivi", async () => {
svuota();
await chiama("notifica-personalizzata", tokenAdmin, {
messaggio: `${TITOLO_STAFF} solo g5`,
giocatoreId: G_CINQUE,
});
assert.deepEqual(destinatariPush(), [G_CINQUE]);
});
await prova(
"un allenatore o un giocatore non possono mandare il messaggio dello staff",
async () => {
for (const token of [allenatore.token, giocatore.token]) {
const res = await chiama("notifica-personalizzata", token, { messaggio: "no" });
assert.ok(!res.ok, `stato ${res.status}`);
}
},
);
// --- 2. Sondaggio pre-partita ---------------------------------------------------------
await prova("il sondaggio non manda la push ai dispositivi dell'allenatore", async () => {
svuota();
const res = await chiama("apri-sondaggio", tokenAdmin, { eventoId: PARTITA });
assert.equal(res.status, 200);
assert.deepEqual(destinatariPush(), [G_QUATTRO, G_CINQUE].sort());
});
await prova("il sondaggio non crea l'avviso in-app per l'allenatore", async () => {
const righe = await notifiche(`evento_id=eq.${PARTITA}&tipo=eq.sondaggio_cacche`);
assert.deepEqual(slot(righe), giocatoriAttivi);
});
await prova("un allenatore non può aprire il sondaggio", async () => {
const res = await chiama("apri-sondaggio", allenatore.token, { eventoId: PARTITA });
assert.ok(!res.ok, `stato ${res.status}`);
});
// --- 3. Sollecito presenze ------------------------------------------------------------
await scrivi("risposte_presenze", SERVIZIO, "POST", [
{ evento_id: PRESENZE, giocatore_id: G_QUATTRO, stato: "presente" },
{ evento_id: PRESENZE, giocatore_id: G_CINQUE, stato: "forse" },
]);
await prova(
"l'allenatore può sollecitare: arriva a chi non ha risposto e a chi ha detto «forse»",
async () => {
svuota();
const res = await chiama("sollecita-presenze", allenatore.token, { eventoId: PRESENZE });
assert.equal(res.status, 200);
const righe = await notifiche(`evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze`);
assert.deepEqual(
slot(righe),
giocatoriAttivi.filter((id) => id !== G_QUATTRO),
"esclusi chi ha già risposto e l'allenatore, incluso chi ha risposto «forse»",
);
assert.ok(slot(righe).includes(G_CINQUE));
assert.deepEqual(destinatariPush(), [G_CINQUE], "push solo ai dispositivi dei sollecitati");
},
);
await prova("un giocatore non può sollecitare le presenze", async () => {
const res = await chiama("sollecita-presenze", giocatore.token, { eventoId: PRESENZE });
assert.ok(!res.ok, `stato ${res.status}`);
});
// --- 4. Turno palloni -----------------------------------------------------------------
await scrivi("turni_palloni", SERVIZIO, "POST", [
{ evento_id: PALLONI_1, giocatore_id: G_UNO },
{ evento_id: PALLONI_2, giocatore_id: G_DUE },
]);
await prova("il turno palloni arriva all'incaricato e a chi li aveva prima", async () => {
const res = await chiama("promemoria-palloni", tokenAdmin, { eventoId: PALLONI_2 });
assert.equal(res.status, 200);
const righe = await notifiche(`evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni`);
assert.deepEqual(slot(righe), [G_UNO, G_DUE]);
const per = new Map(righe.map((r) => [r.giocatore_id, r.titolo]));
assert.equal(per.get(G_DUE), "Tocca a te prendere i palloni");
assert.equal(per.get(G_UNO), "Porta i palloni");
});
await prova(
"per un evento che non è allenamento né partita non parte nessun avviso",
async () => {
await scrivi("turni_palloni", SERVIZIO, "POST", {
evento_id: GENERICO,
giocatore_id: G_QUATTRO,
});
const res = await chiama("promemoria-palloni", tokenAdmin, { eventoId: GENERICO });
assert.equal(res.status, 200);
assert.equal((await notifiche(`evento_id=eq.${GENERICO}&tipo=eq.turno_palloni`)).length, 0);
},
);
await prova("un allenatore non può far partire il turno palloni", async () => {
const res = await chiama("promemoria-palloni", allenatore.token, { eventoId: PALLONI_2 });
assert.ok(!res.ok, `stato ${res.status}`);
});
} finally {
server?.stop();
servizioPush.close();
await rest(
`push_subscriptions?endpoint=like.${encodeURIComponent(`http://127.0.0.1:${porta}/*`)}`,
SERVIZIO,
{
method: "DELETE",
},
);
await rest(`notifiche_utente?corpo=like.${encodeURIComponent(`${PREFISSO}*`)}`, SERVIZIO, {
method: "DELETE",
});
for (const id of [
PARTITA,
PRESENZE,
PALLONI_1,
PALLONI_2,
GENERICO,
PROMEMORIA_CONVOCATI,
PROMEMORIA_TUTTI,
]) {
await rest(`notifiche_utente?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await rest(`turni_palloni?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("destinatari-notifiche");
}
}
+571
View File
@@ -0,0 +1,571 @@
/**
* Notifiche email (M22, DD-036): `bun test/integration/notifiche-email.test.ts`.
*
* Copre il lato database del canale email: il trigger che accoda ogni notifica di
* `notifiche_utente`, le tre funzioni RPC del worker (`prendi_notifiche_email`,
* `esito_notifica_email`, `email_inviate_ultime_24h`), la preferenza per account e la RLS che
* tiene la coda lontana dai client. Il worker vero e la logica di invio sono coperti da
* `unit/mailer-core.test.ts`; qui non parte nessuna mail.
*
* Gira solo sullo stack locale (`npx supabase start`): usa gli slot `g4` (collegato a un
* account di prova) e `g5` (senza account) della rosa seed, con l'email impostata per la
* durata del test e ripristinata alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche email", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-email");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`notifiche email su ${URL_BASE}`);
const PREFISSO = "test-notifiche-email";
// Un evento per test che ne ha bisogno: (giocatore, evento, tipo) è UNIQUE.
const EVENTO_TIPI = `${PREFISSO}-tipi`;
const EVENTO_UPSERT = `${PREFISSO}-upsert`;
const EVENTO_PRESA = `${PREFISSO}-presa`;
const EVENTO_CRON = `${PREFISSO}-cron`;
const SLOT_COLLEGATO = "g4";
const SLOT_LIBERO = "g5";
const EMAIL_COLLEGATO = `${PREFISSO}-g4@example.test`;
const EMAIL_LIBERO = `${PREFISSO}-g5@example.test`;
const PASSWORD = "prova-notifiche-email-123";
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, token = SERVIZIO): Promise<T[]> {
const res = await rest(percorso, token);
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, token: string, metodo: string, corpo: unknown) {
const res = await rest(percorso, token, { method: metodo, body: JSON.stringify(corpo) });
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
}
async function rpc<T = unknown>(nome: string, corpo: Record<string, unknown>, token = SERVIZIO) {
const res = await 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),
});
const testo = await res.text();
return { ok: res.ok, stato: res.status, dati: (testo ? JSON.parse(testo) : null) as T };
}
async function creaUtente(email: string): Promise<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)}`);
return corpo.id;
}
async function accedi(email: string): Promise<string> {
const res = 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 corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
type Coda = {
notifica_id: string;
stato: string;
tentativi: number;
prossimo_tentativo: string;
errore: string | null;
inviata_il: string | null;
};
type Preso = {
id_notifica: string;
destinatario: string;
oggetto: string;
testo: string;
id_evento: string | null;
tipo_notifica: string;
tentativi_fatti: number;
};
const creaNotifica = async (
giocatoreId: string,
extra: Record<string, unknown> = {},
): Promise<string> => {
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
giocatore_id: giocatoreId,
tipo: "admin",
titolo: `${PREFISSO} titolo`,
corpo: `${PREFISSO} corpo`,
...extra,
}),
});
if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`);
return ((await res.json()) as { id: string }[])[0]!.id;
};
const coda = async (id: string): Promise<Coda | undefined> =>
(await leggi<Coda>(`notifiche_email_coda?notifica_id=eq.${id}&select=*`))[0];
/** Prende il lotto e tiene solo le righe di questo test: la coda locale può avere altro. */
const prendi = async (mie: string[], max = 100): Promise<Preso[]> => {
const r = await rpc<Preso[]>("prendi_notifiche_email", { p_max: max });
assert.ok(r.ok, `prendi_notifiche_email: ${r.stato}`);
return r.dati.filter((p) => mie.includes(p.id_notifica));
};
const esito = async (
id: string,
tipo: string,
errore: string | null = null,
prossimo: string | null = null,
) => {
const r = await rpc("esito_notifica_email", {
p_id: id,
p_esito: tipo,
p_errore: errore,
p_prossimo: prossimo,
});
assert.ok(r.ok, `esito_notifica_email: ${r.stato}`);
};
const idUtenti: string[] = [];
const creati: string[] = [];
let tokenAdmin = "";
const originali = new Map<string, { email: string | null; auth_user_id: string | null }>();
try {
// --- preparazione: un admin, un giocatore collegato a g4, g5 senza account -----------
const emailAdmin = `${PREFISSO}-admin-${Date.now()}@example.test`;
const emailGiocatore = `${PREFISSO}-giocatore-${Date.now()}@example.test`;
const idAdmin = await creaUtente(emailAdmin);
const idGiocatore = await creaUtente(emailGiocatore);
idUtenti.push(idAdmin, idGiocatore);
await scrivi("user_roles", SERVIZIO, "POST", { user_id: idAdmin, role: "admin" });
tokenAdmin = await accedi(emailAdmin);
const tokenGiocatore = await accedi(emailGiocatore);
for (const id of [SLOT_COLLEGATO, SLOT_LIBERO]) {
const [riga] = await leggi<{ email: string | null; auth_user_id: string | null }>(
`giocatori_squadra?id=eq.${id}&select=email,auth_user_id`,
);
originali.set(id, riga ?? { email: null, auth_user_id: null });
}
// Il trigger `enforce_giocatori_squadra_update` rifiuta la service key: serve l'admin.
await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", {
email: EMAIL_COLLEGATO,
auth_user_id: idGiocatore,
});
await scrivi(`giocatori_squadra?id=eq.${SLOT_LIBERO}`, tokenAdmin, "PATCH", {
email: EMAIL_LIBERO,
});
for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA]) {
await scrivi("eventi_app", SERVIZIO, "POST", {
id,
tipo: "allenamento",
titolo: `${PREFISSO} evento`,
data: "2030-01-01",
ora: "20:00",
convocati: [],
});
}
// --- 1. Trigger ---------------------------------------------------------------------
await prova("una notifica nuova finisce in coda, in_coda e con zero tentativi", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
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"]) {
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("segnare come letta non rimette in coda una mail già inviata", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const [presa] = await prendi([id]);
assert.ok(presa);
await esito(id, "inviata");
await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "PATCH", { letta: true });
assert.equal((await coda(id))?.stato, "inviata");
});
await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => {
const id = await creaNotifica(SLOT_COLLEGATO, {
tipo: "turno_palloni",
evento_id: EVENTO_UPSERT,
});
creati.push(id);
await prendi([id]);
await esito(id, "inviata");
assert.equal((await coda(id))?.stato, "inviata");
// Come `promemoria-palloni.ts` e `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: "turno_palloni",
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 promemoria del cron accoda una volta sola, anche se il cron rigira",
async () => {
const adesso = new Date(Date.now() + 20 * 60 * 60 * 1000);
const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(adesso);
const ora = new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(adesso);
await scrivi("eventi_app", SERVIZIO, "POST", {
id: EVENTO_CRON,
tipo: "allenamento",
titolo: `${PREFISSO} cron`,
data,
ora,
convocati: [SLOT_COLLEGATO],
});
const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" };
await rpc("genera_promemoria_eventi", args);
const [notifica] = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTO_CRON}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.ok(notifica, "il cron ha creato la notifica");
assert.equal((await coda(notifica.id))?.stato, "in_coda");
await prendi([notifica.id]);
await esito(notifica.id, "inviata");
await rpc("genera_promemoria_eventi", args);
assert.equal(
(await coda(notifica.id))?.stato,
"inviata",
"ON CONFLICT DO NOTHING: nessun rinvio",
);
},
);
await prova("cancellare la notifica (swipe) toglie anche la riga in coda", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
assert.ok(await coda(id));
await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "DELETE", undefined);
assert.equal(await coda(id), undefined);
});
// --- 2. prendi_notifiche_email ------------------------------------------------------
await prova("prendi restituisce destinatario e testo e marca la riga in_invio", async () => {
const id = await creaNotifica(SLOT_COLLEGATO, {
titolo: `${PREFISSO} oggetto`,
corpo: `${PREFISSO} testo`,
evento_id: EVENTO_PRESA,
tipo: "sollecita_presenze",
});
creati.push(id);
const [p] = await prendi([id]);
assert.ok(p, "la riga è stata presa");
assert.equal(p.destinatario, EMAIL_COLLEGATO);
assert.equal(p.id_evento, EVENTO_PRESA);
assert.equal(p.tipo_notifica, "sollecita_presenze");
assert.equal((await coda(id))?.stato, "in_invio");
});
await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0);
});
await prova("due chiamate concorrenti si dividono le righe senza doppioni", async () => {
const ids = await Promise.all(Array.from({ length: 6 }, () => creaNotifica(SLOT_COLLEGATO)));
creati.push(...ids);
const [a, b] = await Promise.all([prendi(ids, 3), prendi(ids, 3)]);
const tutti = [...a, ...b].map((p) => p.id_notifica);
assert.equal(tutti.length, new Set(tutti).size, "nessuna riga presa due volte");
assert.ok(tutti.length >= 3 && tutti.length <= 6);
// Le righe rimaste tornano prese al giro dopo, una sola volta ciascuna.
const resto = (await prendi(ids)).map((p) => p.id_notifica);
assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort());
});
await prova("slot senza account collegato: mail 'saltata', mai presa", async () => {
const id = await creaNotifica(SLOT_LIBERO);
creati.push(id);
assert.equal((await prendi([id])).length, 0);
const riga = await coda(id);
assert.equal(riga?.stato, "saltata");
assert.equal(riga?.errore, null);
});
await prova("slot collegato ma senza email: 'saltata'", async () => {
await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", {
email: null,
});
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", {
email: EMAIL_COLLEGATO,
});
});
await prova("la mail in coda viene ripresa dopo 10 minuti se il worker è morto", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0, "ancora in invio");
const vecchia = new Date(Date.now() - 11 * 60_000).toISOString();
await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", {
aggiornata_il: vecchia,
});
assert.equal((await prendi([id])).length, 1, "recuperata");
});
// --- 3. Preferenza per account ------------------------------------------------------
await prova("nessuna riga in preferenze_utente vuol dire mail attive", async () => {
const righe = await leggi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`);
assert.equal(righe.length, 0);
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
});
await prova("il giocatore spegne le email dal proprio account: 'saltata'", async () => {
const res = await rest("preferenze_utente?on_conflict=auth_user_id", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
body: JSON.stringify({ auth_user_id: idGiocatore, email_notifiche: false }),
});
assert.ok(res.ok, `upsert preferenza: ${res.status}`);
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
});
await prova("riacceso l'interruttore le mail nuove ripartono", async () => {
await scrivi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`, tokenGiocatore, "PATCH", {
email_notifiche: true,
});
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
});
await prova("un giocatore non scrive né legge le preferenze di un altro account", async () => {
const res = await rest("preferenze_utente", tokenGiocatore, {
method: "POST",
body: JSON.stringify({ auth_user_id: idAdmin, email_notifiche: false }),
});
assert.ok(!res.ok, "insert per un altro account rifiutato dalla RLS");
await scrivi("preferenze_utente", SERVIZIO, "POST", {
auth_user_id: idAdmin,
email_notifiche: false,
});
const viste = await leggi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore);
assert.equal(viste.length, 0, "la riga altrui è invisibile");
await scrivi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore, "PATCH", {
email_notifiche: true,
});
const [altrui] = await leggi<{ email_notifiche: boolean }>(
`preferenze_utente?auth_user_id=eq.${idAdmin}`,
);
assert.equal(altrui?.email_notifiche, false, "l'update altrui non ha toccato nulla");
});
// --- 4. esito_notifica_email --------------------------------------------------------
await prova(
"'inviata': stato, orario, e la riga entra nel conteggio delle 24 ore",
async () => {
const prima = (await rpc<number>("email_inviate_ultime_24h", {})).dati;
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
await esito(id, "inviata");
const riga = await coda(id);
assert.equal(riga?.stato, "inviata");
assert.ok(riga?.inviata_il);
assert.equal((await rpc<number>("email_inviate_ultime_24h", {})).dati, prima + 1);
},
);
await prova(
"'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata",
async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
const tra1h = new Date(Date.now() + 60 * 60_000).toISOString();
await esito(id, "riprova", "ETIMEDOUT", tra1h);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 1);
assert.equal(riga?.errore, "ETIMEDOUT");
assert.equal((await prendi([id])).length, 0, "non ancora scaduto il ritardo");
const passato = new Date(Date.now() - 1000).toISOString();
await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", {
prossimo_tentativo: passato,
});
const [p] = await prendi([id]);
assert.equal(p?.tentativi_fatti, 1, "il worker vede quanti tentativi sono già stati fatti");
},
);
await prova("'differita': torna in coda senza consumare un tentativo", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
await esito(id, "differita", "550", new Date(Date.now() + 60 * 60_000).toISOString());
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
});
await prova("'fallita' è definitiva e non viene più presa", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
await esito(id, "fallita", "550");
assert.equal((await coda(id))?.stato, "fallita");
assert.equal((await prendi([id])).length, 0);
});
await prova("un esito su una riga non 'in_invio' non cambia nulla", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await esito(id, "inviata"); // ancora in_coda: nessuno l'ha presa
assert.equal((await coda(id))?.stato, "in_coda");
});
await prova("un esito sconosciuto è rifiutato", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const r = await rpc("esito_notifica_email", { p_id: id, p_esito: "boh" });
assert.ok(!r.ok);
});
// --- 5. Permessi --------------------------------------------------------------------
await prova("un giocatore non vede né scrive la coda", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const lettura = await rest("notifiche_email_coda?select=*", tokenGiocatore);
const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : [];
assert.equal(righe.length, 0);
const scrittura = await rest(`notifiche_email_coda?notifica_id=eq.${id}`, tokenGiocatore, {
method: "PATCH",
body: JSON.stringify({ stato: "in_coda", tentativi: 0 }),
});
assert.ok(!scrittura.ok || (await scrittura.text()) === "");
const inserimento = await rest("notifiche_email_coda", tokenGiocatore, {
method: "POST",
body: JSON.stringify({ notifica_id: id }),
});
assert.ok(!inserimento.ok);
assert.equal((await coda(id))?.stato, "in_coda");
});
await prova("un admin non può usare le funzioni del worker: solo la service role", async () => {
for (const [nome, corpo] of [
["prendi_notifiche_email", { p_max: 1 }],
["esito_notifica_email", { p_id: creati[0], p_esito: "fallita" }],
["email_inviate_ultime_24h", {}],
] as const) {
for (const token of [tokenAdmin, tokenGiocatore]) {
const r = await rpc(nome, { ...corpo }, token);
assert.ok(!r.ok, `${nome} non deve essere chiamabile da un utente`);
}
}
});
} finally {
await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
await rest(
`notifiche_utente?evento_id=in.(${[EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON].join(",")})`,
SERVIZIO,
{ method: "DELETE" },
);
for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON]) {
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 eliminaUtente(id);
}
riepilogo("notifiche-email");
}
}
+31 -15
View File
@@ -284,21 +284,37 @@ if (!locale) {
assert.ok(!votato.ok, `l'allenatore non si vota (${votato.status})`);
});
await prova("i promemoria evento arrivano anche all'allenatore non convocato", async () => {
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ convocati: [GIOCATORE] }),
});
const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, {
method: "POST",
body: JSON.stringify({ p_evento_id: EVENTO }),
});
assert.equal(res.status, 200);
const destinatari = ((await res.json()) as Array<string | Record<string, string>>).map((d) =>
typeof d === "string" ? d : Object.values(d)[0],
);
assert.deepEqual([...destinatari].sort(), [GIOCATORE, SLOT].sort());
});
await prova(
"i promemoria evento non arrivano all'allenatore, convocato o no (DD-039)",
async () => {
const destinatari = async () => {
const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, {
method: "POST",
body: JSON.stringify({ p_evento_id: EVENTO }),
});
assert.equal(res.status, 200);
return ((await res.json()) as Array<string | Record<string, string>>).map((d) =>
typeof d === "string" ? d : Object.values(d)[0],
);
};
// Con i convocati: solo quelli, allenatore escluso anche se attivo.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ convocati: [GIOCATORE] }),
});
assert.deepEqual(await destinatari(), [GIOCATORE]);
// Senza convocati (tutta la rosa): i giocatori attivi, mai l'allenatore.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ convocati: [] }),
});
const tutti = await destinatari();
assert.ok(tutti.includes(GIOCATORE), "il giocatore attivo è tra i destinatari");
assert.ok(!tutti.includes(SLOT), "l'allenatore non è tra i destinatari");
},
);
await prova("scollegare l'account toglie il ruolo", async () => {
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAdmin, {
+225
View File
@@ -0,0 +1,225 @@
/**
* Promemoria evento che non ricompaiono (M23, DD-037): `bun test/integration/promemoria-eventi.test.ts`.
*
* Prima di M23 l'unica traccia che un promemoria fosse già stato generato era la riga in
* `notifiche_utente`: eliminarla dal centro notifiche faceva rigenerare il promemoria (e
* riaccodare la mail) al giro successivo del job. Qui si verifica che il registro
* `promemoria_eventi_generati` lo eviti, senza rompere la generazione normale.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa eventi con il prefisso
* `test-promemoria-eventi` e gli slot `g4`, `g5`, `g6` della rosa seed come convocati; non
* serve un account collegato perché le notifiche si eliminano con la service role.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("promemoria eventi", "stack locale non attivo (npx supabase start)");
riepilogo("promemoria-eventi");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`promemoria eventi su ${URL_BASE}`);
const PREFISSO = "test-promemoria-eventi";
const EVENTO = `${PREFISSO}-a`;
const EVENTO_LONTANO = `${PREFISSO}-lontano`;
const EVENTO_CASCATA = `${PREFISSO}-cascata`;
const G4 = "g4";
const G5 = "g5";
const G6 = "g6";
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) {
const res = await rest(percorso, SERVIZIO, {
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 cron(tipo: "evento_promemoria_24h" | "evento_promemoria_3h") {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_promemoria_eventi`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({
p_tipo: tipo,
p_finestra: tipo === "evento_promemoria_24h" ? "24 hours" : "3 hours",
}),
});
if (!res.ok) throw new Error(`genera_promemoria_eventi: ${res.status} ${await res.text()}`);
}
/** 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 notifiche = (evento: string, tipo: string) =>
leggi<{ id: string; giocatore_id: string; letta: boolean }>(
`notifiche_utente?evento_id=eq.${evento}&tipo=eq.${tipo}&select=id,giocatore_id,letta&order=giocatore_id`,
);
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`,
);
const inCoda = async (ids: string[]) =>
ids.length === 0
? []
: leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
try {
await scrivi("eventi_app", "POST", {
id: EVENTO,
tipo: "allenamento",
titolo: `${PREFISSO} evento`,
...tra(2),
convocati: [G4, G5],
});
await scrivi("eventi_app", "POST", {
id: EVENTO_LONTANO,
tipo: "allenamento",
titolo: `${PREFISSO} lontano`,
...tra(72),
convocati: [G4],
});
await prova("il job genera il promemoria per i convocati e lo registra", async () => {
await cron("evento_promemoria_24h");
const n = await notifiche(EVENTO, "evento_promemoria_24h");
assert.deepEqual(
n.map((r) => r.giocatore_id),
[G4, G5],
);
assert.equal((await registro(EVENTO, "evento_promemoria_24h")).length, 2);
});
await prova("un secondo giro del job non duplica né riaccoda", async () => {
const prima = await notifiche(EVENTO, "evento_promemoria_24h");
const codaPrima = await inCoda(prima.map((r) => r.id));
await cron("evento_promemoria_24h");
const dopo = await notifiche(EVENTO, "evento_promemoria_24h");
assert.deepEqual(
dopo.map((r) => r.id),
prima.map((r) => r.id),
);
assert.equal((await inCoda(dopo.map((r) => r.id))).length, codaPrima.length);
});
await prova(
"eliminata la notifica, il promemoria NON ricompare al giro successivo",
async () => {
const [mia] = await notifiche(EVENTO, "evento_promemoria_24h");
assert.ok(mia);
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
assert.equal((await notifiche(EVENTO, "evento_promemoria_24h")).length, 1);
await cron("evento_promemoria_24h");
await cron("evento_promemoria_24h");
const dopo = await notifiche(EVENTO, "evento_promemoria_24h");
assert.equal(dopo.length, 1, "solo la notifica dell'altro convocato, non quella eliminata");
assert.ok(!dopo.some((r) => r.giocatore_id === mia.giocatore_id));
},
);
await prova("nessuna nuova mail in coda per il promemoria eliminato", async () => {
const righe = await leggi<{ id: string; giocatore_id: string }>(
`notifiche_utente?evento_id=eq.${EVENTO}&tipo=eq.evento_promemoria_24h&select=id,giocatore_id`,
);
const coda = await inCoda(righe.map((r) => r.id));
assert.equal(coda.length, righe.length, "una sola riga in coda per notifica rimasta");
});
await prova("il promemoria a 3 ore è indipendente da quello a 24 ore", async () => {
await cron("evento_promemoria_3h");
const n = await notifiche(EVENTO, "evento_promemoria_3h");
assert.equal(n.length, 2, "l'evento parte tra 2 ore: rientra nella finestra di 3");
const [mia] = n;
assert.ok(mia);
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
await cron("evento_promemoria_3h");
assert.equal((await notifiche(EVENTO, "evento_promemoria_3h")).length, 1);
});
await prova("un convocato aggiunto dopo riceve comunque il suo promemoria", async () => {
await scrivi(`eventi_app?id=eq.${EVENTO}`, "PATCH", { convocati: [G4, G5, G6] });
await cron("evento_promemoria_24h");
const n = await notifiche(EVENTO, "evento_promemoria_24h");
assert.ok(
n.some((r) => r.giocatore_id === G6),
"g6 ha ricevuto il promemoria",
);
assert.equal(n.filter((r) => r.giocatore_id === G6).length, 1);
});
await prova("un evento fuori dalla finestra non genera né registra nulla", async () => {
await cron("evento_promemoria_24h");
await cron("evento_promemoria_3h");
assert.equal((await notifiche(EVENTO_LONTANO, "evento_promemoria_24h")).length, 0);
assert.equal((await registro(EVENTO_LONTANO)).length, 0);
});
await prova("il registro non si vede né si scrive da un client", async () => {
const lettura = await rest("promemoria_eventi_generati?select=*", ANON);
assert.ok(!lettura.ok, "anon non può leggere il registro");
const inserimento = await rest("promemoria_eventi_generati", ANON, {
method: "POST",
body: JSON.stringify({ giocatore_id: G4, evento_id: EVENTO, tipo: "evento_promemoria_3h" }),
});
assert.ok(!inserimento.ok, "anon non può scrivere nel registro");
});
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", {
id: EVENTO_CASCATA,
tipo: "partita",
titolo: `${PREFISSO} cascata`,
data,
ora,
convocati: [G4],
});
await cron("evento_promemoria_24h");
assert.equal((await registro(EVENTO_CASCATA)).length, 1);
await scrivi(`eventi_app?id=eq.${EVENTO_CASCATA}`, "DELETE");
assert.equal((await registro(EVENTO_CASCATA)).length, 0);
});
} finally {
for (const id of [EVENTO, EVENTO_LONTANO, EVENTO_CASCATA]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
riepilogo("promemoria-eventi");
}
}
+434
View File
@@ -0,0 +1,434 @@
/**
* Push dei promemoria e sondaggio in-app (M24, DD-038): `bun test/integration/push-promemoria.test.ts`.
*
* Lato database: il trigger che accoda solo i promemoria a 24 e 3 ore, le funzioni RPC del worker
* (`prendi_push_promemoria`, `esito_push_promemoria`), la RLS che tiene la coda lontana dai
* client, e il nuovo tipo `sondaggio_cacche`. Il worker vero e la logica degli esiti sono coperti
* da `unit/mailer-core.test.ts`; qui non parte nessuna push.
*
* Gira solo sullo stack locale (`npx supabase start`): usa lo slot `g4` della rosa seed e
* iscrizioni push con endpoint `https://push.test-push-promemoria.example/…`, tolte alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("push promemoria", "stack locale non attivo (npx supabase start)");
riepilogo("push-promemoria");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`push promemoria su ${URL_BASE}`);
const PREFISSO = "test-push-promemoria";
const G4 = "g4";
const G5 = "g5";
const ENDPOINT = (n: number) => `https://push.${PREFISSO}.example/${n}`;
const EVENTI = ["a", "b", "c", "d"].map((x) => `${PREFISSO}-${x}`);
let contatore = 0;
const PASSWORD = "prova-push-promemoria-123";
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, token = SERVIZIO): Promise<T[]> {
const res = await rest(percorso, token);
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) {
const res = await rest(percorso, SERVIZIO, {
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<T = unknown>(nome: string, corpo: Record<string, unknown>, token = SERVIZIO) {
const res = await 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),
});
const testo = await res.text();
return { ok: res.ok, dati: (testo ? JSON.parse(testo) : null) as T };
}
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 Coda = {
notifica_id: string;
stato: string;
tentativi: number;
prossimo_tentativo: string;
errore: string | null;
inviata_il: string | null;
};
type Preso = {
id_notifica: string;
oggetto: string;
testo: string;
tentativi_fatti: number;
iscrizioni: Array<{ endpoint: string; p256dh: string; auth: string }>;
};
const evento = async (id: string, ore = 2) => {
const quando = new Date(Date.now() + ore * 3_600_000);
await scrivi("eventi_app", "POST", {
id,
tipo: "allenamento",
titolo: `${PREFISSO} ${id}`,
data: new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando),
ora: new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(quando),
convocati: [G4],
});
};
const creaNotifica = async (
giocatoreId: string,
tipo: string,
eventoId: string | null = null,
): Promise<string> => {
contatore += 1;
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
giocatore_id: giocatoreId,
tipo,
titolo: `${PREFISSO} titolo ${contatore}`,
corpo: `${PREFISSO} corpo`,
...(eventoId ? { evento_id: eventoId } : {}),
}),
});
if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`);
return ((await res.json()) as { id: string }[])[0]!.id;
};
/** Evento nuovo e un promemoria per g4: (giocatore, evento, tipo) è UNIQUE, ogni prova ha il suo. */
const eventiCreati: string[] = [];
const fresco = async (tipo = "evento_promemoria_24h"): Promise<string> => {
contatore += 1;
const id = `${PREFISSO}-x${contatore}`;
await evento(id, 40);
eventiCreati.push(id);
return creaNotifica(G4, tipo, id);
};
const coda = async (id: string): Promise<Coda | undefined> =>
(await leggi<Coda>(`notifiche_push_coda?notifica_id=eq.${id}&select=*`))[0];
const codaEmail = async (id: string) =>
(await leggi<{ stato: string }>(`notifiche_email_coda?notifica_id=eq.${id}&select=stato`))[0];
const prendi = async (mie: string[], max = 100): Promise<Preso[]> => {
const r = await rpc<Preso[]>("prendi_push_promemoria", { p_max: max });
assert.ok(r.ok, "prendi_push_promemoria");
return r.dati.filter((p) => mie.includes(p.id_notifica));
};
const esito = async (
id: string,
tipo: string,
errore: string | null = null,
prossimo: string | null = null,
) => {
const r = await rpc("esito_push_promemoria", {
p_id: id,
p_esito: tipo,
p_errore: errore,
p_prossimo: prossimo,
});
assert.ok(r.ok, `esito_push_promemoria ${tipo}`);
};
const iscrivi = (giocatoreId: string, n: number) =>
scrivi("push_subscriptions", "POST", {
giocatore_id: giocatoreId,
endpoint: ENDPOINT(n),
p256dh: `k${n}`,
auth: `a${n}`,
});
const idUtenti: string[] = [];
try {
const utente = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(utente.id);
for (const id of EVENTI) await evento(id);
// --- 1. Trigger ---------------------------------------------------------------------
await prova("solo i promemoria a 24 e 3 ore finiscono nella coda push", async () => {
const promemoria = [
await creaNotifica(G4, "evento_promemoria_24h", EVENTI[0]),
await creaNotifica(G4, "evento_promemoria_3h", EVENTI[0]),
];
const altre = [
await creaNotifica(G4, "admin"),
await creaNotifica(G4, "turno_palloni", EVENTI[0]),
await creaNotifica(G4, "sollecita_presenze", EVENTI[0]),
await creaNotifica(G4, "sondaggio_cacche", EVENTI[0]),
];
for (const id of promemoria) {
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
}
for (const id of altre) {
assert.equal(await coda(id), undefined, "la push di queste parte già dall'app");
}
});
await prova(
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
async () => {
const id = await creaNotifica(G4, "sondaggio_cacche", EVENTI[1]);
assert.equal((await codaEmail(id))?.stato, "in_coda");
assert.equal(await coda(id), undefined);
},
);
await prova("un tipo sconosciuto è ancora rifiutato dal vincolo", async () => {
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
body: JSON.stringify({ giocatore_id: G4, tipo: "boh", titolo: "x", corpo: "y" }),
});
assert.ok(!res.ok);
});
// --- 2. prendi_push_promemoria ------------------------------------------------------
await prova(
"prendi restituisce testo e iscrizioni del giocatore e marca la riga in_invio",
async () => {
await iscrivi(G4, 1);
await iscrivi(G4, 2);
const id = await fresco("evento_promemoria_3h");
const [p] = await prendi([id]);
assert.ok(p, "la riga è stata presa");
assert.match(p.oggetto, new RegExp(PREFISSO));
assert.equal(p.tentativi_fatti, 0);
assert.deepEqual(p.iscrizioni.map((i) => i.endpoint).sort(), [ENDPOINT(1), ENDPOINT(2)]);
assert.equal(p.iscrizioni[0]?.p256dh.startsWith("k"), true);
assert.equal((await coda(id))?.stato, "in_invio");
},
);
await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => {
const id = await fresco();
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0);
});
await prova("due chiamate concorrenti si dividono le righe senza doppioni", async () => {
const ids = await Promise.all(Array.from({ length: 5 }, () => fresco()));
const [a, b] = await Promise.all([prendi(ids, 3), prendi(ids, 3)]);
const tutti = [...a, ...b].map((p) => p.id_notifica);
assert.equal(tutti.length, new Set(tutti).size, "nessuna riga presa due volte");
const resto = (await prendi(ids)).map((p) => p.id_notifica);
assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort());
});
await prova("giocatore senza dispositivi: 'saltata', mai presa", async () => {
const id = await creaNotifica(G5, "evento_promemoria_24h", EVENTI[0]);
assert.equal((await prendi([id])).length, 0);
const riga = await coda(id);
assert.equal(riga?.stato, "saltata");
assert.equal(riga?.errore, null);
});
await prova("promemoria creato da più di 3 ore: 'saltata', la push non serve più", async () => {
const id = await fresco("evento_promemoria_3h");
const vecchia = new Date(Date.now() - 4 * 3_600_000).toISOString();
await scrivi(`notifiche_utente?id=eq.${id}`, "PATCH", { creato_il: vecchia });
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
});
await prova(
"la riga rimasta in invio per un worker morto viene ripresa dopo 10 minuti",
async () => {
const id = await fresco();
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0, "ancora in invio");
await scrivi(`notifiche_push_coda?notifica_id=eq.${id}`, "PATCH", {
aggiornata_il: new Date(Date.now() - 11 * 60_000).toISOString(),
});
assert.equal((await prendi([id])).length, 1, "recuperata");
},
);
// --- 3. esito_push_promemoria -------------------------------------------------------
const nuovaInvio = async () => {
const id = await fresco();
await prendi([id]);
return id;
};
await prova("'inviata': stato e orario, errore azzerato", async () => {
const id = await nuovaInvio();
await esito(id, "inviata");
const riga = await coda(id);
assert.equal(riga?.stato, "inviata");
assert.ok(riga?.inviata_il);
assert.equal(riga?.errore, null);
});
await prova(
"'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata",
async () => {
const id = await nuovaInvio();
await esito(id, "riprova", "503", new Date(Date.now() + 3_600_000).toISOString());
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 1);
assert.equal(riga?.errore, "503");
assert.equal((await prendi([id])).length, 0);
await scrivi(`notifiche_push_coda?notifica_id=eq.${id}`, "PATCH", {
prossimo_tentativo: new Date(Date.now() - 1000).toISOString(),
});
const [p] = await prendi([id]);
assert.equal(p?.tentativi_fatti, 1);
},
);
await prova("'fallita' e 'saltata' sono definitive", async () => {
const a = await nuovaInvio();
await esito(a, "fallita", "403");
assert.equal((await coda(a))?.stato, "fallita");
assert.equal((await coda(a))?.tentativi, 1);
const b = await nuovaInvio();
await esito(b, "saltata", "410");
assert.equal((await coda(b))?.stato, "saltata");
assert.equal((await prendi([a, b])).length, 0);
});
await prova(
"un esito su una riga non 'in_invio' non cambia nulla, uno sconosciuto è rifiutato",
async () => {
const id = await nuovaInvio();
await esito(id, "inviata");
await esito(id, "fallita", "x");
assert.equal((await coda(id))?.stato, "inviata", "l'esito già deciso non si sovrascrive");
const r = await rpc("esito_push_promemoria", { p_id: id, p_esito: "boh" });
assert.ok(!r.ok);
},
);
await prova("cancellare la notifica toglie anche la riga della coda push", async () => {
const id = await fresco("evento_promemoria_3h");
assert.ok(await coda(id));
await scrivi(`notifiche_utente?id=eq.${id}`, "DELETE");
assert.equal(await coda(id), undefined);
});
await prova(
"il job dei promemoria accoda la push una volta sola, anche se cancelli la notifica",
async () => {
await scrivi(`eventi_app?id=eq.${EVENTI[2]}`, "PATCH", { convocati: [G4] });
const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" };
await rpc("genera_promemoria_eventi", args);
const [n] = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTI[2]}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.ok(n, "il job ha creato il promemoria");
assert.equal((await coda(n.id))?.stato, "in_coda");
await scrivi(`notifiche_utente?id=eq.${n.id}`, "DELETE");
assert.equal(await coda(n.id), undefined, "la coda sparisce con la notifica");
await rpc("genera_promemoria_eventi", args);
const dopo = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTI[2]}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.equal(
dopo.length,
0,
"M23: il promemoria eliminato non torna, quindi nessuna nuova push",
);
},
);
// --- 4. Permessi --------------------------------------------------------------------
await prova("un utente autenticato non vede né scrive la coda push", async () => {
const id = await nuovaInvio();
const lettura = await rest("notifiche_push_coda?select=*", utente.token);
const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : [];
assert.equal(righe.length, 0);
const scrittura = await rest(`notifiche_push_coda?notifica_id=eq.${id}`, utente.token, {
method: "PATCH",
body: JSON.stringify({ stato: "in_coda" }),
});
assert.ok(!scrittura.ok || (await scrittura.text()) === "");
assert.equal((await coda(id))?.stato, "in_invio");
const anon = await rest("notifiche_push_coda?select=*", ANON);
assert.ok(!anon.ok, "anon non può leggere la coda");
});
await prova("le funzioni push del worker sono chiamabili solo dalla service role", async () => {
const id = await nuovaInvio();
for (const token of [utente.token, ANON]) {
const a = await rpc("prendi_push_promemoria", { p_max: 1 }, token);
assert.ok(!a.ok, "prendi_push_promemoria");
const b = await rpc("esito_push_promemoria", { p_id: id, p_esito: "fallita" }, token);
assert.ok(!b.ok, "esito_push_promemoria");
}
assert.equal((await coda(id))?.stato, "in_invio");
});
} finally {
await rest(
`push_subscriptions?endpoint=like.${encodeURIComponent(`https://push.${PREFISSO}.example/*`)}`,
SERVIZIO,
{
method: "DELETE",
},
);
await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
for (const id of [...EVENTI, ...eventiCreati]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
for (const id of idUtenti) {
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("push-promemoria");
}
}
@@ -0,0 +1,250 @@
/**
* Sondaggio pre-partita in-app ed email (M24, DD-038): `bun test/integration/sondaggio-notifiche.test.ts`.
*
* La route `apri-sondaggio` mandava solo la push. Ora scrive anche `notifiche_utente` (tipo
* `sondaggio_cacche`) per i giocatori attivi, allenatori esclusi: compare nel centro notifiche e
* la mail parte dal trigger di M22. Qui si verifica quella scrittura chiamando la route vera.
*
* Gira solo sullo stack locale (`npx supabase start`) e avvia il server di sviluppo puntato al
* database locale (o usa quello indicato da `BASE_URL`). Usa gli slot `g5` (reso non attivo) e
* `g6` (reso allenatore) della rosa seed, ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { avviaServer } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("sondaggio in-app", "stack locale non attivo (npx supabase start)");
riepilogo("sondaggio-notifiche");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`sondaggio in-app su ${URL_BASE}`);
// Il server di sviluppo eredita queste: senza, le route punterebbero al progetto cloud.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const PREFISSO = "test-sondaggio-notifiche";
const PARTITA = `${PREFISSO}-partita`;
const INATTIVO = "g5";
const ALLENATORE = "g6";
const PASSWORD = "prova-sondaggio-notifiche-123";
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, token: string, metodo: string, corpo?: unknown) {
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 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 Notifica = {
id: string;
giocatore_id: string;
letta: boolean;
titolo: string;
creato_il: string;
};
const notifiche = () =>
leggi<Notifica>(
`notifiche_utente?evento_id=eq.${PARTITA}&tipo=eq.sondaggio_cacche&select=id,giocatore_id,letta,titolo,creato_il&order=giocatore_id`,
);
const idUtenti: string[] = [];
let server: { baseUrl: string; stop: () => void } | null = null;
let tokenAdmin = "";
const originali = new Map<string, { attivo: boolean; tipo: string }>();
try {
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
const giocatore = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(admin.id, giocatore.id);
await scrivi("user_roles", SERVIZIO, "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [INATTIVO, ALLENATORE]) {
const [riga] = await leggi<{ attivo: boolean; tipo: string }>(
`giocatori_squadra?id=eq.${id}&select=attivo,tipo`,
);
originali.set(id, riga ?? { attivo: true, tipo: "giocatore" });
}
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, tokenAdmin, "PATCH", { attivo: false });
await scrivi(`giocatori_squadra?id=eq.${ALLENATORE}`, tokenAdmin, "PATCH", {
tipo: "allenatore",
});
await scrivi("eventi_app", SERVIZIO, "POST", {
id: PARTITA,
tipo: "partita",
titolo: `${PREFISSO} partita`,
data: "2030-01-01",
ora: "21:00",
convocati: [],
});
const attesi = (
await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&tipo=eq.giocatore&select=id")
)
.map((g) => g.id)
.sort();
assert.ok(attesi.length > 3, "la rosa seed ha più giocatori attivi");
assert.ok(!attesi.includes(INATTIVO) && !attesi.includes(ALLENATORE));
server = await avviaServer();
console.log(`route apri-sondaggio su ${server.baseUrl}`);
const chiama = (token: string) =>
fetch(`${server!.baseUrl}/api/public/apri-sondaggio`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ eventoId: PARTITA }),
});
await prova("chi non è admin non può aprire il sondaggio e non scrive nulla", async () => {
const res = await chiama(giocatore.token);
assert.ok(!res.ok, `stato ${res.status}`);
assert.equal((await notifiche()).length, 0);
});
await prova("l'admin apre il sondaggio: una notifica per ogni giocatore attivo", async () => {
const res = await chiama(tokenAdmin);
assert.equal(res.status, 200);
const righe = await notifiche();
assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), attesi);
assert.ok(righe.every((r) => r.titolo === "💩 Sondaggio pre-partita aperto"));
assert.ok(righe.every((r) => !r.letta));
});
await prova("l'allenatore e chi non è attivo non ricevono l'avviso in-app", async () => {
const righe = await notifiche();
assert.ok(!righe.some((r) => r.giocatore_id === ALLENATORE));
assert.ok(!righe.some((r) => r.giocatore_id === INATTIVO));
});
await prova(
"l'avviso arriva anche a chi non ha nessun dispositivo iscritto alla push",
async () => {
const iscritti = await leggi<{ giocatore_id: string }>(
"push_subscriptions?select=giocatore_id",
);
const conPush = new Set(iscritti.map((i) => i.giocatore_id));
const senza = attesi.filter((id) => !conPush.has(id));
assert.ok(senza.length > 0, "nel database di prova nessuno ha la push");
const righe = await notifiche();
for (const id of senza)
assert.ok(
righe.some((r) => r.giocatore_id === id),
id,
);
},
);
await prova(
"ogni notifica del sondaggio ha la sua mail in coda e nessuna riga push",
async () => {
const righe = await notifiche();
const ids = righe.map((r) => r.id).join(",");
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids})&select=notifica_id`,
);
assert.equal(email.length, righe.length, "una mail per notifica");
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=in.(${ids})&select=notifica_id`,
);
assert.equal(push.length, 0, "la push del sondaggio parte già dalla route");
},
);
await prova(
"ripremere il pulsante aggiorna le notifiche, riporta a non letta e rimanda la mail",
async () => {
const prima = await notifiche();
const [scelta] = prima;
assert.ok(scelta);
await scrivi(`notifiche_utente?id=eq.${scelta.id}`, SERVIZIO, "PATCH", { letta: true });
await scrivi(`notifiche_email_coda?notifica_id=eq.${scelta.id}`, SERVIZIO, "PATCH", {
stato: "inviata",
});
const res = await chiama(tokenAdmin);
assert.equal(res.status, 200);
const dopo = await notifiche();
assert.equal(
dopo.length,
prima.length,
"nessun doppione: upsert su giocatore, evento e tipo",
);
const aggiornata = dopo.find((r) => r.id === scelta.id);
assert.equal(aggiornata?.letta, false);
assert.ok(aggiornata!.creato_il > scelta.creato_il, "creato_il rinnovato");
const [coda] = await leggi<{ stato: string }>(
`notifiche_email_coda?notifica_id=eq.${scelta.id}&select=stato`,
);
assert.equal(coda?.stato, "in_coda", "la mail riparte");
},
);
} finally {
server?.stop();
await rest(`notifiche_utente?evento_id=eq.${PARTITA}`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=eq.${PARTITA}`, 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("sondaggio-notifiche");
}
}
+22
View File
@@ -1,6 +1,8 @@
/** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */
import assert from "node:assert/strict";
import {
avvisoSondaggio,
destinatariSondaggio,
mediaPartita,
mediaStagione,
recordStagione,
@@ -118,4 +120,24 @@ assert.equal(
"partita passata: terminato",
);
// --- Avviso del sondaggio (DD-038) ---------------------------------------------
assert.deepEqual(avvisoSondaggio({ titolo: "Juve - CRAP", ora: "21:00" }), {
titolo: "💩 Sondaggio pre-partita aperto",
testo: "Juve - CRAP · ore 21:00. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.",
});
// Destinatari in-app ed email: giocatori attivi, mai gli allenatori né chi non è più attivo,
// a prescindere dai dispositivi iscritti alla push.
assert.deepEqual(
destinatariSondaggio([
{ id: "g1", attivo: true, tipo: "giocatore" },
{ id: "g2", attivo: false, tipo: "giocatore" },
{ id: "g3", attivo: true, tipo: "allenatore" },
{ id: "g4", attivo: true, tipo: "giocatore" },
]),
["g1", "g4"],
"solo i giocatori attivi",
);
assert.deepEqual(destinatariSondaggio([]), [], "rosa vuota: nessun destinatario");
console.log("cacche: ok");
+632
View File
@@ -0,0 +1,632 @@
/** Logica pura del worker email (M22): `bun test/unit/mailer-core.test.ts`. Nessuna rete né SMTP. */
import assert from "node:assert/strict";
import {
MAX_TENTATIVI,
classificaErroreSmtp,
classificaStatoPush,
componiMail,
descriviErrore,
elaboraCiclo,
elaboraCicloPush,
escapeHtml,
invioConsentiti,
leggiConfig,
linkNotifica,
messageId,
pulisciOggetto,
ritardoRiprova,
staSano,
type Dipendenze,
type DipendenzePush,
type EsitoInvio,
type EsitoPush,
type IscrizionePush,
type MailComposta,
type NotificaEmail,
type PushDaInviare,
} from "../../mailer/mailer-core.ts";
import { prova, riepilogo } from "../helpers/prova";
const ENV_BASE = {
SUPABASE_URL: "https://x.supabase.co/",
SUPABASE_SERVICE_ROLE_KEY: "chiave",
SMTP_USER: "crapp@gmail.com",
SMTP_PASS: "password-per-app",
APP_URL: "https://crapp.example/",
};
// --- leggiConfig --------------------------------------------------------------
await prova("configurazione completa: default, slash finali tolti, From = SMTP_USER", () => {
const r = leggiConfig(ENV_BASE);
assert.ok("config" in r);
const c = r.config;
assert.equal(c.supabaseUrl, "https://x.supabase.co");
assert.equal(c.appUrl, "https://crapp.example");
assert.equal(c.from, "crapp@gmail.com");
assert.equal(c.fromName, "CrAPP");
assert.deepEqual(c.smtp, {
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: { user: "crapp@gmail.com", pass: "password-per-app" },
});
assert.equal(c.pollSecondi, 30);
assert.equal(c.limiteGiorno, 400);
assert.equal(c.lotto, 10);
});
await prova("elenca insieme tutte le variabili mancanti", () => {
const r = leggiConfig({});
assert.ok("errori" in r);
assert.deepEqual(r.errori, [
"SUPABASE_URL mancante",
"SUPABASE_SERVICE_ROLE_KEY mancante",
"APP_URL mancante",
"MAIL_FROM mancante (o SMTP_USER)",
]);
});
await prova("SMTP_USER e SMTP_PASS vanno insieme", () => {
const r = leggiConfig({ ...ENV_BASE, SMTP_PASS: "" });
assert.ok("errori" in r);
assert.ok(r.errori.includes("SMTP_USER e SMTP_PASS vanno impostati insieme"));
});
await prova("senza credenziali SMTP (server di prova) serve MAIL_FROM e non c'è auth", () => {
const { SMTP_USER: _u, SMTP_PASS: _p, ...senza } = ENV_BASE;
assert.ok("errori" in leggiConfig(senza));
const r = leggiConfig({ ...senza, MAIL_FROM: "prova@crapp.local", SMTP_PORT: "1025" });
assert.ok("config" in r);
assert.equal(r.config.smtp.auth, null);
assert.equal(r.config.smtp.port, 1025);
});
await prova("un numero non valido è un errore, non un default silenzioso", () => {
for (const valore of ["0", "-5", "abc", "1.5"]) {
const r = leggiConfig({ ...ENV_BASE, POLL_SECONDI: valore });
assert.ok("errori" in r, valore);
assert.deepEqual(r.errori, ["POLL_SECONDI deve essere un intero positivo"]);
}
});
// --- classificaErroreSmtp -----------------------------------------------------
await prova("il tetto giornaliero di Gmail non è un errore permanente", () => {
const errore = {
responseCode: 550,
response: "550-5.4.5 Daily user sending quota exceeded.",
};
assert.equal(classificaErroreSmtp(errore), "limite");
});
await prova("credenziali rifiutate: configurazione, non colpa della mail", () => {
assert.equal(classificaErroreSmtp({ code: "EAUTH", responseCode: 535 }), "configurazione");
assert.equal(classificaErroreSmtp({ responseCode: 534 }), "configurazione");
});
await prova("5xx sul destinatario e indirizzo non valido sono permanenti", () => {
assert.equal(
classificaErroreSmtp({ responseCode: 550, response: "550 no such user" }),
"permanente",
);
assert.equal(classificaErroreSmtp({ code: "EENVELOPE" }), "permanente");
});
await prova("4xx e errori di rete sono temporanei", () => {
assert.equal(classificaErroreSmtp({ responseCode: 421 }), "temporaneo");
assert.equal(classificaErroreSmtp({ code: "ETIMEDOUT" }), "temporaneo");
assert.equal(classificaErroreSmtp({ code: "ECONNECTION" }), "temporaneo");
assert.equal(classificaErroreSmtp(new Error("boh")), "temporaneo");
assert.equal(classificaErroreSmtp(null), "temporaneo");
});
await prova("descriviErrore riporta solo codici, mai il testo della risposta", () => {
const errore = { code: "EMESSAGE", responseCode: 550, response: "550 nome@gmail.com non esiste" };
assert.equal(descriviErrore(errore), "EMESSAGE 550");
assert.equal(descriviErrore(new Error("mario@gmail.com")), "errore sconosciuto");
});
// --- backoff e tetto ----------------------------------------------------------
await prova("backoff 1 · 5 · 30 · 120 minuti, poi nessun altro tentativo", () => {
assert.deepEqual([0, 1, 2, 3].map(ritardoRiprova), [1, 5, 30, 120]);
assert.equal(ritardoRiprova(4), null);
assert.equal(MAX_TENTATIVI, 5);
});
await prova("invioConsentiti non supera né il lotto né il tetto delle 24 ore", () => {
assert.equal(invioConsentiti(400, 0, 10), 10);
assert.equal(invioConsentiti(400, 395, 10), 5);
assert.equal(invioConsentiti(400, 400, 10), 0);
assert.equal(invioConsentiti(400, 450, 10), 0);
});
await prova("staSano: sano solo con un ciclo riuscito abbastanza recente", () => {
assert.equal(staSano(null, 1_000_000, 300_000), false);
assert.equal(staSano(900_000, 1_000_000, 300_000), true);
assert.equal(staSano(600_000, 1_000_000, 300_000), false);
});
// --- composizione della mail --------------------------------------------------
const notifica = (extra: Partial<NotificaEmail> = {}): NotificaEmail => ({
id_notifica: "n-1",
destinatario: "mario@gmail.com",
oggetto: "Promemoria: Allenamento",
testo: "30/09/2026 alle 20:30",
id_evento: "ev-1",
tipo_notifica: "evento_promemoria_24h",
tentativi_fatti: 0,
...extra,
});
const OPZ = { appUrl: "https://crapp.example", dominioMessageId: "gmail.com" };
await prova("link: scheda evento se c'è, altrimenti la home; id codificato", () => {
assert.equal(linkNotifica("https://a.it/", "ev-1"), "https://a.it/evento/ev-1");
assert.equal(linkNotifica("https://a.it", null), "https://a.it");
assert.equal(linkNotifica("https://a.it", "a b/c"), "https://a.it/evento/a%20b%2Fc");
});
await prova("Message-ID deterministico per notifica", () => {
assert.equal(messageId("abc", "gmail.com"), "<abc@gmail.com>");
assert.equal(componiMail(notifica(), OPZ).messageId, componiMail(notifica(), OPZ).messageId);
});
await prova("oggetto su una riga sola, con un tetto e un ripiego", () => {
assert.equal(pulisciOggetto("Ciao\r\nBcc: x@y.it"), "Ciao Bcc: x@y.it");
assert.equal(pulisciOggetto(" "), "Notifica CrAPP");
assert.equal(pulisciOggetto("a".repeat(500)).length, 200);
});
await prova("la mail ha testo, HTML, link all'evento e come disattivarle", () => {
const m = componiMail(notifica(), OPZ);
assert.equal(m.to, "mario@gmail.com");
assert.equal(m.subject, "Promemoria: Allenamento");
assert.match(m.text, /30\/09\/2026 alle 20:30/);
assert.match(m.text, /https:\/\/crapp\.example\/evento\/ev-1/);
assert.match(m.text, /Profilo → Opzioni → Email/);
assert.match(m.html, /href="https:\/\/crapp\.example\/evento\/ev-1"/);
assert.match(m.html, /Apri CrAPP/);
});
await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () => {
const m = componiMail(
notifica({ testo: `<img src=x onerror=alert(1)>\nriga "2"`, id_evento: null }),
OPZ,
);
assert.ok(!m.html.includes("<img"));
assert.match(m.html, /&lt;img src=x onerror=alert\(1\)&gt;<br>riga &quot;2&quot;/);
assert.match(m.html, /href="https:\/\/crapp\.example"/);
assert.equal(escapeHtml(`<&>"'`), "&lt;&amp;&gt;&quot;&#39;");
});
await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => {
const m = componiMail(notifica({ oggetto: "<b>Ciao</b>" }), OPZ);
assert.ok(!m.html.includes("<b>"));
});
await prova("corpo vuoto: niente paragrafo vuoto", () => {
const m = componiMail(notifica({ testo: "" }), OPZ);
assert.ok(!m.html.includes('<p style="margin:0 0 16px"></p>'));
assert.ok(!m.text.startsWith("\n"));
});
// --- elaboraCiclo -------------------------------------------------------------
type Chiamata = { id: string; esito: EsitoInvio; errore?: string; prossimo?: Date };
function ambiente(opzioni: {
lotto?: NotificaEmail[];
inviate24h?: number;
invia?: (mail: MailComposta) => Promise<void>;
segna?: Dipendenze["segna"];
}) {
const chiamate: Chiamata[] = [];
const inviate: MailComposta[] = [];
const log: string[] = [];
let richiesto = -1;
const ADESSO = new Date("2026-09-29T10:00:00Z");
const dip: Dipendenze = {
prendi: async (max) => {
richiesto = max;
return opzioni.lotto ?? [];
},
segna:
opzioni.segna ??
(async (id, esito, errore, prossimo) => {
chiamate.push({
id,
esito,
...(errore !== undefined ? { errore } : {}),
...(prossimo ? { prossimo } : {}),
});
}),
invia:
opzioni.invia ??
(async (mail) => {
inviate.push(mail);
}),
inviateUltime24h: async () => opzioni.inviate24h ?? 0,
adesso: () => ADESSO,
log: (m) => log.push(m),
};
return { dip, chiamate, inviate, log, ADESSO, richiesto: () => richiesto };
}
const OPZ_CICLO = { limiteGiorno: 400, lotto: 10, ...OPZ };
await prova("ciclo: invia ogni mail e segna 'inviata'", async () => {
const a = ambiente({ lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })] });
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.inviate, 2);
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[
["a", "inviata"],
["b", "inviata"],
],
);
assert.equal(a.inviate.length, 2);
});
await prova("ciclo: chiede al massimo quanto consente il tetto giornaliero", async () => {
const a = ambiente({ inviate24h: 396 });
await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(a.richiesto(), 4);
});
await prova("ciclo: tetto raggiunto, non prende nulla dalla coda", async () => {
const a = ambiente({ inviate24h: 400, lotto: [notifica()] });
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fermato, "limite");
assert.equal(a.richiesto(), -1);
assert.equal(a.chiamate.length, 0);
});
await prova("ciclo: errore temporaneo, riprova con backoff sul numero di tentativi", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a", tentativi_fatti: 2 })],
invia: async () => {
throw { code: "ETIMEDOUT" };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.riprovate, 1);
const [c] = a.chiamate;
assert.equal(c?.esito, "riprova");
assert.equal(c?.prossimo?.getTime(), a.ADESSO.getTime() + 30 * 60_000);
});
await prova("ciclo: all'ultimo tentativo l'errore temporaneo diventa 'fallita'", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a", tentativi_fatti: 4 })],
invia: async () => {
throw { responseCode: 421 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fallite, 1);
assert.equal(a.chiamate[0]?.esito, "fallita");
});
await prova("ciclo: errore permanente, subito 'fallita' senza riprovare", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" })],
invia: async () => {
throw { responseCode: 550, response: "550 no such user" };
},
});
await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(a.chiamate[0]?.esito, "fallita");
assert.equal(a.chiamate[0]?.errore, "550");
});
await prova("ciclo: una mail fallita non ferma le successive", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })],
invia: async (mail) => {
if (mail.messageId.startsWith("<a@")) throw { responseCode: 550 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fallite, 1);
assert.equal(r.inviate, 1);
assert.equal(r.fermato, null);
});
await prova(
"ciclo: tetto Gmail a metà lotto, rimanda questa e le successive e si ferma",
async () => {
let n = 0;
const a = ambiente({
lotto: [
notifica({ id_notifica: "a" }),
notifica({ id_notifica: "b" }),
notifica({ id_notifica: "c" }),
],
invia: async () => {
n += 1;
if (n === 2)
throw { responseCode: 550, response: "550-5.4.5 Daily user sending quota exceeded" };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fermato, "limite");
assert.equal(r.inviate, 1);
assert.equal(r.differite, 2);
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[
["a", "inviata"],
["b", "differita"],
["c", "differita"],
],
);
assert.equal(a.chiamate[1]?.prossimo?.getTime(), a.ADESSO.getTime() + 60 * 60_000);
},
);
await prova(
"ciclo: credenziali SMTP rifiutate, tutto differito senza consumare tentativi",
async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })],
invia: async () => {
throw { code: "EAUTH", responseCode: 535 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fermato, "configurazione");
assert.deepEqual(
a.chiamate.map((c) => c.esito),
["differita", "differita"],
);
assert.equal(r.fallite, 0);
},
);
await prova(
"ciclo: se la registrazione dell'esito fallisce non si riprova la mail già inviata",
async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" })],
segna: async () => {
throw new Error("rete giù");
},
});
await assert.rejects(elaboraCiclo(a.dip, OPZ_CICLO), /rete giù/);
assert.equal(a.inviate.length, 1);
},
);
await prova("ciclo: i log non contengono indirizzi né testi", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })],
invia: async (mail) => {
if (mail.messageId.startsWith("<b@"))
throw { responseCode: 550, response: "550 mario@gmail.com" };
},
});
await elaboraCiclo(a.dip, OPZ_CICLO);
const tutto = a.log.join("\n");
assert.ok(!tutto.includes("mario@gmail.com"));
assert.ok(!tutto.includes("30/09/2026"));
assert.ok(!tutto.includes("Allenamento"));
});
// --- Configurazione VAPID (M24) -----------------------------------------------
await prova("senza chiavi VAPID il worker parte e manda solo le email", () => {
const r = leggiConfig(ENV_BASE);
assert.ok("config" in r);
assert.equal(r.config.vapid, null);
});
await prova("con le chiavi VAPID la push dei promemoria è attiva", () => {
const r = leggiConfig({
...ENV_BASE,
VAPID_PUBLIC_KEY: "pub",
VAPID_PRIVATE_KEY: "priv",
VAPID_SUBJECT: "mailto:a@b.it",
});
assert.ok("config" in r);
assert.deepEqual(r.config.vapid, {
publicKey: "pub",
privateKey: "priv",
subject: "mailto:a@b.it",
});
});
await prova("le chiavi VAPID vanno impostate insieme", () => {
for (const env of [{ VAPID_PUBLIC_KEY: "pub" }, { VAPID_PRIVATE_KEY: "priv" }]) {
const r = leggiConfig({ ...ENV_BASE, ...env });
assert.ok("errori" in r);
assert.deepEqual(r.errori, ["VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme"]);
}
});
// --- classificaStatoPush ------------------------------------------------------
await prova("stato HTTP del servizio push: accettata, scaduta, temporanea, permanente", () => {
for (const s of [200, 201, 202]) assert.equal(classificaStatoPush(s), "ok", String(s));
for (const s of [404, 410]) assert.equal(classificaStatoPush(s), "scaduta", String(s));
for (const s of [429, 500, 502, 503])
assert.equal(classificaStatoPush(s), "temporaneo", String(s));
for (const s of [400, 401, 403, 413])
assert.equal(classificaStatoPush(s), "permanente", String(s));
});
// --- elaboraCicloPush ---------------------------------------------------------
const disp = (n: number): IscrizionePush[] =>
Array.from({ length: n }, (_, i) => ({
endpoint: `https://push.example/${i}`,
p256dh: "k",
auth: "a",
}));
const pushDa = (extra: Partial<PushDaInviare> = {}): PushDaInviare => ({
id_notifica: "p-1",
oggetto: "Promemoria: Allenamento",
testo: "30/09/2026 alle 20:30",
tentativi_fatti: 0,
iscrizioni: disp(1),
...extra,
});
type ChiamataPush = { id: string; esito: EsitoPush; errore?: string; prossimo?: Date };
function ambientePush(opzioni: {
lotto?: PushDaInviare[];
risposta?: (iscrizione: IscrizionePush) => Promise<{ stato: number }>;
elimina?: (endpoint: string) => Promise<void>;
}) {
const chiamate: ChiamataPush[] = [];
const inviate: Array<{ endpoint: string; titolo: string; testo: string }> = [];
const eliminate: string[] = [];
const log: string[] = [];
const ADESSO = new Date("2026-09-29T10:00:00Z");
const dip: DipendenzePush = {
prendi: async () => opzioni.lotto ?? [],
segna: async (id, esito, errore, prossimo) => {
chiamate.push({
id,
esito,
...(errore !== undefined ? { errore } : {}),
...(prossimo ? { prossimo } : {}),
});
},
invia: async (iscrizione, titolo, testo) => {
inviate.push({ endpoint: iscrizione.endpoint, titolo, testo });
return opzioni.risposta ? opzioni.risposta(iscrizione) : { stato: 201 };
},
elimina:
opzioni.elimina ??
(async (endpoint) => {
eliminate.push(endpoint);
}),
adesso: () => ADESSO,
log: (m) => log.push(m),
};
return { dip, chiamate, inviate, eliminate, log, ADESSO };
}
await prova("push: manda titolo e testo a tutti i dispositivi e segna 'inviata'", async () => {
const a = ambientePush({ lotto: [pushDa({ iscrizioni: disp(2) })] });
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(a.inviate.length, 2);
assert.equal(a.inviate[0]?.titolo, "Promemoria: Allenamento");
assert.equal(a.inviate[0]?.testo, "30/09/2026 alle 20:30");
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[["p-1", "inviata"]],
);
});
await prova("push: basta un dispositivo che accetta, quello scaduto viene eliminato", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 201 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(r.iscrizioniRimosse, 1);
assert.deepEqual(a.eliminate, ["https://push.example/0"]);
assert.equal(a.chiamate[0]?.esito, "inviata");
});
await prova("push: tutte le iscrizioni scadute vuol dire 'saltata', non un errore", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async () => ({ stato: 404 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.saltate, 1);
assert.equal(r.iscrizioniRimosse, 2);
assert.equal(a.chiamate[0]?.esito, "saltata");
});
await prova("push: nessuna iscrizione nella riga, 'saltata' senza chiamate", async () => {
const a = ambientePush({ lotto: [pushDa({ iscrizioni: [] })] });
await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(a.inviate.length, 0);
assert.equal(a.chiamate[0]?.esito, "saltata");
});
await prova("push: errore temporaneo, riprova con backoff sul numero di tentativi", async () => {
const a = ambientePush({
lotto: [pushDa({ tentativi_fatti: 1 })],
risposta: async () => ({ stato: 503 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.riprovate, 1);
assert.equal(a.chiamate[0]?.esito, "riprova");
assert.equal(a.chiamate[0]?.errore, "503");
assert.equal(a.chiamate[0]?.prossimo?.getTime(), a.ADESSO.getTime() + 5 * 60_000);
});
await prova("push: rete giù, riprova; all'ultimo tentativo 'fallita'", async () => {
const giu = async () => {
throw new Error("rete");
};
const a = ambientePush({ lotto: [pushDa({ tentativi_fatti: 0 })], risposta: giu });
await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(a.chiamate[0]?.esito, "riprova");
assert.equal(a.chiamate[0]?.errore, "rete");
const b = ambientePush({ lotto: [pushDa({ tentativi_fatti: 4 })], risposta: giu });
const r = await elaboraCicloPush(b.dip, { lotto: 10 });
assert.equal(r.fallite, 1);
assert.equal(b.chiamate[0]?.esito, "fallita");
});
await prova("push: rifiuto permanente (chiavi VAPID sbagliate) è subito 'fallita'", async () => {
const a = ambientePush({ lotto: [pushDa()], risposta: async () => ({ stato: 403 }) });
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.fallite, 1);
assert.equal(a.chiamate[0]?.esito, "fallita");
assert.equal(a.chiamate[0]?.errore, "403");
});
await prova("push: una notifica che fallisce non ferma le successive", async () => {
const a = ambientePush({
lotto: [pushDa({ id_notifica: "p-1" }), pushDa({ id_notifica: "p-2", iscrizioni: disp(1) })],
risposta: async () => ({ stato: 201 }),
});
a.dip.invia = async (_i, _t, _x) => {
if (a.inviate.length === 0) {
a.inviate.push({ endpoint: "x", titolo: "", testo: "" });
throw new Error("rete");
}
return { stato: 201 };
};
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.riprovate, 1);
assert.equal(r.inviate, 1);
});
await prova("push: se l'eliminazione dell'iscrizione fallisce, il resto prosegue", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 201 }),
elimina: async () => {
throw new Error("db giù");
},
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(r.iscrizioniRimosse, 0);
});
await prova("push: la registrazione dell'esito che fallisce interrompe il ciclo", async () => {
const a = ambientePush({ lotto: [pushDa()] });
a.dip.segna = async () => {
throw new Error("rete giù");
};
await assert.rejects(elaboraCicloPush(a.dip, { lotto: 10 }), /rete giù/);
});
await prova("push: i log non contengono endpoint né testi dei promemoria", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) }), pushDa({ id_notifica: "p-2" })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 503 }),
});
await elaboraCicloPush(a.dip, { lotto: 10 });
const tutto = a.log.join("\n");
assert.ok(!tutto.includes("push.example"));
assert.ok(!tutto.includes("Allenamento"));
assert.ok(!tutto.includes("20:30"));
});
riepilogo("mailer-core");
+16
View File
@@ -0,0 +1,16 @@
/** Preferenze dell'account (M22): `bun test/unit/preferenze-utente.test.ts`. */
import assert from "node:assert/strict";
import { emailAttive } from "@/lib/preferenze-utente";
import { prova, riepilogo } from "../helpers/prova";
await prova("senza riga le email sono attive (default DD-036)", () => {
assert.equal(emailAttive(null), true);
assert.equal(emailAttive(undefined), true);
});
await prova("con la riga vale il valore scelto dal giocatore", () => {
assert.equal(emailAttive({ email_notifiche: true }), true);
assert.equal(emailAttive({ email_notifiche: false }), false);
});
riepilogo("preferenze-utente");
+21
View File
@@ -6,6 +6,7 @@ import {
contaPartiteGiocate,
contaPresenzeGiocatore,
destinatariSollecito,
includeInfortunato,
serieConferme,
serieConsecutiva,
totaliEventiGiocatore,
@@ -295,4 +296,24 @@ const conAltri2 = conRisposta(
assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache");
assert.equal(conAltri2.tempi["e2"]?.["g2"], POI);
// --- includeInfortunato: "infortunato" fuori per gli eventi extra-campo -------
assert.equal(includeInfortunato("allenamento"), true, "allenamento: infortunato è un'opzione");
assert.equal(includeInfortunato("partita"), true, "partita: infortunato è un'opzione");
assert.equal(includeInfortunato("evento"), false, "evento extra-campo: niente infortunato");
assert.equal(
includeInfortunato("evento", "infortunato"),
true,
"chi ha già risposto infortunato lo vede ancora, per poterlo togliere",
);
assert.equal(
includeInfortunato("evento", "presente"),
false,
"un'altra risposta esistente non riapre l'opzione",
);
assert.equal(
includeInfortunato("evento", undefined),
false,
"nessuna risposta: l'opzione resta chiusa per gli eventi extra-campo",
);
console.log("presenze: ok");
+8
View File
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/public/keepalive",
"schedule": "0 5 * * *"
}
]
}