Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12011d330 | ||
|
|
7143c3d2eb | ||
|
|
71b2d97fae | ||
|
|
9b4e3e4b45 | ||
|
|
563142a30a | ||
|
|
07624f6b9f | ||
|
|
ded71f567b | ||
|
|
b6dee87eb3 | ||
|
|
4760e8650c | ||
|
|
e9013b703b | ||
|
|
58eba642f7 | ||
|
|
969dc1400c | ||
|
|
b7d3fde225 | ||
|
|
2a0b611459 | ||
|
|
cf667453a7 | ||
|
|
39918f2eb7 | ||
|
|
a72eb087d1 | ||
|
|
34edfc662e | ||
|
|
e0a9d19a02 | ||
|
|
3c2f9cf876 | ||
|
|
93d70f4879 | ||
|
|
20ca6ba5a6 | ||
|
|
342b457ee7 | ||
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 |
No files matched your search
+3
-1
@@ -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
-8
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 24/09/2026
|
||||
Ultimo aggiornamento: 29/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
@@ -10,8 +10,10 @@ 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.1: notifiche su tre canali (push, in-app
|
||||
ed email), promemoria che non si ripetono, turno palloni e solleciti presenze automatici (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 +26,12 @@ 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)
|
||||
- 37 migration in `supabase/migrations/`, fino a `m26_notifiche_automatiche` (30/09/2026), tutte
|
||||
applicate in produzione, verificato con `npx supabase migration list` il 30/09/2026
|
||||
- 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 +61,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,9 +206,9 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: tutto quello che era in lavorazione è chiuso, tesseramento CSI incluso
|
||||
(numero e data di tessera registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci
|
||||
ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0), le notifiche su tre
|
||||
canali (DD-036–DD-039, 1.2.0) e quelle automatiche di palloni e solleciti (DD-040, 1.2.1) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
---
|
||||
|
||||
|
||||
+33
-12
@@ -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
@@ -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
|
||||
@@ -13,6 +13,147 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.2.1] - 2026-09-30
|
||||
|
||||
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
|
||||
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
|
||||
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
|
||||
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
|
||||
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
|
||||
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
|
||||
turno» resta.
|
||||
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
|
||||
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
|
||||
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
|
||||
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
|
||||
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
|
||||
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
|
||||
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
|
||||
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
|
||||
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
|
||||
non riceve tutte le notifiche insieme.
|
||||
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
|
||||
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
|
||||
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
|
||||
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
|
||||
- **Documentazione** — DD-040 in `DESIGN_DECISIONS.md`; sezione «Testi delle notifiche» nel catalogo di
|
||||
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
|
||||
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
|
||||
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
|
||||
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
|
||||
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
|
||||
palloni manuale non parte più per email.
|
||||
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
|
||||
di riconsegna, come già descritto nel catalogo.
|
||||
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
|
||||
«successivo» coincidono con quelli dei job del database.
|
||||
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
|
||||
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
|
||||
|
||||
### Corretto
|
||||
|
||||
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
|
||||
|
||||
## [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
|
||||
|
||||
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
|
||||
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
|
||||
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
|
||||
nessuna push (DD-035).
|
||||
|
||||
## [1.0.0] - 2026-09-24
|
||||
|
||||
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
|
||||
|
||||
+30
-6
@@ -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,32 @@ 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. Migration `m26_notifiche_automatiche` (DD-040): il vincolo `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job; `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti. Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi automatici ma non i due dei pulsanti. |
|
||||
| `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). Con DD-040 (migration `m26`) il trigger salta i tipi del turno palloni, che non mandano email. |
|
||||
| `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. Con DD-040 (migration `m26`) il `CHECK` sul tipo ammette anche i tipi dei palloni (12h, 6h, 3h, revocato) e dei solleciti (24h, 12h, 6h); è anche la memoria che permette la revoca. |
|
||||
| `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`. Con DD-040 (migration `m26`) accoda anche turno palloni e solleciti presenze. |
|
||||
|
||||
### Funzioni SQL e job delle notifiche
|
||||
|
||||
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
|
||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26), con `genera_avvisi_palloni_fascia()` | Avvisi del turno palloni a 12, 6 e 3 ore per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26), con `..._fascia()` | Solleciti a 24, 12 e 6 ore ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
|
||||
## Funzioni speciali
|
||||
|
||||
|
||||
+408
-1
@@ -50,6 +50,12 @@ Serve a rispondere a domande del tipo:
|
||||
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
|
||||
| [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 |
|
||||
| [DD-040](#dd-040--promemoria-con-oggi-o-domani-turno-palloni-e-solleciti-automatici) | Palloni e solleciti automatici |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
@@ -934,6 +940,9 @@ torna utile, e con lui il segreto: DD-024 descrive già come farlo.
|
||||
|
||||
---
|
||||
|
||||
**Aggiornamento (30 settembre 2026)** — DD-040 affianca al pulsante un invio automatico a 12, 6 e 3 ore
|
||||
prima dell'evento, per gli eventi con un incaricato confermato; il pulsante e la route restano.
|
||||
|
||||
### DD-026 — Il testo della notifica viaggia dentro la push
|
||||
|
||||
**Stato:** accettata · **Data:** settembre 2026
|
||||
@@ -1445,9 +1454,407 @@ 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**
|
||||
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
|
||||
fonte di bug, passare alla tabella `staff` separata.
|
||||
|
||||
### DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
|
||||
|
||||
**Data:** 24 settembre 2026
|
||||
**Stato:** Accettata (implementata, versione 1.1.0)
|
||||
|
||||
**Contesto**
|
||||
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
|
||||
oggi ci si accorge che è scaduto solo aprendo la tab Profili di `/admin`: spesso quando il
|
||||
giocatore è già fuori regola. La richiesta è un avviso in Home, giallo nei 7 giorni prima
|
||||
della scadenza e nero dopo, con i nomi dei giocatori, che sparisca da solo quando il
|
||||
certificato viene rinnovato. Specifica completa in
|
||||
[modules/profilo-giocatore.md](modules/profilo-giocatore.md#avviso-certificati).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. L'avviso si **calcola al volo** dalla data già salvata, con una funzione pura in
|
||||
`profili-core.ts`: nessuna tabella, nessuno stato «avvisato», nessuna migration.
|
||||
2. Lo vedono **solo gli admin**, con i nomi di tutta la rosa, e **il giocatore interessato**,
|
||||
riferito solo a sé. L'allenatore e i compagni non lo vedono.
|
||||
3. L'avviso dello staff **non è cliccabile**; quello personale apre
|
||||
`/profilo?tab=documenti`.
|
||||
4. Soglia fissa di **7 giorni** in una costante; il giorno della scadenza il certificato vale
|
||||
ancora (come `statoScadenza()`).
|
||||
5. **Niente push**: l'avviso vive solo in Home.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Mostrarlo anche all'allenatore → il certificato è un dato sanitario che oggi l'allenatore
|
||||
non vede (DD-034); servirebbe una funzione `SECURITY DEFINER` apposta per un'informazione
|
||||
che all'allenatore non serve ad agire.
|
||||
- Salvare gli avvisi in una tabella o nel centro notifiche (DD-030) → uno stato da tenere
|
||||
allineato alla data, che si disallinea appena il giocatore corregge la scadenza; calcolarlo
|
||||
è gratis e sempre giusto.
|
||||
- Avviso dello staff cliccabile verso `/admin?tab=profili` → l'admin non può caricare il file
|
||||
al posto del giocatore (DD-017), quindi il tocco non gli farebbe fare nulla in più, e
|
||||
richiederebbe di aggiungere a `/admin` la lettura della tab dall'URL.
|
||||
- Mostrare al massimo alcuni nomi e un «e altri N» → a inizio stagione ne scadono molti
|
||||
insieme ed è proprio lì che l'elenco completo serve.
|
||||
- Push quotidiana a chi ha il certificato in scadenza → servirebbe un cron, che oggi non
|
||||
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
|
||||
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
|
||||
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
|
||||
dal widget «Completa il tuo profilo» e dalla tab Profili.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Nessuna query nuova: si riusa `useProfili()`, già in cache per Profilo e `/admin`, e le RLS
|
||||
esistenti decidono quanti profili arrivano (tutti all'admin, il proprio al giocatore).
|
||||
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
|
||||
da solo.
|
||||
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
|
||||
- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome.
|
||||
|
||||
**Riesame**
|
||||
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.
|
||||
|
||||
**Aggiornamento (30 settembre 2026)** — DD-040 esclude dalle email le notifiche del turno palloni: per
|
||||
quei tipi i canali sono due (push e in-app).
|
||||
|
||||
**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.
|
||||
|
||||
### DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici
|
||||
|
||||
**Data:** 30 settembre 2026
|
||||
**Stato:** Accettata (implementata e verificata in locale: migration `m26`, job, route e test; la
|
||||
migration non è ancora applicata in produzione)
|
||||
|
||||
**Contesto**
|
||||
Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno
|
||||
lo premeva, nessuno veniva avvisato. I testi erano inoltre brevi e informali, e i promemoria non dicevano
|
||||
quando e dove. Si vuole che turno palloni e sollecito presenze partano anche da soli, a orari fissi prima
|
||||
dell'evento, e che i testi siano più formali e completi. I testi nuovi sono nel
|
||||
[catalogo](modules/notifiche.md#testi-delle-notifiche).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Promemoria a 24 e 3 ore:** nuovo testo con data, ora e luogo. La parola nel titolo e nella riga
|
||||
«Data» è **«domani»** se la data dell'evento è quella di domani (fuso `Europe/Rome`), **«oggi»**
|
||||
altrimenti. Vale per entrambi i promemoria, quindi anche quello a 3 ore di un evento dopo la
|
||||
mezzanotte dice «domani». La parola la calcola la funzione SQL quando genera la notifica.
|
||||
2. **Turno palloni automatico.** Un job `pg_cron` ogni 15 minuti avvisa per gli eventi di tipo allenamento
|
||||
o partita, in tre tempi con finestre che non si sovrappongono: **12 ore** (tra 12 e 6 ore prima
|
||||
dell'inizio), **6 ore** (tra 6 e 3) e **3 ore** (meno di 3). Parte **solo se per l'evento c'è un
|
||||
incaricato confermato** in `turni_palloni`: le proposte automatiche di `completaTurni()` non salvate non
|
||||
contano. Gli avvisi sono due, con lo stesso testo nei tre tempi: «incarico assegnato» all'incaricato e
|
||||
«riconsegna» a chi aveva i palloni all'evento precedente, se è un'altra persona.
|
||||
- **Pulsante manuale.** «Avvisa chi è di turno» e la route `promemoria-palloni` **restano**: tipo
|
||||
`turno_palloni`, testo nuovo, push mandata dalla route e non dalla coda. È una notifica in più;
|
||||
riceverne due (automatica e manuale) va bene.
|
||||
- **Incaricato cambiato dopo un avviso.** Il nuovo incaricato riceve il suo avviso al giro successivo,
|
||||
con il tipo della finestra in cui cade. Chi aveva già ricevuto un avviso e non è più destinatario
|
||||
riceve **una sola volta** «Turno palloni: incarico revocato» (`turno_palloni_revocato`), se
|
||||
l'evento non è iniziato. Il job lo scopre confrontando i destinatari attuali con il **registro** degli
|
||||
avvisi automatici già generati (non con le notifiche, che il giocatore può aver eliminato), senza
|
||||
trigger su `turni_palloni`. Gli avvisi del pulsante non contano: possono riguardare una proposta non
|
||||
confermata.
|
||||
- **Niente email.** Gli avvisi palloni (automatici, revoca e pulsante) arrivano come push e nel centro
|
||||
notifiche, **non per email**: il trigger `notifiche_utente_accoda_email` salta i tipi
|
||||
`turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. È un'eccezione a
|
||||
DD-038 («ogni notifica ha tre canali») solo per questi tipi; l'interruttore «Email» non cambia.
|
||||
3. **Sollecito presenze automatico in tre tempi.** Lo stesso job manda il sollecito **24 ore** (tra 24 e
|
||||
12 ore prima), **12 ore** (tra 12 e 6) e **6 ore** (meno di 6) prima, al massimo una notifica per
|
||||
giocatore e per tipo. Lo riceve solo chi in quel momento non ha risposto o ha risposto «forse» ed è
|
||||
destinatario dell'evento: i convocati, o tutta la rosa attiva se `convocati` è vuoto, mai l'allenatore.
|
||||
Chi risponde presente, assente o in ritardo smette di riceverli; chi resta su «forse» li riceve tutti e
|
||||
tre. Un evento creato a meno di 12 ore dall'inizio riceve solo il sollecito della sua finestra.
|
||||
- **Pulsante manuale.** «Sollecita» e la route `sollecita-presenze` **restano**, anche per l'allenatore:
|
||||
tipo `sollecita_presenze`, push mandata dalla route, testo nuovo con in più la riga «Richiesta di:
|
||||
_Nome_». I destinatari sono **gli stessi dell'automatico** (convocati senza risposta o con «forse»);
|
||||
oggi la route guarda invece tutti i giocatori attivi.
|
||||
4. **Stessa infrastruttura dei promemoria** (DD-037, DD-038): le notifiche automatiche nascono nel
|
||||
database, il registro `promemoria_eventi_generati` impedisce i doppioni anche se il giocatore le
|
||||
elimina, la push la manda il worker dalla coda `notifiche_push_coda`, l'email (quando c'è) nasce dal
|
||||
trigger su `notifiche_utente`. Tipi nuovi: `sollecita_presenze_24h`, `_12h`, `_6h`;
|
||||
`turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. Il `turno_palloni` del pulsante resta.
|
||||
5. **Migration `m26`:** estende i `CHECK` sui tipi di `notifiche_utente` e del registro, il trigger della
|
||||
coda push, il trigger della coda email (che salta i tipi dei palloni) e ridefinisce la funzione dei
|
||||
promemoria. Le funzioni nuove riusano `giocatori_destinatari_evento()`.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Togliere i pulsanti manuali (turno palloni e sollecito) una volta arrivati gli automatici → scartata su
|
||||
indicazione esplicita: servono all'admin per rimandare un avviso o sollecitare fuori dagli orari fissi.
|
||||
- Mandare tutti i solleciti (o gli avvisi palloni) a un evento creato tardi → scartata: più messaggi nel
|
||||
giro di pochi minuti sono rumore.
|
||||
- «Domani» e «oggi» fissi nei due testi → scartata: il job delle 24 ore scatta anche per eventi che
|
||||
iniziano oggi, e quello delle 3 ore attraversa la mezzanotte.
|
||||
- Calcolare il testo nell'app invece che nel database → scartata: le notifiche nascono nei job `pg_cron`,
|
||||
che non chiamano l'app (DD-038).
|
||||
- Spostare nel worker il calcolo di evento precedente e successivo dei palloni → scartata: un secondo
|
||||
punto che scrive in `notifiche_utente`, per un vantaggio piccolo.
|
||||
- Ritirare l'avviso già consegnato al vecchio incaricato → scartata: per la push non si può, e in-app
|
||||
confonde. Si manda la revoca.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Cambia la regola «quattro notifiche su sei dipendono da un gesto manuale»: restano solo manuali il
|
||||
messaggio dello staff e il sondaggio pre-partita; per turno palloni e sollecito gli automatici si
|
||||
aggiungono ai pulsanti, e un giocatore può ricevere lo stesso avviso due volte.
|
||||
- **Logica duplicata:** «evento precedente» e «evento successivo» per i palloni, in `palloni-core.ts`
|
||||
(usato dal pulsante e dalla schermata), sono riscritti in SQL per i job (`genera_avvisi_palloni_fascia`).
|
||||
Ordine comune: data, ora, id. Se si cambia una regola in una sola delle due versioni, pulsante e job danno
|
||||
risposte diverse: `notifiche-automatiche.test.ts` confronta testi e destinatari sugli stessi dati, anche a
|
||||
parità di data e ora.
|
||||
- `destinatariSollecito()` riceve anche i convocati e serve al sollecito manuale; un giocatore non
|
||||
convocato non è più sollecitato. `avvisiPalloniEvento()` resta come riferimento per i test finché il SQL
|
||||
non la sostituisce del tutto.
|
||||
- Se un incaricato revocato viene poi riassegnato non riceve un secondo avviso, perché il registro lo ha
|
||||
già generato per quella finestra: la revoca resta l'ultima notifica che ha letto.
|
||||
- Le mail automatiche per evento e giocatore sono al massimo **cinque** (due promemoria, tre solleciti),
|
||||
più quelle dei pulsanti (sollecito, sondaggio). Il turno palloni non ne manda.
|
||||
- Il testo dei promemoria già generati non cambia (il registro impedisce di rigenerarli).
|
||||
|
||||
**Riesame**
|
||||
Dopo la prima stagione di uso: se i solleciti o gli avvisi palloni risultano troppi, ridurre le
|
||||
finestre o escludere chi ha risposto «forse» più di una volta.
|
||||
+10
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
calcolata dal CSI
|
||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
||||
- [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)
|
||||
- [x] Notifiche automatiche — promemoria con «oggi/domani», turno palloni 12, 6 e 3 ore prima, sollecito
|
||||
presenze a 24, 12 e 6 ore (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
||||
|
||||
## Prossimo
|
||||
|
||||
|
||||
@@ -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 le push delle notifiche che nascono nel database: promemoria a 24 e 3 ore, turno palloni a 12, 6 e 3 ore con revoca, solleciti presenze a 24, 12 e 6 ore (DD-040). 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`.
|
||||
+52
-31
@@ -69,7 +69,8 @@ quando quei campi sono compilati.
|
||||
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
|
||||
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
|
||||
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
|
||||
«Colpo d'occhio» con le statistiche personali.
|
||||
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
|
||||
vedono solo gli admin e il giocatore interessato (DD-035).
|
||||
|
||||
## Squadra
|
||||
|
||||
@@ -113,7 +114,7 @@ a lui (`usePuoGestireEventi()`).
|
||||
|
||||
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
|
||||
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
|
||||
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso.
|
||||
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono solleciti automatici a 24, 12 e 6 ore prima dell'evento, ma il pulsante resta.
|
||||
|
||||
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
|
||||
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
|
||||
@@ -121,46 +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 |
|
||||
| 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
|
||||
|
||||
@@ -172,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.
|
||||
@@ -181,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||
trigger non accoda nulla, restano push e centro notifiche. 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`).
|
||||
+211
-10
@@ -14,10 +14,154 @@ 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.
|
||||
|
||||
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
|
||||
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
|
||||
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
|
||||
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
|
||||
|
||||
---
|
||||
|
||||
## 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, **tranne
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | **Promemoria a 24 ore** — «Promemoria evento di _domani_ (o _oggi_): _titolo_», con data, ora e luogo | 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** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **12, 6 e 3 ore prima** dell'evento (DD-040): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipi `turno_palloni_12h`, `_6h` e `_3h`, push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | 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ì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, in tre tempi (DD-040): **24, 12 e 6 ore** prima dell'evento, dallo stesso job; tipi `sollecita_presenze_24h`, `_12h`, `_6h`, push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno 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 |
|
||||
|
||||
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||
automatiche (1, 2, 4 e 5) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||
|
||||
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
|
||||
> verificate in locale; la migration va ancora applicata in produzione. Finché non lo è, l'app si comporta
|
||||
> come in 1.2.0: promemoria con il testo breve, turno palloni e sollecito presenze solo da pulsante.
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
### Testi delle notifiche
|
||||
|
||||
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
|
||||
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato di
|
||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | ----------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
|
||||
Le righe 1-2, 4a-4c e 5 nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
|
||||
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
|
||||
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
|
||||
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
|
||||
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
|
||||
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
|
||||
|
||||
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
|
||||
dall'emoji. Non entrano nel centro notifiche e non generano email.
|
||||
|
||||
| Situazione | Titolo | Corpo |
|
||||
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
|
||||
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
|
||||
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
|
||||
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
|
||||
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
|
||||
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
|
||||
|
||||
### 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 eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
|
||||
rimanda il messaggio o riapre il sondaggio; quelle automatiche (1, 2, 4 e 5) non tornano affatto,
|
||||
perché il registro ricorda di averle già generate.
|
||||
- **Un evento già iniziato non genera più promemoria.**
|
||||
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
|
||||
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
|
||||
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
|
||||
- **Il turno palloni parte solo se l'incaricato è confermato** (DD-040): per una partita la
|
||||
proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo
|
||||
un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta
|
||||
«Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso.
|
||||
- **I solleciti cadono in finestre che non si sovrappongono** (DD-040): 24 ore tra 24 e 12 ore
|
||||
prima dell'inizio, 12 ore tra 12 e 6, 6 ore sotto le 6. Un evento creato a meno di 12 ore
|
||||
dall'inizio riceve solo quello della sua finestra. Chi risponde presente, assente o in ritardo
|
||||
smette di riceverli; chi resta su «forse» li riceve tutti e tre.
|
||||
- **Per ogni evento un giocatore riceve al massimo cinque mail automatiche** (DD-040): due
|
||||
promemoria e tre solleciti, più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||
- **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` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 12·6·3 ore e revoca, solleciti a 24·12·6 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`) | `integration/notifiche-automatiche.test.ts` (nuovo), `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.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
|
||||
@@ -57,14 +201,15 @@ 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
|
||||
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts` .
|
||||
|
||||
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
|
||||
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
|
||||
@@ -88,6 +233,9 @@ variabile d'ambiente.
|
||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||
|
||||
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
|
||||
anche per l'allenatore, `richiediGestoreEventi()`).
|
||||
|
||||
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||
@@ -112,6 +260,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)
|
||||
@@ -126,27 +281,36 @@ 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 (con DD-040 le sorgenti 3 e 4 hanno in più un
|
||||
job `pg_cron` come la 2, che scrive `turno_palloni_12h`/`_6h`/`_3h`/`_revocato` e `sollecita_presenze_24h`/
|
||||
`_12h`/`_6h`; i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
|
||||
|
||||
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`.
|
||||
|
||||
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
|
||||
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
|
||||
|
||||
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
|
||||
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
|
||||
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
|
||||
@@ -159,6 +323,43 @@ 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.
|
||||
|
||||
Con DD-040 il trigger accoda in `notifiche_push_coda` anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||
`sollecita_presenze_24h`, `_12h` e `_6h`, perché nascono nel database e nessuna route manda la
|
||||
push (quelle dei pulsanti, `turno_palloni` e `sollecita_presenze`, restano fuori dalla coda): le regole di invio, backoff e scarto sono quelle qui sopra. Un sollecito in coda da oltre 3 ore si scarta come un
|
||||
promemoria.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-13
@@ -10,8 +10,9 @@
|
||||
## Obiettivo
|
||||
|
||||
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.
|
||||
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
|
||||
notifiche e senza email a 12, 6 e 3 ore dall'evento (DD-040). 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`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è 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 è
|
||||
@@ -51,6 +55,13 @@ oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un al
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
|
||||
> `pg_cron` ogni 15 minuti manda l'avviso **12, 6 e 3 ore prima** dell'evento, solo se per l'evento c'è un
|
||||
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
|
||||
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
|
||||
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
|
||||
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
|
||||
|
||||
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
|
||||
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
|
||||
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
|
||||
@@ -65,16 +76,19 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
||||
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
|
||||
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
|
||||
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||
- 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`)
|
||||
@@ -64,14 +68,22 @@ compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||
↓
|
||||
src/routes/api/public/sollecita-presenze.ts
|
||||
├─ legge l'evento (eventi_app) e le risposte già date
|
||||
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
|
||||
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
|
||||
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
|
||||
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
|
||||
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **24, 12 e 6 ore prima** dell'evento a chi in
|
||||
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
|
||||
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
|
||||
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
|
||||
|
||||
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
@@ -82,9 +94,9 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
|
||||
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
|
||||
(DD-024).
|
||||
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron`
|
||||
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
|
||||
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
|
||||
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
|
||||
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
|
||||
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
|
||||
|
||||
@@ -64,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
@@ -80,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||
sulla tab Stagione.
|
||||
|
||||
### Avviso certificati
|
||||
|
||||
**Stato:** implementato (DD-035).
|
||||
|
||||
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
|
||||
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
|
||||
già salvata: niente tabella, niente migration, niente push.
|
||||
|
||||
#### Chi lo vede
|
||||
|
||||
| Utente | Cosa vede | Al tocco |
|
||||
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
|
||||
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
|
||||
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
|
||||
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
|
||||
| Altri giocatori | niente | — |
|
||||
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
|
||||
|
||||
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
|
||||
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
|
||||
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
|
||||
nuovo, come il widget «Completa il tuo profilo».
|
||||
|
||||
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
|
||||
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
|
||||
solo proprio a un giocatore.
|
||||
|
||||
#### Quando compare
|
||||
|
||||
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
|
||||
`GIORNI_AVVISO_CERTIFICATO = 7`.
|
||||
|
||||
| Condizione | Stato | Avviso |
|
||||
| -------------------- | ----------- | ------------------ |
|
||||
| `giorni > 7` | valido | nessuno |
|
||||
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
|
||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||
| data o file mancanti | mancante | nessuno |
|
||||
|
||||
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
|
||||
tab Profili di `/admin` e nel widget di completamento del giocatore.
|
||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||
cioè `attivo` e `tipo = 'giocatore'`).
|
||||
|
||||
#### Testi
|
||||
|
||||
| Avviso | Titolo (staff / personale) | Riga |
|
||||
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
|
||||
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
|
||||
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
|
||||
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
|
||||
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
|
||||
|
||||
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
|
||||
conserva niente, rilegge la data a ogni apertura della Home.
|
||||
|
||||
#### Più giocatori
|
||||
|
||||
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
|
||||
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
|
||||
scadono molti insieme.
|
||||
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
|
||||
nero, poi quello giallo.
|
||||
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
|
||||
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
|
||||
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
|
||||
|
||||
#### Aspetto e posizione
|
||||
|
||||
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
|
||||
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
|
||||
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
|
||||
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
|
||||
di confondersi con lo sfondo.
|
||||
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
|
||||
ciò che scade oggi, poi ciò che manca.
|
||||
|
||||
#### Rinnovo
|
||||
|
||||
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
|
||||
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
|
||||
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
|
||||
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
|
||||
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
|
||||
|
||||
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
|
||||
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
|
||||
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
|
||||
data, chiunque l'abbia scritta.
|
||||
|
||||
#### Caricamento
|
||||
|
||||
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
|
||||
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
|
||||
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
|
||||
la Home non deve rompersi per un dato accessorio.
|
||||
|
||||
#### Implementazione
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
|
||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
|
||||
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||
|
||||
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
|
||||
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
|
||||
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
|
||||
i profili.
|
||||
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
|
||||
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
|
||||
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
|
||||
di anno nel calcolo dei giorni.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
@@ -132,6 +254,10 @@ Upload.
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||
riceve un avviso nella propria Home e compare in quello degli admin (vedi
|
||||
[Avviso certificati](#avviso-certificati)).
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
@@ -255,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
|
||||
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
@@ -269,6 +398,7 @@ Non carica file al posto di altri.
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useProfili } from "@/lib/profili";
|
||||
import {
|
||||
avvisiCertificati,
|
||||
formatDataBreve,
|
||||
testoScadenza,
|
||||
type AvvisoCertificato,
|
||||
} from "@/lib/profili-core";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
function Card({
|
||||
colore,
|
||||
titolo,
|
||||
righe,
|
||||
cliccabile,
|
||||
}: {
|
||||
colore: "warning" | "primary";
|
||||
titolo: string;
|
||||
righe: string[];
|
||||
cliccabile?: boolean;
|
||||
}) {
|
||||
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||
colore === "warning"
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
} ${cliccabile ? "premi" : ""}`;
|
||||
const contenuto = (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||
</p>
|
||||
{righe.map((r) => (
|
||||
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{r}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return cliccabile ? (
|
||||
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classi}>{contenuto}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
|
||||
export function AvvisoCertificati() {
|
||||
const admin = useIsAdmin();
|
||||
const ruoliPronti = useRuoliPronti();
|
||||
const base = useGiocatoreBase();
|
||||
const { righe: rosa } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
|
||||
if (!ruoliPronti || isPending) return null;
|
||||
|
||||
const oggi = oggiISO();
|
||||
|
||||
if (admin) {
|
||||
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
|
||||
if (scaduti.length === 0 && inScadenza.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{scaduti.length > 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificati scaduti"
|
||||
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
/>
|
||||
) : null}
|
||||
{inScadenza.length > 0 ? (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificati in scadenza"
|
||||
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!base) return null;
|
||||
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
|
||||
const mio = scaduti[0] ?? inScadenza[0];
|
||||
if (!mio) return null;
|
||||
|
||||
return mio.giorni < 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificato scaduto"
|
||||
cliccabile
|
||||
righe={[
|
||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificato in scadenza"
|
||||
cliccabile
|
||||
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -110,7 +110,9 @@ function RigaNotifica({
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||
>
|
||||
<p className="font-semibold">{notifica.titolo}</p>
|
||||
{notifica.corpo ? <p className="mt-0.5 text-muted-foreground">{notifica.corpo}</p> : null}
|
||||
{notifica.corpo ? (
|
||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
</motion.div>
|
||||
<button
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -133,6 +133,12 @@ export type RigaClassifica = {
|
||||
punti: number;
|
||||
};
|
||||
|
||||
/** Data numerica `GG/MM/AAAA`, come scritta dai testi delle notifiche (stessa del database). */
|
||||
export function formatDataNumerica(iso: string): string {
|
||||
const [anno, mese, giorno] = iso.split("-");
|
||||
return `${giorno}/${mese}/${anno}`;
|
||||
}
|
||||
|
||||
export function formatData(iso: string) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
|
||||
|
||||
@@ -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,15 @@ export type TipoNotifica =
|
||||
| "evento_promemoria_24h"
|
||||
| "evento_promemoria_3h"
|
||||
| "turno_palloni"
|
||||
| "sollecita_presenze";
|
||||
| "turno_palloni_12h"
|
||||
| "turno_palloni_6h"
|
||||
| "turno_palloni_3h"
|
||||
| "turno_palloni_revocato"
|
||||
| "sollecita_presenze"
|
||||
| "sollecita_presenze_24h"
|
||||
| "sollecita_presenze_12h"
|
||||
| "sollecita_presenze_6h"
|
||||
| "sondaggio_cacche";
|
||||
|
||||
export type NotificaUtente = {
|
||||
id: string;
|
||||
|
||||
+41
-25
@@ -1,4 +1,4 @@
|
||||
import { formatData } from "./crapp-data";
|
||||
import { formatDataNumerica } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { dataOggi } from "./scout-live";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
@@ -8,12 +8,19 @@ 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 e, a parità,
|
||||
* di ora. Lo stesso ordine lo usa il database per i job automatici (M26, DD-040).
|
||||
*/
|
||||
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));
|
||||
.sort((a, b) => {
|
||||
if (a.data !== b.data) return a.data < b.data ? -1 : 1;
|
||||
if (a.ora !== b.ora) return a.ora < b.ora ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +28,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>,
|
||||
@@ -139,11 +146,12 @@ export function destinatariPromemoriaPalloni(
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025, DD-040).
|
||||
*
|
||||
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
|
||||
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
|
||||
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
|
||||
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
|
||||
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
|
||||
* «riconsegna». Se per l'evento non c'è un incaricato non parte nulla. Il testo viaggia cifrato
|
||||
* dentro la push, quindi il service worker lo mostra senza rete.
|
||||
*/
|
||||
export function avvisiPalloniEvento(
|
||||
turni: Record<string, string>,
|
||||
@@ -153,29 +161,37 @@ export function avvisiPalloniEvento(
|
||||
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
|
||||
if (!evento) return [];
|
||||
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
|
||||
|
||||
const incaricato = turni[evento.id];
|
||||
if (incaricato) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Tocca a te prendere i palloni",
|
||||
testo: dopo
|
||||
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
|
||||
: `${quando}: a fine evento porti a casa i palloni.`,
|
||||
});
|
||||
}
|
||||
if (!incaricato) return [];
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const intestazione = [
|
||||
`Evento: ${evento.titolo}`,
|
||||
`Data: ${formatDataNumerica(evento.data)}, ore ${evento.ora}`,
|
||||
];
|
||||
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Turno palloni: incarico assegnato",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: custodia dei palloni al termine dell'evento",
|
||||
...(dopo ? [`Riconsegna: ${formatDataNumerica(dopo.data)}`] : []),
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di riportarli.
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
const precedente = prima ? turni[prima.id] : undefined;
|
||||
if (precedente && precedente !== incaricato) {
|
||||
avvisi.push({
|
||||
giocatoreId: precedente,
|
||||
titolo: "Porta i palloni",
|
||||
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
|
||||
titolo: "Turno palloni: riconsegna",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+42
-5
@@ -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 { formatDataNumerica, type Stato } from "./crapp-data";
|
||||
import type { Evento, EventoTipo } from "./eventi";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
import { dataOggi } from "./scout-live";
|
||||
@@ -72,18 +72,32 @@ export function totaliEventiGiocatore(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
|
||||
* 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 (DD-040): i destinatari dell'evento — i convocati, o tutta la
|
||||
* rosa se `convocati` è vuoto — che non hanno ancora risposto o hanno risposto «forse». Mai gli
|
||||
* allenatori (DD-034). Stessa regola del job automatico del database (`giocatori_destinatari_evento`
|
||||
* più `genera_solleciti_presenze`, migration M26): funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni, chiamata dalla route `/api/public/sollecita-presenze` con i dati che ha già letto.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
convocati: string[] = [],
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
|
||||
return squadra
|
||||
.filter(inRosa)
|
||||
.filter((g) => convocati.length === 0 || convocati.includes(g.id))
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
@@ -91,6 +105,29 @@ export function destinatariSollecito(
|
||||
.map((g) => g.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Titolo e corpo del sollecito, gli stessi del job automatico (migration M26). `da` è il nome di
|
||||
* chi preme il pulsante: compare solo nel sollecito manuale, come riga «Richiesta di».
|
||||
*/
|
||||
export function testoSollecito(
|
||||
evento: Pick<Evento, "titolo" | "data" | "ora" | "luogo">,
|
||||
stato: string | undefined,
|
||||
da?: string,
|
||||
): { titolo: string; testo: string } {
|
||||
const luogo = evento.luogo.trim();
|
||||
return {
|
||||
titolo: `Conferma di partecipazione richiesta: ${evento.titolo}`,
|
||||
testo: [
|
||||
`Data: ${formatDataNumerica(evento.data)}`,
|
||||
`Ora: ${evento.ora}`,
|
||||
...(luogo ? [`Luogo: ${luogo}`] : []),
|
||||
...(da ? [`Richiesta di: ${da}`] : []),
|
||||
`Risposta attuale: ${stato === "forse" ? "forse" : "nessuna"}`,
|
||||
"Azione: indicare presente, assente o in ritardo",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serie di presenze consecutive su eventi già passati, in ordine di data:
|
||||
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
|
||||
|
||||
+71
-1
@@ -1,5 +1,5 @@
|
||||
import { rigaCsv } from "./scout-export";
|
||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||
@@ -215,3 +215,73 @@ export function csvTesseramento(
|
||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||
}
|
||||
|
||||
/** Soglia dell'avviso certificati in Home (DD-035). */
|
||||
export const GIORNI_AVVISO_CERTIFICATO = 7;
|
||||
|
||||
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
|
||||
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
|
||||
const a = new Date(`${scadenza}T00:00:00`);
|
||||
const b = new Date(`${oggi}T00:00:00`);
|
||||
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
|
||||
export function formatDataBreve(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Riga dell'avviso certificati per un giocatore (DD-035). */
|
||||
export function testoScadenza(giorni: number): string {
|
||||
if (giorni === 0) return "scade oggi";
|
||||
if (giorni === 1) return "scade domani";
|
||||
return `scade tra ${giorni} giorni`;
|
||||
}
|
||||
|
||||
export type AvvisoCertificato = {
|
||||
giocatoreId: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
scadenza: string;
|
||||
giorni: number;
|
||||
};
|
||||
|
||||
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
||||
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
||||
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
||||
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
||||
*/
|
||||
export function avvisiCertificati(
|
||||
rosa: GiocatoreSquadra[],
|
||||
profili: Record<string, Profilo>,
|
||||
oggi: string,
|
||||
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
||||
const scaduti: AvvisoCertificato[] = [];
|
||||
const inScadenza: AvvisoCertificato[] = [];
|
||||
for (const g of rosa) {
|
||||
if (!inRosa(g)) continue;
|
||||
const p = profili[g.id];
|
||||
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
||||
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
||||
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
|
||||
const avviso: AvvisoCertificato = {
|
||||
giocatoreId: g.id,
|
||||
nome: g.nome,
|
||||
cognome: g.cognome,
|
||||
scadenza: p.certificatoScadenza,
|
||||
giorni,
|
||||
};
|
||||
(giorni < 0 ? scaduti : inScadenza).push(avviso);
|
||||
}
|
||||
scaduti.sort(confrontaAvvisi);
|
||||
inScadenza.sort(confrontaAvvisi);
|
||||
return { scaduti, inScadenza };
|
||||
}
|
||||
+11
-3
@@ -24,15 +24,23 @@ async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
|
||||
return (data ?? []).map((r) => r.role as Ruolo);
|
||||
}
|
||||
|
||||
function useRuoli(): Ruolo[] {
|
||||
function useRuoliQuery() {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoli(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
function useRuoli(): Ruolo[] {
|
||||
return useRuoliQuery().data ?? [];
|
||||
}
|
||||
|
||||
/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
|
||||
export function useRuoliPronti(): boolean {
|
||||
return !useRuoliQuery().isPending;
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,9 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediGestoreEventi } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { destinatariSollecito } from "@/lib/presenze";
|
||||
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -35,19 +34,24 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
.eq("evento_id", evento.id);
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? []);
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? [], evento.convocati);
|
||||
|
||||
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth")
|
||||
.select("endpoint, giocatore_id, p256dh, auth")
|
||||
.in("giocatore_id", destinatari);
|
||||
|
||||
const titolo = "Manca la tua risposta";
|
||||
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
|
||||
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
|
||||
} una conferma: presente, assente o in ritardo?`;
|
||||
// Il testo dice se il giocatore non ha risposto o ha risposto «forse» (DD-040): è
|
||||
// quindi per destinatario, e la push porta il testo del proprio destinatario.
|
||||
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
|
||||
const testi = new Map(
|
||||
destinatari.map((giocatoreId) => [
|
||||
giocatoreId,
|
||||
testoSollecito(evento, stati.get(giocatoreId), parsed.data.da),
|
||||
]),
|
||||
);
|
||||
|
||||
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
|
||||
// notifica a non letta, non fallire per il vincolo UNIQUE. `types.ts` non include
|
||||
@@ -57,8 +61,8 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
destinatari.map((giocatoreId) => ({
|
||||
giocatore_id: giocatoreId,
|
||||
tipo: "sollecita_presenze",
|
||||
titolo,
|
||||
corpo: testo,
|
||||
titolo: testi.get(giocatoreId)?.titolo,
|
||||
corpo: testi.get(giocatoreId)?.testo,
|
||||
evento_id: evento.id,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
@@ -68,8 +72,10 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
const avviso = testi.get(iscrizione.giocatore_id);
|
||||
if (!avviso) continue;
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Flame, ChevronRight } from "lucide-react";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||
import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati";
|
||||
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -120,6 +121,8 @@ function Index() {
|
||||
|
||||
<PromemoriaPalloni />
|
||||
|
||||
<AvvisoCertificati />
|
||||
|
||||
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
|
||||
|
||||
<Section
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
$$;
|
||||
@@ -0,0 +1,417 @@
|
||||
-- M26 — Notifiche automatiche di promemoria, turno palloni e sollecito presenze (DD-040)
|
||||
--
|
||||
-- Cosa cambia rispetto a M17/M23/M24:
|
||||
--
|
||||
-- 1. I promemoria a 24 e 3 ore hanno un testo nuovo, con data, ora e luogo; la parola «domani»
|
||||
-- compare se la data dell'evento è quella di domani (fuso Europe/Rome), «oggi» altrimenti.
|
||||
-- 2. Il turno palloni parte anche da solo, a 12, 6 e 3 ore dall'inizio, per gli allenamenti e le
|
||||
-- partite con un incaricato *confermato* in `turni_palloni`: all'incaricato e, se è un'altra
|
||||
-- persona, a chi aveva i palloni all'evento precedente. Se l'incaricato cambia dopo un
|
||||
-- avviso, chi lo aveva ricevuto riceve una sola revoca.
|
||||
-- 3. Il sollecito presenze parte da solo a 24, 12 e 6 ore dall'inizio, a chi tra i destinatari
|
||||
-- dell'evento non ha risposto o ha risposto «forse».
|
||||
-- 4. Le notifiche del turno palloni non generano email: restano push e centro notifiche.
|
||||
--
|
||||
-- I pulsanti manuali («Avvisa chi è di turno», «Sollecita») restano: usano gli stessi testi (vedi
|
||||
-- `src/lib/palloni-core.ts` e `src/lib/presenze.ts`) e mandano la push dalla route, non dalla coda.
|
||||
--
|
||||
-- Le fasce sono contigue e senza sovrapposizione: un evento creato a meno di 12 ore dall'inizio
|
||||
-- riceve solo la notifica della fascia in cui cade, non tutte insieme. Ogni notifica automatica si
|
||||
-- genera una sola volta per (giocatore, evento, tipo), anche se il giocatore la elimina: lo ricorda
|
||||
-- il registro `promemoria_eventi_generati` (M23, DD-037), che qui si estende ai tipi nuovi.
|
||||
|
||||
-- --- Tipi di notifica --------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
|
||||
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
|
||||
tipo IN (
|
||||
'admin',
|
||||
'evento_promemoria_24h',
|
||||
'evento_promemoria_3h',
|
||||
'turno_palloni',
|
||||
'turno_palloni_12h',
|
||||
'turno_palloni_6h',
|
||||
'turno_palloni_3h',
|
||||
'turno_palloni_revocato',
|
||||
'sollecita_presenze',
|
||||
'sollecita_presenze_24h',
|
||||
'sollecita_presenze_12h',
|
||||
'sollecita_presenze_6h',
|
||||
'sondaggio_cacche'
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE public.promemoria_eventi_generati DROP CONSTRAINT promemoria_eventi_generati_tipo_check;
|
||||
ALTER TABLE public.promemoria_eventi_generati ADD CONSTRAINT promemoria_eventi_generati_tipo_check CHECK (
|
||||
tipo IN (
|
||||
'evento_promemoria_24h',
|
||||
'evento_promemoria_3h',
|
||||
'turno_palloni_12h',
|
||||
'turno_palloni_6h',
|
||||
'turno_palloni_3h',
|
||||
'turno_palloni_revocato',
|
||||
'sollecita_presenze_24h',
|
||||
'sollecita_presenze_12h',
|
||||
'sollecita_presenze_6h'
|
||||
)
|
||||
);
|
||||
|
||||
-- --- Istante d'inizio di un evento -------------------------------------------------------------
|
||||
--
|
||||
-- `data` e `ora` sono ora locale Italia (stessa convenzione di M10 e M17). `ora` è testo libero: se
|
||||
-- non è un'ora valida il risultato è NULL e l'evento viene solo escluso (vedi `ora_evento_a_time`).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.inizio_evento(p_data date, p_ora text)
|
||||
RETURNS timestamptz
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT (p_data + public.ora_evento_a_time(p_ora)) AT TIME ZONE 'Europe/Rome';
|
||||
$$;
|
||||
|
||||
-- --- Promemoria a 24 e 3 ore: testo nuovo ------------------------------------------------------
|
||||
--
|
||||
-- Stessa firma e stessi criteri di M23 (i cron non cambiano). Cambia solo il testo: titolo
|
||||
-- «Promemoria evento di domani|oggi: <titolo>», corpo con Data, Ora e, se c'è, Luogo.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.genera_promemoria_eventi(p_tipo text, p_finestra interval)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
WITH da_generare AS (
|
||||
SELECT d AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora, e.luogo,
|
||||
CASE WHEN e.data = ((now() AT TIME ZONE 'Europe/Rome')::date + 1)
|
||||
THEN 'domani' ELSE 'oggi' END AS parola
|
||||
FROM public.eventi_app e
|
||||
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
|
||||
WHERE public.inizio_evento(e.data, e.ora) IS NOT NULL
|
||||
AND public.inizio_evento(e.data, e.ora) - now() BETWEEN interval '0' AND p_finestra
|
||||
),
|
||||
nuovi AS (
|
||||
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||
SELECT giocatore_id, evento_id, p_tipo FROM da_generare
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING giocatore_id, evento_id
|
||||
)
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||
SELECT g.giocatore_id, p_tipo,
|
||||
'Promemoria evento di ' || g.parola || ': ' || g.titolo,
|
||||
'Data: ' || g.parola || ', ' || to_char(g.data, 'DD/MM/YYYY')
|
||||
|| E'\nOra: ' || g.ora
|
||||
|| CASE WHEN btrim(g.luogo) <> '' THEN E'\nLuogo: ' || btrim(g.luogo) ELSE '' END,
|
||||
g.evento_id
|
||||
FROM nuovi n
|
||||
JOIN da_generare g ON g.giocatore_id = n.giocatore_id AND g.evento_id = n.evento_id
|
||||
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- --- Turno palloni automatico ------------------------------------------------------------------
|
||||
--
|
||||
-- «Evento precedente» e «evento successivo» seguono `eventoPrecedente()`/`eventoSuccessivo()` di
|
||||
-- `src/lib/palloni-core.ts`: solo allenamenti e partite, in ordine di data (a parità, di ora). La
|
||||
-- logica è volutamente la stessa in due posti (il database non può chiamare il codice dell'app):
|
||||
-- `test/integration/notifiche-automatiche.test.ts` li confronta sugli stessi dati.
|
||||
|
||||
-- Avvisi della fascia [p_da, p_a) prima dell'inizio. Parte solo se l'evento ha un incaricato
|
||||
-- confermato in `turni_palloni`; i destinatari devono essere giocatori attivi.
|
||||
CREATE OR REPLACE FUNCTION public.genera_avvisi_palloni_fascia(
|
||||
p_tipo text, p_da interval, p_a interval
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
WITH ev AS (
|
||||
SELECT e.id, e.titolo, e.data, e.ora,
|
||||
public.inizio_evento(e.data, e.ora) AS inizio,
|
||||
lag(e.id) OVER ordine AS precedente_id,
|
||||
lead(e.data) OVER ordine AS successivo_data
|
||||
FROM public.eventi_app e
|
||||
WHERE e.tipo IN ('allenamento', 'partita')
|
||||
WINDOW ordine AS (
|
||||
ORDER BY e.data, public.ora_evento_a_time(e.ora), e.id COLLATE "C"
|
||||
)
|
||||
),
|
||||
turni AS (
|
||||
SELECT ev.*, t.giocatore_id AS incaricato, tp.giocatore_id AS precedente
|
||||
FROM ev
|
||||
JOIN public.turni_palloni t ON t.evento_id = ev.id
|
||||
LEFT JOIN public.turni_palloni tp ON tp.evento_id = ev.precedente_id
|
||||
WHERE ev.inizio IS NOT NULL
|
||||
AND ev.inizio - now() >= p_da
|
||||
AND ev.inizio - now() < p_a
|
||||
),
|
||||
destinatari AS (
|
||||
SELECT incaricato AS giocatore_id, id AS evento_id, true AS e_incaricato,
|
||||
titolo, data, ora, successivo_data
|
||||
FROM turni
|
||||
UNION ALL
|
||||
SELECT precedente, id, false, titolo, data, ora, successivo_data
|
||||
FROM turni
|
||||
WHERE precedente IS NOT NULL AND precedente <> incaricato
|
||||
),
|
||||
validi AS (
|
||||
SELECT d.*
|
||||
FROM destinatari d
|
||||
JOIN public.giocatori_squadra g
|
||||
ON g.id = d.giocatore_id AND g.attivo = true AND g.tipo = 'giocatore'
|
||||
),
|
||||
nuovi AS (
|
||||
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||
SELECT giocatore_id, evento_id, p_tipo FROM validi
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING giocatore_id, evento_id
|
||||
)
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||
SELECT v.giocatore_id, p_tipo,
|
||||
CASE WHEN v.e_incaricato THEN 'Turno palloni: incarico assegnato'
|
||||
ELSE 'Turno palloni: riconsegna' END,
|
||||
'Evento: ' || v.titolo
|
||||
|| E'\nData: ' || to_char(v.data, 'DD/MM/YYYY') || ', ore ' || v.ora
|
||||
|| CASE WHEN v.e_incaricato
|
||||
THEN E'\nIncarico: custodia dei palloni al termine dell''evento'
|
||||
|| CASE WHEN v.successivo_data IS NOT NULL
|
||||
THEN E'\nRiconsegna: ' || to_char(v.successivo_data, 'DD/MM/YYYY')
|
||||
ELSE '' END
|
||||
ELSE E'\nIncarico: riconsegna dei palloni in custodia dal turno precedente'
|
||||
END,
|
||||
v.evento_id
|
||||
FROM nuovi n
|
||||
JOIN validi v ON v.giocatore_id = n.giocatore_id AND v.evento_id = n.evento_id
|
||||
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Revoca: chi ha ricevuto un avviso automatico per un evento non ancora iniziato e non è più
|
||||
-- destinatario (incaricato cambiato o tolto, o turno precedente riassegnato) riceve una sola volta
|
||||
-- «incarico revocato». Si guarda il registro, non `notifiche_utente`: il giocatore può aver
|
||||
-- eliminato l'avviso. Gli avvisi del pulsante non contano: possono riguardare una proposta non
|
||||
-- confermata, che qui non esiste.
|
||||
CREATE OR REPLACE FUNCTION public.genera_revoche_palloni()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
WITH ev AS (
|
||||
SELECT e.id, e.titolo, e.data, e.ora,
|
||||
public.inizio_evento(e.data, e.ora) AS inizio,
|
||||
lag(e.id) OVER ordine AS precedente_id
|
||||
FROM public.eventi_app e
|
||||
WHERE e.tipo IN ('allenamento', 'partita')
|
||||
WINDOW ordine AS (
|
||||
ORDER BY e.data, public.ora_evento_a_time(e.ora), e.id COLLATE "C"
|
||||
)
|
||||
),
|
||||
attuali AS (
|
||||
SELECT t.giocatore_id, ev.id AS evento_id
|
||||
FROM ev JOIN public.turni_palloni t ON t.evento_id = ev.id
|
||||
UNION
|
||||
SELECT tp.giocatore_id, ev.id
|
||||
FROM ev
|
||||
JOIN public.turni_palloni t ON t.evento_id = ev.id
|
||||
JOIN public.turni_palloni tp ON tp.evento_id = ev.precedente_id
|
||||
JOIN public.giocatori_squadra g ON g.id = tp.giocatore_id
|
||||
WHERE tp.giocatore_id <> t.giocatore_id
|
||||
),
|
||||
da_revocare AS (
|
||||
SELECT DISTINCT r.giocatore_id, r.evento_id, ev.titolo, ev.data, ev.ora
|
||||
FROM public.promemoria_eventi_generati r
|
||||
JOIN ev ON ev.id = r.evento_id
|
||||
WHERE r.tipo IN ('turno_palloni_12h', 'turno_palloni_6h', 'turno_palloni_3h')
|
||||
AND ev.inizio IS NOT NULL
|
||||
AND ev.inizio >= now()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM attuali a
|
||||
JOIN public.giocatori_squadra g
|
||||
ON g.id = a.giocatore_id AND g.attivo = true AND g.tipo = 'giocatore'
|
||||
WHERE a.giocatore_id = r.giocatore_id AND a.evento_id = r.evento_id
|
||||
)
|
||||
),
|
||||
nuovi AS (
|
||||
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||
SELECT giocatore_id, evento_id, 'turno_palloni_revocato' FROM da_revocare
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING giocatore_id, evento_id
|
||||
)
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||
SELECT d.giocatore_id, 'turno_palloni_revocato',
|
||||
'Turno palloni: incarico revocato',
|
||||
'Evento: ' || d.titolo
|
||||
|| E'\nData: ' || to_char(d.data, 'DD/MM/YYYY') || ', ore ' || d.ora
|
||||
|| E'\nIncarico: non più a tuo carico per questo evento',
|
||||
d.evento_id
|
||||
FROM nuovi n
|
||||
JOIN da_revocare d ON d.giocatore_id = n.giocatore_id AND d.evento_id = n.evento_id
|
||||
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.genera_avvisi_palloni()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_12h', interval '6 hours', interval '12 hours');
|
||||
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_6h', interval '3 hours', interval '6 hours');
|
||||
PERFORM public.genera_avvisi_palloni_fascia('turno_palloni_3h', interval '0', interval '3 hours');
|
||||
PERFORM public.genera_revoche_palloni();
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- --- Sollecito presenze automatico -------------------------------------------------------------
|
||||
--
|
||||
-- Destinatari: quelli dell'evento (`giocatori_destinatari_evento`: convocati, o tutta la rosa
|
||||
-- attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno una risposta o
|
||||
-- hanno risposto «forse».
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.genera_solleciti_presenze_fascia(
|
||||
p_tipo text, p_da interval, p_a interval
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
WITH da_generare AS (
|
||||
SELECT d.gid AS giocatore_id, e.id AS evento_id, e.titolo, e.data, e.ora, e.luogo, r.stato
|
||||
FROM public.eventi_app e
|
||||
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d(gid)
|
||||
LEFT JOIN public.risposte_presenze r
|
||||
ON r.evento_id = e.id AND r.giocatore_id = d.gid
|
||||
WHERE public.inizio_evento(e.data, e.ora) IS NOT NULL
|
||||
AND public.inizio_evento(e.data, e.ora) - now() >= p_da
|
||||
AND public.inizio_evento(e.data, e.ora) - now() < p_a
|
||||
AND (r.stato IS NULL OR r.stato = 'forse')
|
||||
),
|
||||
nuovi AS (
|
||||
INSERT INTO public.promemoria_eventi_generati (giocatore_id, evento_id, tipo)
|
||||
SELECT giocatore_id, evento_id, p_tipo FROM da_generare
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING giocatore_id, evento_id
|
||||
)
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||
SELECT g.giocatore_id, p_tipo,
|
||||
'Conferma di partecipazione richiesta: ' || g.titolo,
|
||||
'Data: ' || to_char(g.data, 'DD/MM/YYYY')
|
||||
|| E'\nOra: ' || g.ora
|
||||
|| CASE WHEN btrim(g.luogo) <> '' THEN E'\nLuogo: ' || btrim(g.luogo) ELSE '' END
|
||||
|| E'\nRisposta attuale: ' || CASE WHEN g.stato = 'forse' THEN 'forse' ELSE 'nessuna' END
|
||||
|| E'\nAzione: indicare presente, assente o in ritardo',
|
||||
g.evento_id
|
||||
FROM nuovi n
|
||||
JOIN da_generare g ON g.giocatore_id = n.giocatore_id AND g.evento_id = n.evento_id
|
||||
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.genera_solleciti_presenze()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_24h', interval '12 hours', interval '24 hours');
|
||||
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_12h', interval '6 hours', interval '12 hours');
|
||||
PERFORM public.genera_solleciti_presenze_fascia('sollecita_presenze_6h', interval '0', interval '6 hours');
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Come gli altri job di M17: solo il proprietario (pg_cron) e la service role li eseguono.
|
||||
REVOKE ALL ON FUNCTION public.inizio_evento(date, text) FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni_fascia(text, interval, interval)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.genera_revoche_palloni() FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.genera_avvisi_palloni() FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze_fascia(text, interval, interval)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.genera_solleciti_presenze() FROM PUBLIC, anon, authenticated;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.inizio_evento(date, text) TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni_fascia(text, interval, interval)
|
||||
TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.genera_revoche_palloni() TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.genera_avvisi_palloni() TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze_fascia(text, interval, interval)
|
||||
TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.genera_solleciti_presenze() TO service_role;
|
||||
|
||||
SELECT cron.schedule(
|
||||
'avvisi-palloni-automatici',
|
||||
'*/15 * * * *',
|
||||
$$ SELECT public.genera_avvisi_palloni() $$
|
||||
);
|
||||
|
||||
SELECT cron.schedule(
|
||||
'solleciti-presenze-automatici',
|
||||
'*/15 * * * *',
|
||||
$$ SELECT public.genera_solleciti_presenze() $$
|
||||
);
|
||||
|
||||
-- --- Push: anche le notifiche automatiche di palloni e solleciti -------------------------------
|
||||
--
|
||||
-- Nascono nel database come i promemoria: nessuna route le manda, quindi la push passa dalla coda
|
||||
-- (DD-038). Gli avvisi dei pulsanti (`turno_palloni`, `sollecita_presenze`) restano fuori: la push
|
||||
-- la manda già la route e una seconda sarebbe un doppione.
|
||||
|
||||
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
|
||||
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
|
||||
AFTER INSERT ON public.notifiche_utente
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.tipo IN (
|
||||
'evento_promemoria_24h',
|
||||
'evento_promemoria_3h',
|
||||
'turno_palloni_12h',
|
||||
'turno_palloni_6h',
|
||||
'turno_palloni_3h',
|
||||
'turno_palloni_revocato',
|
||||
'sollecita_presenze_24h',
|
||||
'sollecita_presenze_12h',
|
||||
'sollecita_presenze_6h'
|
||||
))
|
||||
EXECUTE FUNCTION public.accoda_push_promemoria();
|
||||
|
||||
-- --- Email: il turno palloni non ne manda ------------------------------------------------------
|
||||
--
|
||||
-- Stessa funzione di M22, con un'uscita anticipata per i tipi dei palloni (automatici, revoca e
|
||||
-- pulsante). L'`upsert` del pulsante riscrive `creato_il` e fa scattare il trigger: anche quel
|
||||
-- caso esce senza accodare nulla.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.accoda_notifica_email()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.tipo IN (
|
||||
'turno_palloni',
|
||||
'turno_palloni_12h',
|
||||
'turno_palloni_6h',
|
||||
'turno_palloni_3h',
|
||||
'turno_palloni_revocato'
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.notifiche_email_coda (notifica_id)
|
||||
VALUES (NEW.id)
|
||||
ON CONFLICT (notifica_id) DO UPDATE
|
||||
SET stato = 'in_coda',
|
||||
tentativi = 0,
|
||||
prossimo_tentativo = now(),
|
||||
errore = NULL,
|
||||
inviata_il = NULL,
|
||||
aggiornata_il = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
+20
-6
@@ -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), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26, DD-040) 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,544 @@
|
||||
/**
|
||||
* 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 PRESENZE_CONVOCATI = `${PREFISSO}-presenze-convocati`;
|
||||
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; corpo: string; tipo: string };
|
||||
const notifiche = (filtro: string) =>
|
||||
leggi<Notifica>(
|
||||
`notifiche_utente?${filtro}&select=giocatore_id,titolo,corpo,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"],
|
||||
[PRESENZE_CONVOCATI, "partita", "2031-04-03"],
|
||||
[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",
|
||||
luogo: id === PRESENZE ? "PalaCRAP" : "",
|
||||
convocati: id === PRESENZE_CONVOCATI ? [G_QUATTRO, G_CINQUE] : [],
|
||||
});
|
||||
}
|
||||
|
||||
// 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(
|
||||
"il sollecito manuale ha il testo nuovo, con chi lo chiede e la risposta attuale",
|
||||
async () => {
|
||||
svuota();
|
||||
const res = await chiama("sollecita-presenze", tokenAdmin, {
|
||||
eventoId: PRESENZE,
|
||||
da: "Mario Rossi",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const righe = await notifiche(`evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze`);
|
||||
const cinque = righe.find((r) => r.giocatore_id === G_CINQUE);
|
||||
const altro = righe.find((r) => r.giocatore_id !== G_CINQUE);
|
||||
assert.equal(
|
||||
cinque?.titolo,
|
||||
`Conferma di partecipazione richiesta: ${PREFISSO} ${PRESENZE}`,
|
||||
);
|
||||
assert.equal(
|
||||
cinque?.corpo,
|
||||
[
|
||||
"Data: 02/04/2031",
|
||||
"Ora: 20:00",
|
||||
"Luogo: PalaCRAP",
|
||||
"Richiesta di: Mario Rossi",
|
||||
"Risposta attuale: forse",
|
||||
"Azione: indicare presente, assente o in ritardo",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.match(altro?.corpo ?? "", /Risposta attuale: nessuna/, "chi non ha risposto");
|
||||
const conId = await leggi<{ id: string }>(
|
||||
`notifiche_utente?evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze&select=id`,
|
||||
);
|
||||
const email = await leggi<{ notifica_id: string }>(
|
||||
`notifiche_email_coda?notifica_id=in.(${conId.map((r) => r.id).join(",")})&select=notifica_id`,
|
||||
);
|
||||
assert.equal(email.length, conId.length, "il sollecito manuale parte anche per email");
|
||||
assert.equal(
|
||||
righe.filter((r) => r.giocatore_id === G_CINQUE).length,
|
||||
1,
|
||||
"ripremere il pulsante riscrive la notifica, non la duplica",
|
||||
);
|
||||
assert.deepEqual(destinatariPush(), [G_CINQUE]);
|
||||
},
|
||||
);
|
||||
|
||||
await prova(
|
||||
"il sollecito manuale segue i convocati, come quello automatico (DD-040)",
|
||||
async () => {
|
||||
const res = await chiama("sollecita-presenze", tokenAdmin, {
|
||||
eventoId: PRESENZE_CONVOCATI,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const righe = await notifiche(
|
||||
`evento_id=eq.${PRESENZE_CONVOCATI}&tipo=eq.sollecita_presenze`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
slot(righe),
|
||||
[G_QUATTRO, G_CINQUE],
|
||||
"solo i convocati: i non convocati non ricevono nulla, nemmeno senza risposta",
|
||||
);
|
||||
assert.ok(
|
||||
!righe[0]!.corpo.includes("Richiesta di"),
|
||||
"senza nome, senza la riga «Richiesta di»",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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), "Turno palloni: incarico assegnato");
|
||||
assert.equal(per.get(G_UNO), "Turno palloni: riconsegna");
|
||||
const corpoDue = righe.find((r) => r.giocatore_id === G_DUE)?.corpo ?? "";
|
||||
assert.match(
|
||||
corpoDue,
|
||||
/^Evento: test-destinatari-notifiche test-destinatari-notifiche-palloni-2\n/,
|
||||
);
|
||||
assert.match(corpoDue, /\nData: 08\/05\/2031, ore 20:00\n/);
|
||||
assert.match(corpoDue, /Incarico: custodia dei palloni al termine dell'evento/);
|
||||
|
||||
// DD-040: il turno palloni arriva come push e in-app, mai per email.
|
||||
const conId = await leggi<{ id: string }>(
|
||||
`notifiche_utente?evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni&select=id`,
|
||||
);
|
||||
assert.equal(conId.length, righe.length);
|
||||
const email = await leggi<{ notifica_id: string }>(
|
||||
`notifiche_email_coda?notifica_id=in.(${conId.map((r) => r.id).join(",")})&select=notifica_id`,
|
||||
);
|
||||
assert.equal(email.length, 0, "nessuna email per il turno 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,
|
||||
PRESENZE_CONVOCATI,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* Notifiche automatiche di turno palloni e sollecito presenze (M26, DD-040):
|
||||
* `bun test/integration/notifiche-automatiche.test.ts`.
|
||||
*
|
||||
* Esegue i job veri del database (`genera_avvisi_palloni`, `genera_solleciti_presenze`) su eventi
|
||||
* creati con orari relativi a «adesso» e verifica:
|
||||
*
|
||||
* - le finestre contigue e senza sovrapposizione (palloni 12·6·3 ore, solleciti 24·12·6 ore);
|
||||
* - i destinatari (incaricato e turno precedente; convocati senza risposta o con «forse»; mai
|
||||
* l'allenatore né chi non è più attivo);
|
||||
* - una sola generazione per giocatore, evento e tipo, anche se la notifica viene eliminata;
|
||||
* - la revoca dell'incarico, una sola volta, guardando il registro e non le notifiche;
|
||||
* - il canale: push in coda, nessuna email per i palloni, email per i solleciti;
|
||||
* - che il testo generato in SQL sia identico a quello del codice TypeScript (`avvisiPalloniEvento`,
|
||||
* `testoSollecito`) e che «evento precedente/successivo» seguano lo stesso ordine di
|
||||
* `palloni-core.ts`.
|
||||
*
|
||||
* Gira solo sullo stack locale (`npx supabase start`). Usa eventi con il prefisso
|
||||
* `test-notifiche-automatiche` e gli slot `g4`, `g5`, `g7`, `g8`, `g9` (inattivo) e `g10`
|
||||
* (allenatore) della rosa seed, ripristinati alla fine.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { avvisiPalloniEvento, eventoPrecedente, eventoSuccessivo } from "@/lib/palloni-core";
|
||||
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("notifiche automatiche", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("notifiche-automatiche");
|
||||
} else {
|
||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||
console.log(`notifiche automatiche su ${URL_BASE}`);
|
||||
|
||||
const PREFISSO = "test-notifiche-automatiche";
|
||||
const PASSWORD = "prova-notifiche-automatiche-123";
|
||||
const G4 = "g4";
|
||||
const G5 = "g5";
|
||||
const G6 = "g6";
|
||||
const G7 = "g7";
|
||||
const G8 = "g8";
|
||||
const INATTIVO = "g9";
|
||||
const ALLENATORE = "g10";
|
||||
|
||||
// Palloni: in ordine cronologico PASSATO < PREC < MAIN < EV6 < EV12 < LONTANO.
|
||||
const PASSATO = `${PREFISSO}-passato`;
|
||||
const PREC = `${PREFISSO}-prec`;
|
||||
const MAIN = `${PREFISSO}-main`;
|
||||
const EV6 = `${PREFISSO}-ev6`;
|
||||
const EV12 = `${PREFISSO}-ev12`;
|
||||
const LONTANO = `${PREFISSO}-lontano`;
|
||||
const GENERICO = `${PREFISSO}-generico`;
|
||||
const COPPIA_A = `${PREFISSO}-coppia-a`;
|
||||
const COPPIA_B = `${PREFISSO}-coppia-b`;
|
||||
const ORA_ROTTA = `${PREFISSO}-ora-rotta`;
|
||||
// Solleciti: eventi extra-campo, così non entrano nell'ordine dei palloni.
|
||||
const S24 = `${PREFISSO}-s24`;
|
||||
const S12 = `${PREFISSO}-s12`;
|
||||
const S6 = `${PREFISSO}-s6`;
|
||||
const S_TARDI = `${PREFISSO}-s-tardi`;
|
||||
const S_FORSE = `${PREFISSO}-s-forse`;
|
||||
const S_FUORI = `${PREFISSO}-s-fuori`;
|
||||
const TUTTI = [
|
||||
PASSATO,
|
||||
PREC,
|
||||
MAIN,
|
||||
EV6,
|
||||
EV12,
|
||||
LONTANO,
|
||||
GENERICO,
|
||||
COPPIA_A,
|
||||
COPPIA_B,
|
||||
ORA_ROTTA,
|
||||
S24,
|
||||
S12,
|
||||
S6,
|
||||
S_TARDI,
|
||||
S_FORSE,
|
||||
S_FUORI,
|
||||
];
|
||||
|
||||
const rest = (percorso: string, token: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
async function leggi<T>(percorso: string): Promise<T[]> {
|
||||
const res = await rest(percorso, SERVIZIO);
|
||||
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()) as T[];
|
||||
}
|
||||
|
||||
async function scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
|
||||
const res = await rest(percorso, token, {
|
||||
method: metodo,
|
||||
body: corpo === undefined ? null : JSON.stringify(corpo),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function rpc(nome: string, corpo: unknown = {}, token = SERVIZIO) {
|
||||
return fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(corpo),
|
||||
});
|
||||
}
|
||||
|
||||
async function esegui(nome: string, corpo: unknown = {}) {
|
||||
const res = await rpc(nome, corpo);
|
||||
if (!res.ok) throw new Error(`${nome}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function creaUtente(email: string): Promise<{ id: string; token: string }> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
|
||||
});
|
||||
const corpo = (await res.json()) as { id?: string };
|
||||
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: { apikey: ANON, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
});
|
||||
const t = (await login.json()) as { access_token?: string };
|
||||
if (!t.access_token) throw new Error("accesso fallito");
|
||||
return { id: corpo.id, token: t.access_token };
|
||||
}
|
||||
|
||||
/** Data e ora dell'inizio tra `ore` ore, nel fuso Europe/Rome (convenzione di `eventi_app`). */
|
||||
function tra(ore: number): { data: string; ora: string } {
|
||||
const quando = new Date(Date.now() + ore * 60 * 60 * 1000);
|
||||
const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando);
|
||||
const ora = new Intl.DateTimeFormat("it-IT", {
|
||||
timeZone: "Europe/Rome",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(quando);
|
||||
return { data, ora };
|
||||
}
|
||||
const gg = (iso: string) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
|
||||
|
||||
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
|
||||
const notifiche = (evento: string, tipo?: string) =>
|
||||
leggi<Riga>(
|
||||
`notifiche_utente?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}` +
|
||||
`&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id,tipo`,
|
||||
);
|
||||
const destinatari = async (evento: string, tipo: string) =>
|
||||
(await notifiche(evento, tipo)).map((r) => r.giocatore_id).sort();
|
||||
const registro = (evento: string, tipo?: string) =>
|
||||
leggi<{ giocatore_id: string; tipo: string }>(
|
||||
`promemoria_eventi_generati?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}` +
|
||||
`&select=giocatore_id,tipo&order=giocatore_id`,
|
||||
);
|
||||
const inCodaEmail = async (ids: string[]) =>
|
||||
ids.length === 0
|
||||
? []
|
||||
: leggi<{ notifica_id: string }>(
|
||||
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
|
||||
);
|
||||
const inCodaPush = async (ids: string[]) =>
|
||||
ids.length === 0
|
||||
? []
|
||||
: leggi<{ notifica_id: string; stato: string }>(
|
||||
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id,stato`,
|
||||
);
|
||||
|
||||
type RigaEvento = {
|
||||
id: string;
|
||||
tipo: Evento["tipo"];
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
data: string;
|
||||
ora: string;
|
||||
convocati: string[];
|
||||
};
|
||||
/** Gli eventi del database come li vedrebbe il codice dell'app (solo i campi che servono). */
|
||||
const eventiDelDatabase = async (): Promise<Evento[]> =>
|
||||
(await leggi<RigaEvento>("eventi_app?select=id,tipo,titolo,luogo,data,ora,convocati")).map(
|
||||
(e) => ({ ...e, note: "", campionato: false, casa: true, pagelleChiuse: false }),
|
||||
);
|
||||
const turniDelDatabase = async (): Promise<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
(await leggi<{ evento_id: string; giocatore_id: string }>("turni_palloni?select=*")).map(
|
||||
(t) => [t.evento_id, t.giocatore_id],
|
||||
),
|
||||
);
|
||||
|
||||
const creaEvento = (
|
||||
id: string,
|
||||
tipo: string,
|
||||
quando: { data: string; ora: string },
|
||||
extra: Record<string, unknown> = {},
|
||||
) =>
|
||||
scrivi("eventi_app", "POST", {
|
||||
id,
|
||||
tipo,
|
||||
titolo: `${PREFISSO} ${id.slice(PREFISSO.length + 1)}`,
|
||||
luogo: "PalaCRAP",
|
||||
...quando,
|
||||
convocati: [],
|
||||
...extra,
|
||||
});
|
||||
const spostaEvento = (id: string, ore: number) =>
|
||||
scrivi(`eventi_app?id=eq.${id}`, "PATCH", tra(ore));
|
||||
const assegna = (evento: string, giocatore: string) =>
|
||||
scrivi("turni_palloni", "POST", { evento_id: evento, giocatore_id: giocatore });
|
||||
const cambiaTurno = (evento: string, giocatore: string) =>
|
||||
scrivi(`turni_palloni?evento_id=eq.${evento}`, "PATCH", { giocatore_id: giocatore });
|
||||
const rispondi = (evento: string, giocatore: string, stato: string) =>
|
||||
scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", {
|
||||
evento_id: evento,
|
||||
giocatore_id: giocatore,
|
||||
stato,
|
||||
}).catch(async () =>
|
||||
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", {
|
||||
stato,
|
||||
}),
|
||||
);
|
||||
|
||||
const idUtenti: string[] = [];
|
||||
let tokenAdmin = "";
|
||||
const originali = new Map<string, Record<string, unknown>>();
|
||||
|
||||
try {
|
||||
// --- Preparazione: un admin per toccare gli slot, un allenatore e un giocatore inattivo ---
|
||||
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
|
||||
const allenatore = await creaUtente(`${PREFISSO}-allenatore-${Date.now()}@example.test`);
|
||||
idUtenti.push(admin.id, allenatore.id);
|
||||
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
|
||||
tokenAdmin = admin.token;
|
||||
for (const id of [ALLENATORE, INATTIVO]) {
|
||||
const [riga] = await leggi<Record<string, unknown>>(
|
||||
`giocatori_squadra?id=eq.${id}&select=tipo,attivo,auth_user_id`,
|
||||
);
|
||||
originali.set(id, riga ?? { tipo: "giocatore", attivo: true, auth_user_id: null });
|
||||
}
|
||||
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
|
||||
await scrivi(
|
||||
`giocatori_squadra?id=eq.${ALLENATORE}`,
|
||||
"PATCH",
|
||||
{ tipo: "allenatore", auth_user_id: allenatore.id },
|
||||
tokenAdmin,
|
||||
);
|
||||
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin);
|
||||
|
||||
// Palloni. Le ore scelte stanno a più di mezz'ora dai confini delle finestre.
|
||||
await creaEvento(PASSATO, "partita", tra(-60));
|
||||
await creaEvento(PREC, "allenamento", tra(-30));
|
||||
await creaEvento(MAIN, "allenamento", tra(2.5));
|
||||
await creaEvento(EV6, "partita", tra(4.5));
|
||||
await creaEvento(EV12, "allenamento", tra(9));
|
||||
await creaEvento(LONTANO, "allenamento", tra(40));
|
||||
await creaEvento(GENERICO, "evento", tra(2.2));
|
||||
await assegna(PREC, G5);
|
||||
await assegna(MAIN, G4);
|
||||
await assegna(EV6, G7);
|
||||
await assegna(EV12, G8);
|
||||
await assegna(GENERICO, G4); // extra-campo: il turno non conta
|
||||
|
||||
// --- Turno palloni: finestre e destinatari ---------------------------------------------
|
||||
await prova("ogni evento riceve l'avviso della sua fascia e di nessun'altra", async () => {
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4, G5]);
|
||||
assert.deepEqual(await destinatari(EV6, "turno_palloni_6h"), [G4, G7]);
|
||||
assert.deepEqual(await destinatari(EV12, "turno_palloni_12h"), [G7, G8]);
|
||||
for (const [evento, altri] of [
|
||||
[MAIN, ["turno_palloni_6h", "turno_palloni_12h"]],
|
||||
[EV6, ["turno_palloni_3h", "turno_palloni_12h"]],
|
||||
[EV12, ["turno_palloni_3h", "turno_palloni_6h"]],
|
||||
] as const) {
|
||||
for (const tipo of altri) {
|
||||
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await prova(
|
||||
"nessun avviso per eventi lontani, passati, extra-campo o senza incaricato",
|
||||
async () => {
|
||||
assert.equal((await notifiche(LONTANO)).length, 0, "tra 40 ore: fuori da ogni fascia");
|
||||
assert.equal((await notifiche(PREC)).length, 0, "già iniziato");
|
||||
assert.equal((await notifiche(GENERICO)).length, 0, "un evento extra-campo non ha palloni");
|
||||
assert.equal((await registro(GENERICO)).length, 0);
|
||||
},
|
||||
);
|
||||
|
||||
await prova(
|
||||
"il testo è quello di «incarico assegnato» e «riconsegna», identico al codice",
|
||||
async () => {
|
||||
const eventi = await eventiDelDatabase();
|
||||
const turni = await turniDelDatabase();
|
||||
for (const [evento, tipo] of [
|
||||
[MAIN, "turno_palloni_3h"],
|
||||
[EV6, "turno_palloni_6h"],
|
||||
[EV12, "turno_palloni_12h"],
|
||||
] as const) {
|
||||
const attesi = avvisiPalloniEvento(turni, eventi, evento);
|
||||
assert.equal(attesi.length, 2, evento);
|
||||
const righe = await notifiche(evento, tipo);
|
||||
for (const atteso of attesi) {
|
||||
const r = righe.find((x) => x.giocatore_id === atteso.giocatoreId);
|
||||
assert.ok(r, `${evento}: manca l'avviso a ${atteso.giocatoreId}`);
|
||||
assert.equal(r.titolo, atteso.titolo, `${evento} titolo`);
|
||||
assert.equal(r.corpo, atteso.testo, `${evento} corpo`);
|
||||
}
|
||||
}
|
||||
const [incarico] = (await notifiche(MAIN, "turno_palloni_3h")).filter(
|
||||
(r) => r.giocatore_id === G4,
|
||||
);
|
||||
const eventiEv6 = eventi.find((e) => e.id === EV6)!;
|
||||
const main = eventi.find((e) => e.id === MAIN)!;
|
||||
assert.equal(
|
||||
incarico?.corpo,
|
||||
[
|
||||
`Evento: ${PREFISSO} main`,
|
||||
`Data: ${gg(main.data)}, ore ${main.ora}`,
|
||||
"Incarico: custodia dei palloni al termine dell'evento",
|
||||
`Riconsegna: ${gg(eventiEv6.data)}`,
|
||||
].join("\n"),
|
||||
"la riconsegna è la data dell'evento successivo",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await prova("registro e canali: push in coda, nessuna email", async () => {
|
||||
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2);
|
||||
const righe = [
|
||||
...(await notifiche(MAIN, "turno_palloni_3h")),
|
||||
...(await notifiche(EV6, "turno_palloni_6h")),
|
||||
...(await notifiche(EV12, "turno_palloni_12h")),
|
||||
];
|
||||
const ids = righe.map((r) => r.id);
|
||||
assert.equal(ids.length, 6);
|
||||
assert.equal((await inCodaPush(ids)).length, 6, "la push la manda il worker");
|
||||
assert.equal((await inCodaEmail(ids)).length, 0, "i palloni non mandano email (DD-040)");
|
||||
});
|
||||
|
||||
await prova("un secondo giro non duplica nulla; eliminata, l'avviso non torna", async () => {
|
||||
const prima = await notifiche(MAIN, "turno_palloni_3h");
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.deepEqual(
|
||||
(await notifiche(MAIN, "turno_palloni_3h")).map((r) => r.id),
|
||||
prima.map((r) => r.id),
|
||||
);
|
||||
const mia = prima.find((r) => r.giocatore_id === G5)!;
|
||||
await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE");
|
||||
await esegui("genera_avvisi_palloni");
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.deepEqual(await destinatari(MAIN, "turno_palloni_3h"), [G4], "g5 non la riavrà");
|
||||
assert.equal((await registro(MAIN, "turno_palloni_3h")).length, 2, "il registro la ricorda");
|
||||
});
|
||||
|
||||
await prova(
|
||||
"un incaricato non più attivo o un evento con ora illeggibile non avvisano",
|
||||
async () => {
|
||||
await creaEvento(ORA_ROTTA, "allenamento", { data: tra(24 * 60).data, ora: "boh" });
|
||||
await assegna(ORA_ROTTA, G4);
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.equal(
|
||||
(await notifiche(ORA_ROTTA)).length,
|
||||
0,
|
||||
"ora non valida: escluso, senza errori",
|
||||
);
|
||||
|
||||
await scrivi(`turni_palloni?evento_id=eq.${EV12}`, "PATCH", { giocatore_id: INATTIVO });
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.ok(
|
||||
!(await destinatari(EV12, "turno_palloni_12h")).includes(INATTIVO),
|
||||
"chi non è più attivo non riceve nulla",
|
||||
);
|
||||
await cambiaTurno(EV12, G8);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Revoca dell'incarico ---------------------------------------------------------------
|
||||
await prova(
|
||||
"incaricato cambiato: il nuovo riceve l'avviso, il vecchio una sola revoca",
|
||||
async () => {
|
||||
await cambiaTurno(MAIN, G6);
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.deepEqual(
|
||||
await destinatari(MAIN, "turno_palloni_3h"),
|
||||
[G4, G6],
|
||||
"g4 aveva già il suo; g6 è nuovo; g5 aveva eliminato il proprio e non lo riavrà",
|
||||
);
|
||||
const revoche = await notifiche(MAIN, "turno_palloni_revocato");
|
||||
assert.deepEqual(
|
||||
revoche.map((r) => r.giocatore_id),
|
||||
[G4],
|
||||
"solo chi aveva l'incarico e non ce l'ha più; g5 è ancora il turno precedente",
|
||||
);
|
||||
assert.equal(revoche[0]!.titolo, "Turno palloni: incarico revocato");
|
||||
const main = (await eventiDelDatabase()).find((e) => e.id === MAIN)!;
|
||||
assert.equal(
|
||||
revoche[0]!.corpo,
|
||||
[
|
||||
`Evento: ${PREFISSO} main`,
|
||||
`Data: ${gg(main.data)}, ore ${main.ora}`,
|
||||
"Incarico: non più a tuo carico per questo evento",
|
||||
].join("\n"),
|
||||
);
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.equal(
|
||||
(await notifiche(MAIN, "turno_palloni_revocato")).length,
|
||||
1,
|
||||
"la revoca parte una volta sola",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await prova("la revoca ha push in coda e nessuna email", async () => {
|
||||
const [revoca] = await notifiche(MAIN, "turno_palloni_revocato");
|
||||
assert.equal((await inCodaPush([revoca!.id])).length, 1);
|
||||
assert.equal((await inCodaEmail([revoca!.id])).length, 0);
|
||||
});
|
||||
|
||||
await prova("riassegnato al vecchio incaricato: nessun secondo avviso", async () => {
|
||||
await cambiaTurno(MAIN, G4);
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.deepEqual(
|
||||
(await notifiche(MAIN)).filter((r) => r.giocatore_id === G4).map((r) => r.tipo),
|
||||
["turno_palloni_3h", "turno_palloni_revocato"],
|
||||
"il registro ha già generato l'avviso di quella fascia: resta la revoca come ultima",
|
||||
);
|
||||
await cambiaTurno(MAIN, G4);
|
||||
});
|
||||
|
||||
await prova(
|
||||
"turno tolto: chi era stato avvisato, incaricato o riconsegna, viene revocato",
|
||||
async () => {
|
||||
// La notifica dell'avviso può essere stata eliminata: conta il registro.
|
||||
const [avvisoG7] = (await notifiche(EV6, "turno_palloni_6h")).filter(
|
||||
(r) => r.giocatore_id === G7,
|
||||
);
|
||||
await scrivi(`notifiche_utente?id=eq.${avvisoG7!.id}`, "DELETE");
|
||||
await scrivi(`turni_palloni?evento_id=eq.${EV6}`, "DELETE");
|
||||
await esegui("genera_avvisi_palloni");
|
||||
const revocati = await destinatari(EV6, "turno_palloni_revocato");
|
||||
assert.ok(revocati.includes(G7), "g7 aveva eliminato l'avviso: conta il registro");
|
||||
assert.ok(!revocati.includes(G8), "g8 non c'entra con questo evento");
|
||||
// Senza incaricato non parte nulla: nemmeno un nuovo avviso di fascia per nessuno.
|
||||
assert.equal((await notifiche(EV6, "turno_palloni_3h")).length, 0);
|
||||
await assegna(EV6, G7);
|
||||
},
|
||||
);
|
||||
|
||||
await prova("gli avvisi del pulsante (`turno_palloni`) non provocano revoche", async () => {
|
||||
await scrivi("notifiche_utente", "POST", {
|
||||
giocatore_id: G8,
|
||||
tipo: "turno_palloni",
|
||||
titolo: "manuale",
|
||||
corpo: "proposta non confermata",
|
||||
evento_id: MAIN,
|
||||
});
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.ok(
|
||||
!(await destinatari(MAIN, "turno_palloni_revocato")).includes(G8),
|
||||
"la proposta del pulsante può non essere un turno confermato",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("un evento già iniziato non genera la revoca", async () => {
|
||||
await scrivi("promemoria_eventi_generati", "POST", {
|
||||
giocatore_id: G4,
|
||||
evento_id: PREC,
|
||||
tipo: "turno_palloni_3h",
|
||||
});
|
||||
await esegui("genera_avvisi_palloni");
|
||||
assert.equal((await notifiche(PREC, "turno_palloni_revocato")).length, 0);
|
||||
});
|
||||
|
||||
// --- Stesso ordine del codice: precedente e successivo ---------------------------------
|
||||
await prova("precedente e successivo seguono lo stesso ordine di palloni-core.ts", async () => {
|
||||
const ora = "20:00";
|
||||
const data = tra(24 * 8).data;
|
||||
await creaEvento(COPPIA_B, "partita", { data, ora });
|
||||
await creaEvento(COPPIA_A, "allenamento", { data, ora });
|
||||
await assegna(COPPIA_A, G4);
|
||||
await assegna(COPPIA_B, G5);
|
||||
const eventi = await eventiDelDatabase();
|
||||
const turni = await turniDelDatabase();
|
||||
assert.equal(
|
||||
eventoPrecedente(eventi, COPPIA_B)?.id,
|
||||
COPPIA_A,
|
||||
"a parità di data e ora: per id",
|
||||
);
|
||||
assert.equal(eventoSuccessivo(eventi, COPPIA_A)?.id, COPPIA_B);
|
||||
|
||||
// Solo i due eventi della coppia cadono nella fascia: 8 giorni ± un giorno.
|
||||
await esegui("genera_avvisi_palloni_fascia", {
|
||||
p_tipo: "turno_palloni_12h",
|
||||
p_da: `${24 * 7} hours`,
|
||||
p_a: `${24 * 9} hours`,
|
||||
});
|
||||
for (const evento of [COPPIA_A, COPPIA_B]) {
|
||||
const righe = await notifiche(evento, "turno_palloni_12h");
|
||||
const attesi = avvisiPalloniEvento(turni, eventi, evento);
|
||||
assert.equal(righe.length, attesi.length, evento);
|
||||
for (const a of attesi) {
|
||||
const r = righe.find((x) => x.giocatore_id === a.giocatoreId);
|
||||
assert.ok(r, `${evento}: ${a.giocatoreId}`);
|
||||
assert.equal(r.titolo, a.titolo);
|
||||
assert.equal(r.corpo, a.testo);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Solleciti presenze -----------------------------------------------------------------
|
||||
await creaEvento(S24, "evento", tra(18), { convocati: [G4, G5, G7, INATTIVO, ALLENATORE] });
|
||||
await creaEvento(S12, "evento", tra(9));
|
||||
await creaEvento(S6, "evento", tra(4));
|
||||
await creaEvento(S_FUORI, "evento", tra(30));
|
||||
await rispondi(S24, G7, "presente");
|
||||
await rispondi(S24, G5, "forse");
|
||||
await rispondi(S12, G4, "assente");
|
||||
await rispondi(S12, G5, "forse");
|
||||
await rispondi(S6, G4, "ritardo");
|
||||
|
||||
const tuttiGiocatoriAttivi = async () =>
|
||||
(await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&tipo=eq.giocatore&select=id"))
|
||||
.map((g) => g.id)
|
||||
.sort();
|
||||
|
||||
await prova(
|
||||
"ogni evento riceve il sollecito della sua fascia, a chi deve rispondere",
|
||||
async () => {
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.deepEqual(
|
||||
await destinatari(S24, "sollecita_presenze_24h"),
|
||||
[G4, G5],
|
||||
"convocati senza risposta (g4) o «forse» (g5): g7 ha risposto, inattivo e allenatore esclusi",
|
||||
);
|
||||
const senzaRisposta = (await tuttiGiocatoriAttivi()).filter((g) => g !== G4);
|
||||
assert.deepEqual(
|
||||
await destinatari(S12, "sollecita_presenze_12h"),
|
||||
senzaRisposta,
|
||||
"convocati vuoto: tutta la rosa attiva, meno chi ha risposto; g5 («forse») resta",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await destinatari(S6, "sollecita_presenze_6h"),
|
||||
(await tuttiGiocatoriAttivi()).filter((g) => g !== G4),
|
||||
);
|
||||
assert.equal((await notifiche(S_FUORI)).length, 0, "tra 30 ore: fuori da ogni fascia");
|
||||
for (const [evento, altri] of [
|
||||
[S24, ["sollecita_presenze_12h", "sollecita_presenze_6h"]],
|
||||
[S12, ["sollecita_presenze_24h", "sollecita_presenze_6h"]],
|
||||
[S6, ["sollecita_presenze_24h", "sollecita_presenze_12h"]],
|
||||
] as const) {
|
||||
for (const tipo of altri) {
|
||||
assert.equal((await notifiche(evento, tipo)).length, 0, `${evento} non ha ${tipo}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await prova("mai all'allenatore né a chi non è attivo", async () => {
|
||||
for (const evento of [S24, S12, S6]) {
|
||||
const tutti = (await notifiche(evento)).map((r) => r.giocatore_id);
|
||||
assert.ok(!tutti.includes(ALLENATORE), `${evento}: allenatore`);
|
||||
assert.ok(!tutti.includes(INATTIVO), `${evento}: inattivo`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("il testo è quello del sollecito, identico al codice", async () => {
|
||||
const eventi = await eventiDelDatabase();
|
||||
const squadra = await leggi<{
|
||||
id: string;
|
||||
attivo: boolean;
|
||||
tipo: "giocatore" | "allenatore";
|
||||
}>("giocatori_squadra?select=id,attivo,tipo");
|
||||
for (const [id, tipo] of [
|
||||
[S24, "sollecita_presenze_24h"],
|
||||
[S12, "sollecita_presenze_12h"],
|
||||
[S6, "sollecita_presenze_6h"],
|
||||
] as const) {
|
||||
const evento = eventi.find((e) => e.id === id)!;
|
||||
const risposte = await leggi<{ giocatore_id: string; stato: string }>(
|
||||
`risposte_presenze?evento_id=eq.${id}&select=giocatore_id,stato`,
|
||||
);
|
||||
const attesi = destinatariSollecito(squadra, risposte, evento.convocati);
|
||||
assert.deepEqual(await destinatari(id, tipo), [...attesi].sort(), `${id}: destinatari`);
|
||||
for (const r of await notifiche(id, tipo)) {
|
||||
const { titolo, testo } = testoSollecito(
|
||||
evento,
|
||||
risposte.find((x) => x.giocatore_id === r.giocatore_id)?.stato,
|
||||
);
|
||||
assert.equal(r.titolo, titolo, `${id} titolo`);
|
||||
assert.equal(r.corpo, testo, `${id} corpo di ${r.giocatore_id}`);
|
||||
}
|
||||
}
|
||||
const [g5] = (await notifiche(S24, "sollecita_presenze_24h")).filter(
|
||||
(r) => r.giocatore_id === G5,
|
||||
);
|
||||
assert.match(g5!.corpo, /Risposta attuale: forse/);
|
||||
assert.ok(!g5!.corpo.includes("Richiesta di"), "l'automatico non ha un mittente");
|
||||
});
|
||||
|
||||
await prova("il sollecito ha push in coda ed email", async () => {
|
||||
const ids = (await notifiche(S24, "sollecita_presenze_24h")).map((r) => r.id);
|
||||
assert.equal((await inCodaPush(ids)).length, ids.length);
|
||||
assert.equal((await inCodaEmail(ids)).length, ids.length);
|
||||
});
|
||||
|
||||
await prova("un secondo giro non duplica; eliminato, il sollecito non torna", async () => {
|
||||
const prima = await notifiche(S24, "sollecita_presenze_24h");
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.equal((await notifiche(S24, "sollecita_presenze_24h")).length, prima.length);
|
||||
await scrivi(`notifiche_utente?id=eq.${prima[0]!.id}`, "DELETE");
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.equal((await notifiche(S24, "sollecita_presenze_24h")).length, prima.length - 1);
|
||||
});
|
||||
|
||||
await prova(
|
||||
"chi risponde smette di riceverli, chi resta su «forse» li riceve tutti e tre",
|
||||
async () => {
|
||||
await creaEvento(S_FORSE, "evento", tra(18), { convocati: [G4, G5, G8] });
|
||||
await rispondi(S_FORSE, G5, "forse");
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_24h"), [G4, G5, G8]);
|
||||
|
||||
await rispondi(S_FORSE, G4, "presente"); // risponde dopo il primo sollecito
|
||||
await spostaEvento(S_FORSE, 9); // entra nella fascia delle 12 ore
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.deepEqual(
|
||||
await destinatari(S_FORSE, "sollecita_presenze_12h"),
|
||||
[G5, G8],
|
||||
"g4 ha risposto: niente secondo sollecito; «forse» e nessuna risposta sì",
|
||||
);
|
||||
|
||||
await rispondi(S_FORSE, G8, "assente");
|
||||
await spostaEvento(S_FORSE, 4);
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.deepEqual(await destinatari(S_FORSE, "sollecita_presenze_6h"), [G5]);
|
||||
assert.equal(
|
||||
(await notifiche(S_FORSE)).filter((r) => r.giocatore_id === G5).length,
|
||||
3,
|
||||
"g5, sempre «forse»: 24, 12 e 6 ore",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await prova("un evento creato tardi riceve solo il sollecito della sua fascia", async () => {
|
||||
await creaEvento(S_TARDI, "evento", tra(5), { convocati: [G4] });
|
||||
await esegui("genera_solleciti_presenze");
|
||||
assert.deepEqual(
|
||||
(await notifiche(S_TARDI)).map((r) => r.tipo),
|
||||
["sollecita_presenze_6h"],
|
||||
"non tre solleciti insieme",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("il sollecito del pulsante e quello automatico convivono", async () => {
|
||||
await scrivi("notifiche_utente", "POST", {
|
||||
giocatore_id: G5,
|
||||
tipo: "sollecita_presenze",
|
||||
titolo: "manuale",
|
||||
corpo: "manuale",
|
||||
evento_id: S24,
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await notifiche(S24)).filter((r) => r.giocatore_id === G5).map((r) => r.tipo),
|
||||
["sollecita_presenze", "sollecita_presenze_24h"],
|
||||
);
|
||||
});
|
||||
|
||||
// --- Schema, permessi, pianificazione ---------------------------------------------------
|
||||
await prova("le funzioni dei job sono riservate alla service role", async () => {
|
||||
for (const nome of [
|
||||
"genera_avvisi_palloni",
|
||||
"genera_solleciti_presenze",
|
||||
"genera_revoche_palloni",
|
||||
]) {
|
||||
assert.ok(!(await rpc(nome, {}, ANON)).ok, `${nome}: anon`);
|
||||
assert.ok(!(await rpc(nome, {}, admin.token)).ok, `${nome}: utente autenticato`);
|
||||
}
|
||||
for (const nome of ["genera_avvisi_palloni_fascia", "genera_solleciti_presenze_fascia"]) {
|
||||
const corpo = { p_tipo: "turno_palloni_3h", p_da: "0", p_a: "1 hour" };
|
||||
assert.ok(!(await rpc(nome, corpo, ANON)).ok, `${nome}: anon`);
|
||||
assert.ok(!(await rpc(nome, corpo, admin.token)).ok, `${nome}: utente autenticato`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("il registro accetta solo i tipi automatici", async () => {
|
||||
const res = await rest("promemoria_eventi_generati", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ giocatore_id: G4, evento_id: MAIN, tipo: "turno_palloni" }),
|
||||
});
|
||||
assert.ok(!res.ok, "`turno_palloni` è il tipo del pulsante: non entra nel registro");
|
||||
});
|
||||
|
||||
await prova("ogni tipo nuovo è accettato da notifiche_utente", async () => {
|
||||
for (const tipo of [
|
||||
"turno_palloni_12h",
|
||||
"turno_palloni_6h",
|
||||
"turno_palloni_3h",
|
||||
"turno_palloni_revocato",
|
||||
"sollecita_presenze_24h",
|
||||
"sollecita_presenze_12h",
|
||||
"sollecita_presenze_6h",
|
||||
]) {
|
||||
const res = await rest("notifiche_utente", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
giocatore_id: G4,
|
||||
tipo,
|
||||
titolo: "x",
|
||||
corpo: "y",
|
||||
evento_id: LONTANO,
|
||||
}),
|
||||
});
|
||||
assert.ok(res.ok, `${tipo}: ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("i due job sono pianificati ogni 15 minuti", async () => {
|
||||
const contenitore = spawnSync(
|
||||
"docker",
|
||||
["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"],
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
.stdout.trim()
|
||||
.split("\n")[0];
|
||||
assert.ok(contenitore, "container del database locale non trovato");
|
||||
const esito = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"exec",
|
||||
contenitore,
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-At",
|
||||
"-F",
|
||||
"|",
|
||||
"-c",
|
||||
"select jobname, schedule, command from cron.job where jobname in " +
|
||||
"('avvisi-palloni-automatici','solleciti-presenze-automatici') order by jobname",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(esito.status, 0, esito.stderr);
|
||||
const righe = esito.stdout.trim().split("\n");
|
||||
assert.equal(righe.length, 2);
|
||||
assert.match(
|
||||
righe[0]!,
|
||||
/^avvisi-palloni-automatici\|\*\/15 \* \* \* \*\|.*genera_avvisi_palloni\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
righe[1]!,
|
||||
/^solleciti-presenze-automatici\|\*\/15 \* \* \* \*\|.*genera_solleciti_presenze\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
await prova("cancellare l'evento toglie registro e notifiche collegate", async () => {
|
||||
assert.ok((await registro(MAIN)).length > 0);
|
||||
await scrivi(`eventi_app?id=eq.${MAIN}`, "DELETE");
|
||||
assert.equal((await registro(MAIN)).length, 0);
|
||||
assert.equal((await notifiche(MAIN)).length, 0);
|
||||
});
|
||||
} finally {
|
||||
for (const id of TUTTI) {
|
||||
await rest(`notifiche_utente?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`promemoria_eventi_generati?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`turni_palloni?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`risposte_presenze?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
if (tokenAdmin) {
|
||||
for (const [id, valori] of originali) {
|
||||
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(valori),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of idUtenti) {
|
||||
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
||||
});
|
||||
}
|
||||
riepilogo("notifiche-automatiche");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 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 con email viene accodato", async () => {
|
||||
for (const tipo of [
|
||||
"evento_promemoria_24h",
|
||||
"evento_promemoria_3h",
|
||||
"sollecita_presenze",
|
||||
"sollecita_presenze_24h",
|
||||
"sollecita_presenze_12h",
|
||||
"sollecita_presenze_6h",
|
||||
"sondaggio_cacche",
|
||||
]) {
|
||||
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
|
||||
creati.push(id);
|
||||
assert.equal((await coda(id))?.stato, "in_coda", tipo);
|
||||
}
|
||||
});
|
||||
|
||||
await prova(
|
||||
"il turno palloni non genera email, in nessuna delle sue forme (DD-040)",
|
||||
async () => {
|
||||
for (const tipo of [
|
||||
"turno_palloni",
|
||||
"turno_palloni_12h",
|
||||
"turno_palloni_6h",
|
||||
"turno_palloni_3h",
|
||||
"turno_palloni_revocato",
|
||||
]) {
|
||||
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
|
||||
creati.push(id);
|
||||
assert.equal(await coda(id), undefined, `${tipo}: nessuna riga nella coda email`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await prova("segnare come letta non rimette in coda una mail già inviata", async () => {
|
||||
const id = await creaNotifica(SLOT_COLLEGATO);
|
||||
creati.push(id);
|
||||
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: "sollecita_presenze",
|
||||
evento_id: EVENTO_UPSERT,
|
||||
});
|
||||
creati.push(id);
|
||||
await prendi([id]);
|
||||
await esito(id, "inviata");
|
||||
assert.equal((await coda(id))?.stato, "inviata");
|
||||
|
||||
// Come `sollecita-presenze.ts`: stesso (giocatore, evento, tipo).
|
||||
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
||||
body: JSON.stringify({
|
||||
giocatore_id: SLOT_COLLEGATO,
|
||||
tipo: "sollecita_presenze",
|
||||
titolo: `${PREFISSO} aggiornato`,
|
||||
corpo: "nuovo testo",
|
||||
evento_id: EVENTO_UPSERT,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
assert.ok(res.ok, `upsert: ${res.status}`);
|
||||
const riga = await coda(id);
|
||||
assert.equal(riga?.stato, "in_coda");
|
||||
assert.equal(riga?.tentativi, 0);
|
||||
assert.equal(riga?.inviata_il, null);
|
||||
});
|
||||
|
||||
await prova("il rinvio con upsert del turno palloni non accoda nessuna mail", async () => {
|
||||
const id = await creaNotifica(SLOT_COLLEGATO, {
|
||||
tipo: "turno_palloni",
|
||||
evento_id: EVENTO_UPSERT,
|
||||
});
|
||||
creati.push(id);
|
||||
assert.equal(await coda(id), undefined);
|
||||
|
||||
// Come `promemoria-palloni.ts`: stesso (giocatore, evento, tipo), `creato_il` riscritto.
|
||||
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
||||
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}`);
|
||||
assert.equal(await coda(id), undefined, "nemmeno dopo il rinvio");
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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 EVENTO_DOMANI = `${PREFISSO}-domani`;
|
||||
const EVENTO_OGGI = `${PREFISSO}-oggi`;
|
||||
const EVENTO_SENZA_LUOGO = `${PREFISSO}-senza-luogo`;
|
||||
const G4 = "g4";
|
||||
const G5 = "g5";
|
||||
const G6 = "g6";
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
// --- Testo (DD-040) ------------------------------------------------------------------
|
||||
// «domani» se la data dell'evento è quella di domani a Roma, «oggi» altrimenti. Per non
|
||||
// dipendere dall'ora in cui gira il test: un evento domani a mezzanotte cade sempre nella
|
||||
// finestra delle 24 ore, uno oggi alle 23:59 pure.
|
||||
const oggiRoma = tra(0).data;
|
||||
const domaniRoma = new Date(new Date(`${oggiRoma}T12:00:00Z`).getTime() + 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const gg = (iso: string) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
|
||||
const testoDi = async (evento: string, tipo: string) => {
|
||||
const [n] = await leggi<{ titolo: string; corpo: string }>(
|
||||
`notifiche_utente?evento_id=eq.${evento}&tipo=eq.${tipo}&giocatore_id=eq.${G4}&select=titolo,corpo`,
|
||||
);
|
||||
assert.ok(n, `promemoria ${tipo} di ${evento} non generato`);
|
||||
return n;
|
||||
};
|
||||
|
||||
await prova("evento di domani: titolo e data dicono «domani», con ora e luogo", async () => {
|
||||
await scrivi("eventi_app", "POST", {
|
||||
id: EVENTO_DOMANI,
|
||||
tipo: "partita",
|
||||
titolo: `${PREFISSO} cena`,
|
||||
data: domaniRoma,
|
||||
ora: "00:00",
|
||||
luogo: " PalaCRAP ",
|
||||
convocati: [G4],
|
||||
});
|
||||
await cron("evento_promemoria_24h");
|
||||
const n = await testoDi(EVENTO_DOMANI, "evento_promemoria_24h");
|
||||
assert.equal(n.titolo, `Promemoria evento di domani: ${PREFISSO} cena`);
|
||||
assert.equal(
|
||||
n.corpo,
|
||||
`Data: domani, ${gg(domaniRoma)}\nOra: 00:00\nLuogo: PalaCRAP`,
|
||||
"il luogo è ripulito dagli spazi",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("evento di oggi: titolo e data dicono «oggi»", async () => {
|
||||
await scrivi("eventi_app", "POST", {
|
||||
id: EVENTO_OGGI,
|
||||
tipo: "allenamento",
|
||||
titolo: `${PREFISSO} oggi`,
|
||||
data: oggiRoma,
|
||||
ora: "23:59",
|
||||
luogo: "Palestra",
|
||||
convocati: [G4],
|
||||
});
|
||||
await cron("evento_promemoria_24h");
|
||||
const n = await testoDi(EVENTO_OGGI, "evento_promemoria_24h");
|
||||
assert.equal(n.titolo, `Promemoria evento di oggi: ${PREFISSO} oggi`);
|
||||
assert.equal(n.corpo, `Data: oggi, ${gg(oggiRoma)}\nOra: 23:59\nLuogo: Palestra`);
|
||||
});
|
||||
|
||||
await prova("lo stesso testo vale per il promemoria a 3 ore", async () => {
|
||||
const { data, ora } = tra(2);
|
||||
await scrivi("eventi_app", "POST", {
|
||||
id: EVENTO_SENZA_LUOGO,
|
||||
tipo: "evento",
|
||||
titolo: `${PREFISSO} senza luogo`,
|
||||
data,
|
||||
ora,
|
||||
convocati: [G4],
|
||||
});
|
||||
await cron("evento_promemoria_3h");
|
||||
const n = await testoDi(EVENTO_SENZA_LUOGO, "evento_promemoria_3h");
|
||||
const parola = data === domaniRoma ? "domani" : "oggi";
|
||||
assert.equal(
|
||||
n.titolo,
|
||||
`Promemoria evento di ${parola}: ${PREFISSO} senza luogo`,
|
||||
"anche il promemoria a 3 ore di un evento dopo mezzanotte dice «domani»",
|
||||
);
|
||||
assert.equal(
|
||||
n.corpo,
|
||||
`Data: ${parola}, ${gg(data)}\nOra: ${ora}`,
|
||||
"senza luogo la riga «Luogo» manca",
|
||||
);
|
||||
});
|
||||
|
||||
await prova(
|
||||
"il promemoria nuovo parte come push e come email, con lo stesso testo",
|
||||
async () => {
|
||||
const [n] = await leggi<{ id: string }>(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_DOMANI}&tipo=eq.evento_promemoria_24h&giocatore_id=eq.${G4}&select=id`,
|
||||
);
|
||||
assert.ok(n);
|
||||
assert.equal((await inCoda([n.id])).length, 1, "email in coda");
|
||||
const push = await leggi<{ notifica_id: string }>(
|
||||
`notifiche_push_coda?notifica_id=eq.${n.id}&select=notifica_id`,
|
||||
);
|
||||
assert.equal(push.length, 1, "push in coda");
|
||||
},
|
||||
);
|
||||
|
||||
await prova("cancellare l'evento toglie anche il registro", async () => {
|
||||
const { data, ora } = { data: tra(2).data, ora: tra(2).ora };
|
||||
await scrivi("eventi_app", "POST", {
|
||||
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,
|
||||
EVENTO_DOMANI,
|
||||
EVENTO_OGGI,
|
||||
EVENTO_SENZA_LUOGO,
|
||||
]) {
|
||||
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
riepilogo("promemoria-eventi");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 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(
|
||||
"le notifiche automatiche di palloni e solleciti (DD-040) vanno in coda push; i pulsanti no",
|
||||
async () => {
|
||||
const automatiche = [
|
||||
"turno_palloni_12h",
|
||||
"turno_palloni_6h",
|
||||
"turno_palloni_3h",
|
||||
"turno_palloni_revocato",
|
||||
"sollecita_presenze_24h",
|
||||
"sollecita_presenze_12h",
|
||||
"sollecita_presenze_6h",
|
||||
];
|
||||
for (const tipo of automatiche) {
|
||||
const riga = await coda(await creaNotifica(G4, tipo, EVENTI[2]));
|
||||
assert.equal(riga?.stato, "in_coda", tipo);
|
||||
assert.equal(riga?.tentativi, 0, tipo);
|
||||
}
|
||||
// `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti: la push la manda
|
||||
// la route, e una seconda dal worker sarebbe un doppione.
|
||||
for (const tipo of ["turno_palloni", "sollecita_presenze"]) {
|
||||
assert.equal(await coda(await creaNotifica(G4, tipo, EVENTI[2])), undefined, tipo);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await prova(
|
||||
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
|
||||
async () => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { formatData, giocatori, statoMeta } from "@/lib/crapp-data";
|
||||
import { formatData, formatDataNumerica, giocatori, statoMeta } from "@/lib/crapp-data";
|
||||
|
||||
// --- rosa --------------------------------------------------------------------
|
||||
assert.ok(giocatori.length > 0, "la rosa non è vuota");
|
||||
@@ -29,6 +29,15 @@ for (const g of giocatori) {
|
||||
|
||||
// --- formatData --------------------------------------------------------------
|
||||
assert.equal(formatData("2026-09-01"), "mar 01 settembre");
|
||||
|
||||
// --- formatDataNumerica: la data dei testi delle notifiche, come to_char(…, 'DD/MM/YYYY') ----
|
||||
assert.equal(formatDataNumerica("2026-09-01"), "01/09/2026");
|
||||
assert.equal(formatDataNumerica("2026-12-31"), "31/12/2026");
|
||||
assert.equal(
|
||||
formatDataNumerica("2026-03-29"),
|
||||
"29/03/2026",
|
||||
"nessuna conversione di fuso: il giorno del cambio ora resta quello scritto",
|
||||
);
|
||||
assert.equal(formatData("2026-01-31"), "sab 31 gennaio");
|
||||
assert.ok(
|
||||
!formatData("2026-03-29").includes("28"),
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
/** 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, /<img src=x onerror=alert\(1\)><br>riga "2"/);
|
||||
assert.match(m.html, /href="https:\/\/crapp\.example"/);
|
||||
assert.equal(escapeHtml(`<&>"'`), "<&>"'");
|
||||
});
|
||||
|
||||
await prova(
|
||||
"il corpo a righe etichettate delle notifiche automatiche (DD-040) resta leggibile",
|
||||
() => {
|
||||
const corpo = "Data: domani, 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
||||
const m = componiMail(
|
||||
notifica({
|
||||
oggetto: "Promemoria evento di domani: Allenamento",
|
||||
testo: corpo,
|
||||
id_evento: "ev-1",
|
||||
}),
|
||||
OPZ,
|
||||
);
|
||||
assert.equal(m.subject, "Promemoria evento di domani: Allenamento");
|
||||
assert.ok(m.text.startsWith(`${corpo}\n\nApri CrAPP: `), "in testo semplice le righe restano");
|
||||
assert.match(m.html, /Data: domani, 01\/10\/2026<br>Ora: 21:00<br>Luogo: PalaCRAP/);
|
||||
},
|
||||
);
|
||||
|
||||
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");
|
||||
@@ -1,10 +1,12 @@
|
||||
/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
contatoreBadge,
|
||||
daRiga,
|
||||
pallinoNotifiche,
|
||||
type RigaNotifica,
|
||||
type TipoNotifica,
|
||||
} from "@/lib/notifiche-utente";
|
||||
|
||||
// --- daRiga: conversione database -> modello applicativo ---------------------
|
||||
@@ -48,4 +50,33 @@ assert.deepEqual(pallinoNotifiche(30, 12), { testo: "9+", daLeggere: true });
|
||||
// Tutte lette: il pallino resta per poterle ancora aprire ed eliminare.
|
||||
assert.deepEqual(pallinoNotifiche(3, 0), { testo: "3", daLeggere: false });
|
||||
|
||||
// --- i tipi automatici di DD-040 attraversano daRiga senza perdere le righe del testo -----
|
||||
const tipiNuovi: TipoNotifica[] = [
|
||||
"turno_palloni_12h",
|
||||
"turno_palloni_6h",
|
||||
"turno_palloni_3h",
|
||||
"turno_palloni_revocato",
|
||||
"sollecita_presenze_24h",
|
||||
"sollecita_presenze_12h",
|
||||
"sollecita_presenze_6h",
|
||||
];
|
||||
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
||||
for (const tipo of tipiNuovi) {
|
||||
const n = daRiga({ ...riga, tipo, corpo: corpoMultiriga });
|
||||
assert.equal(n.tipo, tipo);
|
||||
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
|
||||
}
|
||||
|
||||
// Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza
|
||||
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
|
||||
const uiBits = readFileSync(
|
||||
new URL("../../src/components/crapp/ui-bits.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
uiBits,
|
||||
/whitespace-pre-line[^"]*"[^>]*>\s*\{notifica\.corpo\}/,
|
||||
"il corpo della notifica mantiene gli a capo",
|
||||
);
|
||||
|
||||
console.log("notifiche-utente: ok");
|
||||
@@ -179,20 +179,46 @@ assert.deepEqual(
|
||||
);
|
||||
|
||||
// --- avvisiPalloniEvento ------------------------------------------------------
|
||||
// Il promemoria per un evento scelto dall'admin: due destinatari, con testi diversi.
|
||||
// L'avviso per un evento scelto: due destinatari, con testi diversi (DD-040). Gli stessi testi dei
|
||||
// job automatici del database: `test/integration/notifiche-automatiche.test.ts` li confronta.
|
||||
const avvisi = avvisiPalloniEvento(turniPush, eventiPush, "p2");
|
||||
assert.deepEqual(
|
||||
avvisi.map((a) => a.giocatoreId),
|
||||
["g2", "g1"],
|
||||
"l'incaricato di questo evento, e chi ha i palloni dalla volta prima",
|
||||
);
|
||||
assert.match(avvisi[0]!.titolo, /prendere i palloni/);
|
||||
assert.match(avvisi[1]!.titolo, /Porta i palloni/);
|
||||
assert.equal(avvisi[0]!.titolo, "Turno palloni: incarico assegnato");
|
||||
assert.equal(avvisi[1]!.titolo, "Turno palloni: riconsegna");
|
||||
assert.equal(
|
||||
avvisi[0]!.testo,
|
||||
[
|
||||
"Evento: Evento p2",
|
||||
"Data: 02/02/2026, ore 21:00",
|
||||
"Incarico: custodia dei palloni al termine dell'evento",
|
||||
"Riconsegna: 03/02/2026",
|
||||
].join("\n"),
|
||||
"incarico: evento, data numerica, incarico e data di riconsegna dell'evento successivo",
|
||||
);
|
||||
assert.equal(
|
||||
avvisi[1]!.testo,
|
||||
[
|
||||
"Evento: Evento p2",
|
||||
"Data: 02/02/2026, ore 21:00",
|
||||
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
|
||||
].join("\n"),
|
||||
"riconsegna: nessuna riga «Riconsegna», chi la riceve li riporta",
|
||||
);
|
||||
assert.ok(
|
||||
avvisi.every((a) => !/oggi/i.test(a.testo)),
|
||||
"il testo nomina l'evento, non 'oggi': può arrivare giorni prima",
|
||||
);
|
||||
|
||||
const ultimo = avvisiPalloniEvento(turniPush, eventiPush, "p3");
|
||||
assert.ok(
|
||||
ultimo.every((a) => !a.testo.includes("Riconsegna:")),
|
||||
"l'ultimo evento non ha un successivo: la riga «Riconsegna» manca",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId),
|
||||
["g1"],
|
||||
@@ -213,6 +239,26 @@ assert.deepEqual(
|
||||
[],
|
||||
"senza turni assegnati non c'è nessuno da avvisare",
|
||||
);
|
||||
assert.deepEqual(
|
||||
avvisiPalloniEvento({ p1: "g1" }, eventiPush, "p2"),
|
||||
[],
|
||||
"senza un incaricato per l'evento non parte nulla, nemmeno la riconsegna (DD-040)",
|
||||
);
|
||||
|
||||
// --- ordine: a parità di data conta l'ora, poi l'id --------------------------
|
||||
const stessoGiorno: Evento[] = [
|
||||
{ ...evento("z", "2026-06-01"), ora: "21:00" },
|
||||
{ ...evento("y", "2026-06-01"), ora: "18:30" },
|
||||
{ ...evento("x", "2026-06-01"), ora: "21:00" },
|
||||
evento("a", "2026-05-31"),
|
||||
];
|
||||
assert.deepEqual(
|
||||
eventiPalloni(stessoGiorno).map((e) => e.id),
|
||||
["a", "y", "x", "z"],
|
||||
"data, poi ora, poi id: lo stesso ordine dei job del database",
|
||||
);
|
||||
assert.equal(eventoPrecedente(stessoGiorno, "x")?.id, "y");
|
||||
assert.equal(eventoSuccessivo(stessoGiorno, "y")?.id, "x");
|
||||
|
||||
// --- serieConsecutivaPalloni: volte consecutive in cui li ha portati lui -----
|
||||
const eventiSerie: Evento[] = [
|
||||
|
||||
@@ -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");
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
contaPartiteGiocate,
|
||||
contaPresenzeGiocatore,
|
||||
destinatariSollecito,
|
||||
includeInfortunato,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
testoSollecito,
|
||||
totaliEventiGiocatore,
|
||||
type MappaPresenze,
|
||||
type MappaTempiRisposta,
|
||||
@@ -157,6 +159,65 @@ assert.deepEqual(
|
||||
);
|
||||
assert.deepEqual(destinatariSollecito([], risposte), [], "rosa vuota, nessun destinatario");
|
||||
|
||||
// --- il sollecito segue i convocati (DD-040) ----------------------------------
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, risposte, ["g1", "g2", "g3"]),
|
||||
["g2", "g3"],
|
||||
"solo i convocati senza risposta o con «forse»: g1 ha risposto, g4 non è convocato",
|
||||
);
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, risposte, ["g4"]),
|
||||
[],
|
||||
"l'unico convocato ha risposto: nessun sollecito",
|
||||
);
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, [], ["g3", "g5", "g6", "g9"]),
|
||||
["g3"],
|
||||
"convocati inattivi, allenatori o fuori rosa restano esclusi anche se convocati",
|
||||
);
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, risposte, []),
|
||||
["g2", "g3"],
|
||||
"nessun convocato indicato vale per tutta la rosa, come nel database",
|
||||
);
|
||||
|
||||
// --- testo del sollecito -----------------------------------------------------
|
||||
const eventoSollecito = {
|
||||
titolo: "Allenamento del giovedì",
|
||||
data: "2026-10-01",
|
||||
ora: "21:00",
|
||||
luogo: "PalaCRAP",
|
||||
};
|
||||
const automatico = testoSollecito(eventoSollecito, undefined);
|
||||
assert.equal(automatico.titolo, "Conferma di partecipazione richiesta: Allenamento del giovedì");
|
||||
assert.equal(
|
||||
automatico.testo,
|
||||
[
|
||||
"Data: 01/10/2026",
|
||||
"Ora: 21:00",
|
||||
"Luogo: PalaCRAP",
|
||||
"Risposta attuale: nessuna",
|
||||
"Azione: indicare presente, assente o in ritardo",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.match(testoSollecito(eventoSollecito, "forse").testo, /Risposta attuale: forse/);
|
||||
assert.match(
|
||||
testoSollecito(eventoSollecito, "assente").testo,
|
||||
/Risposta attuale: nessuna/,
|
||||
"qualunque stato diverso da «forse» si legge come nessuna risposta",
|
||||
);
|
||||
const manuale = testoSollecito(eventoSollecito, undefined, "Mario Rossi");
|
||||
assert.equal(
|
||||
manuale.testo.split("\n")[3],
|
||||
"Richiesta di: Mario Rossi",
|
||||
"il sollecito manuale nomina chi lo chiede, tra il luogo e la risposta attuale",
|
||||
);
|
||||
assert.ok(!automatico.testo.includes("Richiesta di"), "l'automatico non ha un mittente");
|
||||
assert.ok(
|
||||
!testoSollecito({ ...eventoSollecito, luogo: " " }, undefined).testo.includes("Luogo:"),
|
||||
"senza luogo la riga manca",
|
||||
);
|
||||
|
||||
// --- serie conferme: risposta entro 24h dalla convocazione --------------------
|
||||
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
|
||||
...ev(id, "allenamento", data),
|
||||
@@ -295,4 +356,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");
|
||||
@@ -2,12 +2,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
avvisiCertificati,
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
daRigaProfilo,
|
||||
formatDataBreve,
|
||||
giorniAllaScadenza,
|
||||
sezioniComplete,
|
||||
statoScadenza,
|
||||
testoScadenza,
|
||||
type Profilo,
|
||||
} from "@/lib/profili-core";
|
||||
import {
|
||||
@@ -214,4 +218,119 @@ assert.equal(
|
||||
);
|
||||
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
|
||||
|
||||
// --- avviso certificati (DD-035) ------------------------------------------------
|
||||
assert.equal(giorniAllaScadenza("2026-09-27", "2026-09-20"), 7);
|
||||
assert.equal(giorniAllaScadenza("2026-09-19", "2026-09-20"), -1, "scaduto ieri");
|
||||
assert.equal(giorniAllaScadenza("2026-09-20", "2026-09-20"), 0, "scade oggi");
|
||||
assert.equal(giorniAllaScadenza("2026-10-01", "2026-09-30"), 1, "cambio di mese");
|
||||
assert.equal(giorniAllaScadenza("2027-01-01", "2026-12-31"), 1, "cambio di anno");
|
||||
|
||||
assert.equal(formatDataBreve("2026-09-12"), "12/09/2026");
|
||||
|
||||
assert.equal(testoScadenza(0), "scade oggi");
|
||||
assert.equal(testoScadenza(1), "scade domani");
|
||||
assert.equal(testoScadenza(5), "scade tra 5 giorni");
|
||||
|
||||
const rosaCertificati: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g4", nome: "Sara", cognome: "Neri", attivo: false, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g5", nome: "Luca", cognome: "Bianchi", attivo: true, tipo: "allenatore" },
|
||||
];
|
||||
const oggiTest = "2026-09-20";
|
||||
const profiliCertificati: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia
|
||||
g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso
|
||||
g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso
|
||||
};
|
||||
const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest);
|
||||
assert.deepEqual(
|
||||
avvisi.scaduti.map((a) => a.giocatoreId),
|
||||
["g2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
avvisi.inScadenza.map((a) => a.giocatoreId),
|
||||
["g1"],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
avvisiCertificati(
|
||||
[{ ...squadra[0]!, id: "g6" }],
|
||||
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
|
||||
oggiTest,
|
||||
).inScadenza.length,
|
||||
0,
|
||||
"certificato mancante: nessun avviso",
|
||||
);
|
||||
|
||||
const dueInScadenza: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino
|
||||
};
|
||||
const ordinati = avvisiCertificati(
|
||||
[
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||
],
|
||||
dueInScadenza,
|
||||
oggiTest,
|
||||
).inScadenza.map((a) => a.giocatoreId);
|
||||
assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima");
|
||||
|
||||
const pariGiorni: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" },
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" },
|
||||
};
|
||||
const alfabetico = avvisiCertificati(
|
||||
[
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||
],
|
||||
pariGiorni,
|
||||
oggiTest,
|
||||
).inScadenza.map((a) => a.giocatoreId);
|
||||
assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome");
|
||||
|
||||
// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro
|
||||
const rosaSoglia: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||
];
|
||||
const profiliSoglia: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro
|
||||
};
|
||||
const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest);
|
||||
assert.deepEqual(
|
||||
soglia.inScadenza.map((a) => a.giocatoreId),
|
||||
["g3", "g2"],
|
||||
"8 giorni resta valido, 7 e 0 generano avviso",
|
||||
);
|
||||
assert.equal(soglia.scaduti.length, 0);
|
||||
|
||||
// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data
|
||||
const rosaScaduti: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||
];
|
||||
const profiliScaduti: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno
|
||||
};
|
||||
const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map(
|
||||
(a) => a.giocatoreId,
|
||||
);
|
||||
assert.deepEqual(
|
||||
scadutiOrdinati,
|
||||
["g2", "g1", "g3"],
|
||||
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
|
||||
);
|
||||
|
||||
console.log("profili-core: ok");
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/public/keepalive",
|
||||
"schedule": "0 5 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user