11 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 a69bcb3f0d Porta la versione a 1.4.2: link Maps, testo «Apri su Maps»
Il pulsante delle schede di partita, allenamento ed evento dice «Apri su Maps».
CHANGELOG con la sezione 1.4.2 (m34 applicata in produzione il 26/10/2026),
PROJECT_STATE e package.json/package-lock alla 1.4.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:20:42 +01:00
davideandClaude Sonnet 5.5 19ddf715e6 Link Google Maps del luogo degli eventi (m34)
Colonna facoltativa eventi_app.link_maps con CHECK che accetta solo URL https di
Google Maps. Campo nel form di /eventi (admin e allenatori), validazione con
linkMapsValido() e test. Le schede di partita, allenamento ed evento mostrano
«Apri su Google Maps» solo se il link c'è; card e notifiche non cambiano.
Documentazione: calendario.md, DATABASE.md, CHANGELOG.

La migration m34 non è ancora applicata in produzione: va fatto db push prima di
rilasciare questo codice, che legge la nuova colonna.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:12:07 +01:00
davide 2f583ca97f Merge develop: versione 1.4.1 2026-10-19 08:31:53 +02:00
davideandClaude Sonnet 5.5 7996dd050e CHANGELOG: la correzione della card evento è parte della 1.4.1
Sposta la voce da «Non rilasciato» alla sezione 1.4.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:31:10 +02:00
davideandClaude Sonnet 5.5 5ee148b88b Card evento filtrata sui convocati + copertura test per numero di convocati
- La card (Calendario e Home) conta «presenti/convocati» e mostra i pulsanti di presenza solo
  ai convocati; «Da confermare» in Home esclude gli eventi a cui non si è convocati.
- Nuove eConvocato() e rosaConvocata(): il filtro, prima ripetuto in tre punti, è una funzione
  pura usata da card, «Rosa e presenze» e convocatiEvento.
- Test per 0, 1, 2, 3, tutti e id sconosciuti su visibilità, convocazione e solleciti.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:29:36 +02:00
davideandClaude Sonnet 5.5 8def97731c Porta la versione a 1.4.1: eventi visibili solo ai convocati
Sezione 1.4.1 del CHANGELOG, versione in package.json e package-lock.json,
PROJECT_STATE aggiornato. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:14:17 +02:00
davideandClaude Sonnet 5.5 5ca081a6af Eventi visibili solo ai convocati; «Rosa e presenze» filtrata sui convocati
- «Rosa e presenze» (allenamento, partita, evento) elenca, conta e solleciti solo i convocati
  invece di tutta la rosa; il riquadro «La tua risposta» sparisce per i non convocati.
- Calendario, Home e elenco eventi mostrano un evento con convocati solo a chi è convocato;
  admin e allenatori lo vedono sempre, `convocati` vuoto = tutti (eventoVisibile).
- Filtro solo lato interfaccia: la RLS di eventi_app non cambia.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:12:02 +02:00
davideandClaude Sonnet 5.5 3bf07049e9 Porta la versione a 1.4.0: «Presenza modificata» + copertura test e docs
Sezione 1.4.0 del CHANGELOG, versione in package.json e package-lock.json,
PROJECT_STATE aggiornato. Il test di presenza-modificata copre ora i confini
delle 6 ore, ogni stato, allenatore inattivo, admin senza slot, doppio ruolo e
ora dell'evento non valida. Documentazione delle notifiche allineata (riga 9,
copertura test, email, DATABASE). Nessuna nuova migration: la m32 e la m33 sono
applicate in produzione (09/10/2026).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:24:11 +02:00
davideandClaude Sonnet 5.5 986098d933 DD-050: «Presenza modificata» anche agli allenatori (m33)
Fine della fase di prova: la funzione avvisa admin e allenatori attivi. Test
aggiornato (l'allenatore ora è tra i destinatari), DD-050, notifiche, allenatore,
DATABASE, CHANGELOG, ROADMAP e PROJECT_STATE allineati. m33 non ancora applicata
in produzione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:06:46 +02:00
davideandClaude Sonnet 5.5 274fed93c5 Implementa DD-050: avviso «Presenza modificata», in prova solo per gli admin
Migration m32: tipo presenza_modificata, funzione e trigger su risposte_presenze
che avvisano gli admin attivi se una risposta cambia a 0-6 ore da una partita o
un allenamento (push, email e centro notifiche). Gli allenatori non la ricevono
finché dura la prova. Applicata in produzione il 09/10/2026.
Test di integrazione e unitario; DD-050, notifiche, DATABASE, CHANGELOG,
ROADMAP e PROJECT_STATE aggiornati (slot g18 ora giocatore).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:54:47 +02:00
davideandClaude Sonnet 5.5 f0e6a3a6cc Documenta DD-050: avviso ad admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
Solo documentazione: nessun codice né migration. Decisione in valutazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 17:47:12 +02:00
33 changed files with 1166 additions and 121 deletions

No files matched your search

+9 -7
View File
@@ -9,15 +9,18 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale
- **Versione:** 1.3.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — il Calendario mostra solo i prossimi
- **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — ogni evento può avere un link
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
@@ -33,9 +36,8 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
slot lo occupa anche in produzione e va liberato da un admin.
- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
presenti.
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
## Note
+46
View File
@@ -13,6 +13,52 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
26/10/2026.
### Aggiunto
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
## [1.4.1] - 2026-10-19
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
Nessuna migration.
### Modificato
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
### Corretto
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
vuoto resta «tutta la rosa».
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
eventi a cui non sei convocato.
## [1.4.0] - 2026-10-09
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
### Aggiunto
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
## [1.3.1] - 2026-10-06
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
+20 -19
View File
@@ -74,13 +74,13 @@ Colonne di `giocatori_squadra` che meritano una nota:
## Eventi e presenze
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout
@@ -122,11 +122,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
- Il `CHECK` sul tipo ammette anche `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
automatici (compleanni compresi) ma non quelli dei pulsanti.
automatici (compleanni e `presenza_modificata` compresi) ma non quelli dei pulsanti. `presenza_modificata` ha `evento_id` NULL (come `admin`): la UNIQUE non si applica e ogni modifica resta una riga (M32, DD-050).
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
@@ -152,16 +152,17 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
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. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) 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, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
| `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). | — |
| 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. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) 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, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
| `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). | — |
## Badge
+2
View File
@@ -49,6 +49,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
## Prossimo
+73
View File
@@ -0,0 +1,73 @@
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
**Data:** 8 ottobre 2026
**Stato:** Implementata per admin e allenatori (migration `m32`, solo admin, applicata in produzione il 9 ottobre 2026; `m33`, anche allenatori)
**Contesto**
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
**Decisione**
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
evento). Scatta per **qualsiasi
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
modifica la fa un admin per conto suo.
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
ricevere notifiche, perché `notifiche_utente` è per slot.
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
notifiche e cinque email.
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
**Fase di prova (conclusa)**
La `m32` (9 ottobre 2026) l'ha attivata per i soli admin, per vedere in pratica quante notifiche genera. Dopo la
prova la `m33` (stesso giorno) ha aggiunto `'allenatore'` alla lista dei ruoli in `avvisa_presenza_modificata()`:
ora vale la regola del punto 2.
**Alternative scartate**
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
**Conseguenze**
- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
`notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026.
- L'allenatore, che prima non riceveva nulla sulle presenze, riceve questa notifica: aggiunta a
[allenatore.md](../modules/allenatore.md#notifiche).
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
per evento, non togliere l'avviso.
**Riesame**
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
sono troppe o poche, spostare la soglia (una costante nella funzione).
+50 -49
View File
@@ -46,55 +46,56 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
**Accettate**
| ID | Titolo |
| ------------------- | ------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
| ID | Titolo |
| ------------------- | --------------------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
**In valutazione**
+1
View File
@@ -133,6 +133,7 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
| 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. |
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
| 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
+7
View File
@@ -53,6 +53,13 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`.
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
luogo, e né la card né le notifiche lo mostrano.
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
+1 -1
View File
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **Fa eccezione il turno palloni** (DD-040, dalla
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), presenza modificata (DD-050, ad admin e allenatori). **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
+17 -14
View File
@@ -44,10 +44,11 @@ quelle del turno palloni**, che restano solo push e in-app: DD-040).
| 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 |
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
Le notifiche generate dal server (righe 1-6 e 8) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
Le notifiche generate dal server (righe 1-6, 8 e 9) 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 (4, 5 e 8; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
automatiche (4, 5, 8 e 9; la 1 e la 2 non esistono più) 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»).
@@ -75,6 +76,7 @@ disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `G
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
@@ -102,7 +104,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
- 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.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
@@ -158,17 +160,18 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
### Test che verificano il catalogo
| Cosa | Test |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): 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`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
| 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 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.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`) |
| Cosa | Test |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): 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`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
| 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 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
| Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
| 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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "crapp",
"version": "1.3.1",
"version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crapp",
"version": "1.3.1",
"version": "1.4.2",
"dependencies": {
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.3.1",
"version": "1.4.2",
"private": true,
"sideEffects": false,
"type": "module",
+4 -3
View File
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store";
@@ -75,7 +75,8 @@ export function EventoCard({
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter(inRosa);
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -187,7 +188,7 @@ export function EventoCard({
</p>
) : null}
{io ? (
{io && eConvocato(evento, io.id) ? (
<div
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group"
+7 -3
View File
@@ -6,7 +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 { rosaConvocata, type EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
@@ -20,9 +20,12 @@ export function RosaPresenze({
eventoId,
data,
tipo = "allenamento",
convocati = [],
}: {
eventoId: string;
data: string;
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
convocati?: string[];
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo;
}) {
@@ -34,7 +37,8 @@ export function RosaPresenze({
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi();
const rosa = useAnagraficaRosa();
const anagrafica = useAnagraficaRosa();
const rosa = rosaConvocata(anagrafica, convocati);
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
@@ -97,7 +101,7 @@ export function RosaPresenze({
</span>
</div>
{io ? (
{io && rosa.some((g) => g.id === io.id) ? (
<div className="mt-4 border-t border-border pt-3">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
La tua risposta
+3
View File
@@ -145,6 +145,7 @@ export type Database = {
data: string;
id: string;
luogo: string;
link_maps: string | null;
note: string;
ora: string;
pagelle_chiuse: boolean;
@@ -160,6 +161,7 @@ export type Database = {
data: string;
id: string;
luogo?: string;
link_maps?: string | null;
note?: string;
ora?: string;
pagelle_chiuse?: boolean;
@@ -175,6 +177,7 @@ export type Database = {
data?: string;
id?: string;
luogo?: string;
link_maps?: string | null;
note?: string;
ora?: string;
pagelle_chiuse?: boolean;
+14
View File
@@ -0,0 +1,14 @@
import { eventoVisibile, useEventi } from "./eventi";
import { usePuoGestireEventi } from "./ruoli";
import { useGiocatoreId } from "./user-store";
/** Gli eventi che l'utente corrente può vedere (calendario, home, elenco). */
export function useEventiVisibili() {
const { eventi, ...resto } = useEventi();
const giocatoreId = useGiocatoreId();
const vedeTutto = usePuoGestireEventi();
return {
...resto,
eventi: eventi.filter((e) => eventoVisibile(e, giocatoreId, vedeTutto)),
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { daRiga, type Evento, type RigaEvento } from "./eventi";
const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
/** Lettura eventi lato server (route API): stessa conversione del client. */
export async function leggiEventi(): Promise<Evento[]> {
+44 -3
View File
@@ -9,6 +9,8 @@ export type Evento = {
tipo: EventoTipo;
titolo: string;
luogo: string;
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
linkMaps?: string | undefined;
data: string;
ora: string;
note: string;
@@ -30,6 +32,7 @@ export type RigaEvento = {
tipo: string;
titolo: string;
luogo: string;
link_maps?: string | null;
data: string;
ora: string;
note: string | null;
@@ -47,6 +50,7 @@ export function daRiga(r: RigaEvento): Evento {
tipo: (r.tipo as EventoTipo) ?? "evento",
titolo: r.titolo,
luogo: r.luogo ?? "",
linkMaps: r.link_maps ?? "",
data: r.data,
ora: r.ora ?? "",
note: r.note ?? "",
@@ -59,7 +63,7 @@ export function daRiga(r: RigaEvento): Evento {
}
const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
@@ -76,6 +80,17 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
return { tipo: c, campionato: false };
}
/**
* Il link è accettato solo se punta a Google Maps (stesso criterio del CHECK su
* `eventi_app.link_maps`): è un URL che gli utenti toccano, quindi niente `javascript:` né siti terzi.
*/
const LINK_MAPS =
/^https:\/\/(maps\.app\.goo\.gl\/|goo\.gl\/maps\/|maps\.google\.[a-z.]+\/|(www\.)?google\.[a-z.]+\/maps)\S*$/;
export function linkMapsValido(url: string): boolean {
return url.length <= 500 && LINK_MAPS.test(url);
}
export const EVENTI_KEY = ["eventi"] as const;
async function fetchEventi(): Promise<Evento[]> {
@@ -90,6 +105,25 @@ export function useEventi() {
return { ...query, eventi: query.data ?? [] };
}
/** Il giocatore è tra i convocati (elenco vuoto = tutta la rosa). */
export function eConvocato(evento: Pick<Evento, "convocati">, giocatoreId: string): boolean {
return evento.convocati.length === 0 || evento.convocati.includes(giocatoreId);
}
/**
* Chi vede un evento: i convocati; se `convocati` è vuoto, tutta la rosa. Chi gestisce gli
* eventi (admin e allenatori) li vede sempre, convocato o no.
*/
export function eventoVisibile(
evento: Pick<Evento, "convocati">,
giocatoreId: string | null,
vedeTutto: boolean,
): boolean {
if (vedeTutto) return true;
if (evento.convocati.length === 0) return true;
return giocatoreId !== null && eConvocato(evento, giocatoreId);
}
export function useEvento(id: string) {
const { eventi, ...resto } = useEventi();
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
@@ -105,6 +139,7 @@ export function eventoVuoto(): Evento {
tipo: "allenamento",
titolo: "",
luogo: "",
linkMaps: "",
data: new Date().toISOString().slice(0, 10),
ora: "20:30",
note: "",
@@ -125,6 +160,7 @@ export function useSalvaEvento() {
tipo: evento.tipo,
titolo: evento.titolo,
luogo: evento.luogo,
link_maps: evento.linkMaps?.trim() || null,
data: evento.data,
ora: evento.ora,
note: evento.note,
@@ -198,6 +234,11 @@ export function compleanniEventi(
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
if (!evento || evento.convocati.length === 0) return rosa;
return rosa.filter((g) => evento.convocati.includes(g.id));
return rosaConvocata(rosa, evento?.convocati ?? []);
}
/** Restringe una rosa ai convocati indicati: elenco vuoto = tutta la rosa. */
export function rosaConvocata<T extends { id: string }>(rosa: T[], convocati: string[]): T[] {
if (convocati.length === 0) return rosa;
return rosa.filter((g) => convocati.includes(g.id));
}
+2 -1
View File
@@ -26,7 +26,8 @@ export type TipoNotifica =
| "sollecita_presenze_6h"
| "sondaggio_cacche"
| "compleanno"
| "compleanno_auguri";
| "compleanno_auguri"
| "presenza_modificata";
export type NotificaUtente = {
id: string;
+16 -1
View File
@@ -75,6 +75,16 @@ function AllenamentoDetail() {
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div>
{evento.note ? (
@@ -93,7 +103,12 @@ function AllenamentoDetail() {
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
<RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section>
</>
);
+3 -2
View File
@@ -8,7 +8,8 @@ import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { compleanniEventi, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useAnagraficaRosa } from "@/lib/rosa";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
@@ -56,7 +57,7 @@ function Calendario() {
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false);
const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi();
const { eventi } = useEventiVisibili();
const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
+26 -3
View File
@@ -1,3 +1,4 @@
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
@@ -21,8 +22,8 @@ import {
categoriaEvento,
daCategoria,
eventoVuoto,
linkMapsValido,
useEliminaEvento,
useEventi,
useSalvaEvento,
type CategoriaEvento,
type Evento,
@@ -65,7 +66,7 @@ function GestioneEventi() {
const io = useGiocatoreBase();
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
const puoGestire = usePuoGestireEventi();
const { eventi, isPending, isError, error, refetch } = useEventi();
const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento();
const elimina = useEliminaEvento();
@@ -111,6 +112,12 @@ function GestioneEventi() {
toast.error("Serve un titolo per l'evento");
return;
}
if ((bozza.linkMaps ?? "").trim() && !linkMapsValido(bozza.linkMaps!.trim())) {
toast.error(
"Il link deve essere di Google Maps (https://maps.app.goo.gl/… o google.com/maps…)",
);
return;
}
if (modificaEsistente) {
setConfermaModifica(true);
return;
@@ -121,7 +128,11 @@ function GestioneEventi() {
async function conferma() {
if (!bozza) return;
try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
await salva.mutateAsync({
...bozza,
titolo: bozza.titolo.trim(),
linkMaps: (bozza.linkMaps ?? "").trim(),
});
toast.success("Evento salvato");
setBozza(null);
} catch {
@@ -373,6 +384,18 @@ function GestioneEventi() {
/>
</Campo>
<Campo label="Link Google Maps (facoltativo)">
<input
type="url"
inputMode="url"
value={bozza.linkMaps ?? ""}
maxLength={500}
placeholder="Incolla il link da Condividi → Copia link"
onChange={(e) => aggiorna({ linkMaps: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Note">
<textarea
value={bozza.note}
+16 -1
View File
@@ -72,6 +72,16 @@ function EventoDetail() {
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div>
{evento.note ? (
@@ -88,7 +98,12 @@ function EventoDetail() {
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
<RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section>
</>
);
+4 -3
View File
@@ -11,7 +11,8 @@ import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { eConvocato, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa";
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
@@ -47,14 +48,14 @@ function Index() {
const base = useGiocatoreBase();
const giocatore = useIo();
const allenatore = isAllenatore(base);
const { eventi } = useEventi();
const { eventi } = useEventiVisibili();
const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null;
const daConfermare = prossimi
.slice(1)
.filter((e) => !giocatore || !presenze[e.id]?.[giocatore.id]);
.filter((e) => !giocatore || (eConvocato(e, giocatore.id) && !presenze[e.id]?.[giocatore.id]));
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const { data: csi } = useCsi();
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
+16 -1
View File
@@ -133,6 +133,16 @@ function PartitaDetail() {
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div>
{evento.note ? (
@@ -297,7 +307,12 @@ function PartitaDetail() {
) : null}
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
<RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section>
</>
);
@@ -0,0 +1,140 @@
-- M32 — Avviso se una presenza cambia a meno di 6 ore dall'evento (DD-050)
--
-- Quando una riga di `risposte_presenze` cambia (nuova, modificata o ritirata) e mancano da 0 a 6 ore
-- all'inizio di una partita o di un allenamento, ai destinatari arriva una notifica «Presenza modificata».
--
-- FASE DI PROVA: i destinatari sono solo gli admin (DD-050, «Fase di prova»). Per aprirla agli allenatori
-- basta aggiungere 'allenatore' alla lista dei ruoli qui sotto, con una nuova migration.
--
-- Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'email, quello delle push
-- (esteso qui con il tipo nuovo) accoda la push, la riga è il centro notifiche in-app (DD-038).
-- --- Tipo 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',
'compleanno',
'compleanno_auguri',
'presenza_modificata'
)
);
-- --- Funzione e trigger ------------------------------------------------------------------------
--
-- `evento_id` resta NULL (come i messaggi `admin`): la UNIQUE (giocatore_id, evento_id, tipo) non si
-- applica e due modifiche dello stesso evento restano due notifiche distinte.
--
-- Cancellare un evento elimina le risposte con un trigger AFTER DELETE su `eventi_app` (M14): quando
-- scatta questo, l'evento non esiste più, quindi non parte nessun avviso.
CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END;
giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END;
prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END;
dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END;
ev record;
chi record;
BEGIN
IF prima IS NOT DISTINCT FROM dopo THEN
RETURN NULL;
END IF;
SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio
INTO ev
FROM public.eventi_app e
WHERE e.id = evento_id_r;
IF NOT FOUND
OR ev.tipo NOT IN ('partita', 'allenamento')
OR ev.inizio IS NULL
OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN
RETURN NULL;
END IF;
SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo
INTO chi
FROM public.giocatori_squadra g
WHERE g.id = giocatore_r;
IF NOT FOUND THEN
RETURN NULL;
END IF;
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
SELECT d.id,
'presenza_modificata',
'Presenza modificata: ' || chi.nome_completo,
'Evento: ' || ev.titolo
|| E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora
|| E'\nDa: ' || public.nome_stato_presenza(prima)
|| E'\nA: ' || public.nome_stato_presenza(dopo)
FROM public.giocatori_squadra d
WHERE d.attivo = true
AND d.id <> giocatore_r
AND EXISTS (
SELECT 1
FROM public.user_roles ur
WHERE ur.user_id = d.auth_user_id
AND ur.role IN ('admin')
);
RETURN NULL;
END;
$$;
REVOKE ALL ON FUNCTION public.avvisa_presenza_modificata() FROM PUBLIC, anon, authenticated;
CREATE OR REPLACE FUNCTION public.nome_stato_presenza(p_stato text)
RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT CASE WHEN p_stato = 'ritardo' THEN 'in ritardo' ELSE coalesce(p_stato, 'nessuna risposta') END;
$$;
CREATE TRIGGER risposte_presenze_avvisa_modifica
AFTER INSERT OR UPDATE OF stato OR DELETE ON public.risposte_presenze
FOR EACH ROW
EXECUTE FUNCTION public.avvisa_presenza_modificata();
-- --- Push: anche la presenza modificata --------------------------------------------------------
--
-- Stesso trigger di M31 con il tipo nuovo. L'email passa già dal trigger di M22/M26.
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',
'compleanno',
'compleanno_auguri',
'presenza_modificata'
))
EXECUTE FUNCTION public.accoda_push_promemoria();
@@ -0,0 +1,66 @@
-- M33 — «Presenza modificata» anche agli allenatori (DD-050)
--
-- Fine della fase di prova della M32: l'avviso arriva agli admin e agli allenatori attivi (ruolo in
-- `user_roles` e slot attivo), tranne il giocatore a cui appartiene la presenza. Cambia solo la lista
-- dei ruoli nella funzione; trigger, tipo di notifica e code push/email restano quelli della M32.
CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END;
giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END;
prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END;
dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END;
ev record;
chi record;
BEGIN
IF prima IS NOT DISTINCT FROM dopo THEN
RETURN NULL;
END IF;
SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio
INTO ev
FROM public.eventi_app e
WHERE e.id = evento_id_r;
IF NOT FOUND
OR ev.tipo NOT IN ('partita', 'allenamento')
OR ev.inizio IS NULL
OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN
RETURN NULL;
END IF;
SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo
INTO chi
FROM public.giocatori_squadra g
WHERE g.id = giocatore_r;
IF NOT FOUND THEN
RETURN NULL;
END IF;
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
SELECT d.id,
'presenza_modificata',
'Presenza modificata: ' || chi.nome_completo,
'Evento: ' || ev.titolo
|| E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora
|| E'\nDa: ' || public.nome_stato_presenza(prima)
|| E'\nA: ' || public.nome_stato_presenza(dopo)
FROM public.giocatori_squadra d
WHERE d.attivo = true
AND d.id <> giocatore_r
AND EXISTS (
SELECT 1
FROM public.user_roles ur
WHERE ur.user_id = d.auth_user_id
AND ur.role IN ('admin', 'allenatore')
);
RETURN NULL;
END;
$$;
@@ -0,0 +1,16 @@
-- M34 — Link Google Maps del luogo di un evento
--
-- Colonna facoltativa: NULL = nessun link. Il CHECK accetta solo URL https di Google Maps (stesso
-- criterio di `linkMapsValido()` in src/lib/eventi.ts), perché il link è mostrato a tutta la squadra
-- come pulsante. Chi lo scrive è già limitato ad admin e allenatori dalle policy di `eventi_app` (M11).
ALTER TABLE public.eventi_app
ADD COLUMN link_maps text,
ADD CONSTRAINT eventi_app_link_maps_google
CHECK (
link_maps IS NULL
OR (
length(link_maps) <= 500
AND link_maps ~ '^https://(maps\.app\.goo\.gl/|goo\.gl/maps/|maps\.google\.[a-z.]+/|(www\.)?google\.[a-z.]+/maps)\S*$'
)
);
+6 -6
View File
@@ -24,12 +24,12 @@ 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 (calcolo e card di dettaglio), avvisi in Home, 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-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) 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), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), 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 (calcolo e card di dettaglio), avvisi in Home, 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-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) 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), l'avviso «Presenza modificata» ad admin e allenatori con finestra di 6 ore, stati e nessun avviso a cancellazione dell'evento (`presenza-modificata`, M32-M33, DD-050), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), 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
@@ -0,0 +1,440 @@
/**
* Avviso «Presenza modificata» (M32 e M33, DD-050): `bun test/integration/presenza-modificata.test.ts`.
*
* Scrive su `risposte_presenze` come farebbe l'app e verifica, sul database vero:
*
* - una risposta nuova, modificata o ritirata a meno di 6 ore da una partita o da un allenamento
* avvisa gli admin e gli allenatori attivi, con titolo e corpo del catalogo;
* - riceve l'avviso chi ha il ruolo admin o allenatore con uno slot attivo (M33); non riceve nulla un giocatore semplice, e non riceve
* nulla l'admin a cui appartiene la presenza;
* - nessun avviso se lo stato non cambia, se l'evento è oltre le 6 ore, già iniziato, o di un tipo che
* non è partita né allenamento, né quando una risposta sparisce perché l'evento è stato cancellato;
* - ogni modifica è una notifica a sé (nessun raggruppamento) e ha la coda email e la coda push.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4` (chi risponde), `g5` (admin),
* `g6` (giocatore), `g9` (allenatore inattivo) e `g10` (allenatore) della rosa seed, ripristinati 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("presenza modificata", "stack locale non attivo (npx supabase start)");
riepilogo("presenza-modificata");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`presenza modificata su ${URL_BASE}`);
const PASSWORD = "prova-presenza-modificata-123";
const RISPONDE = "g4";
const ADMIN = "g5";
const GIOCATORE = "g6";
const ALLENATORE = "g10";
const INATTIVO = "g9";
const PREFISSO = "test-presenza-mod-";
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 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 (AAAA-MM-GG) e ora (HH:MM) a Roma, tra `ore` ore da adesso. */
function traOre(ore: number): { data: string; ora: string } {
const parti: Record<string, string> = Object.fromEntries(
new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(Date.now() + ore * 3_600_000))
.map((p) => [p.type, p.value]),
);
return {
data: `${parti["year"]}-${parti["month"]}-${parti["day"]}`,
ora: `${parti["hour"]}:${parti["minute"]}`,
};
}
type Riga = { id: string; giocatore_id: string; titolo: string; corpo: string };
const notifiche = () =>
leggi<Riga>(
"notifiche_utente?tipo=eq.presenza_modificata&select=id,giocatore_id,titolo,corpo&order=creato_il",
);
const svuota = async () => {
await rest("notifiche_utente?tipo=eq.presenza_modificata", SERVIZIO, { method: "DELETE" });
};
const risposta = (evento: string, stato: string) =>
scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", {
evento_id: evento,
giocatore_id: RISPONDE,
stato,
});
const aggiorna = (evento: string, stato: string, giocatore = RISPONDE) =>
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", {
stato,
});
const ritira = (evento: string) =>
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${RISPONDE}`, "DELETE");
async function creaEvento(id: string, tipo: string, ore: number, titolo = `Prova ${id}`) {
const { data, ora } = traOre(ore);
await scrivi("eventi_app", "POST", { id, tipo, titolo, data, ora });
return { data, ora, titolo };
}
let tokenAdmin = "";
const idUtenti: string[] = [];
const idEventi: string[] = [];
const originali = new Map<string, Record<string, unknown>>();
try {
const admin = await creaUtente(`${PREFISSO}admin-${Date.now()}@example.test`);
const allenatore = await creaUtente(`${PREFISSO}allenatore-${Date.now()}@example.test`);
const inattivo = await creaUtente(`${PREFISSO}inattivo-${Date.now()}@example.test`);
const senzaSlot = await creaUtente(`${PREFISSO}senza-slot-${Date.now()}@example.test`);
idUtenti.push(admin.id, allenatore.id, inattivo.id, senzaSlot.id);
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [RISPONDE, ADMIN, GIOCATORE, 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.${ADMIN}`,
"PATCH",
{ auth_user_id: admin.id },
tokenAdmin,
);
await scrivi(
`giocatori_squadra?id=eq.${ALLENATORE}`,
"PATCH",
{ tipo: "allenatore", auth_user_id: allenatore.id },
tokenAdmin,
);
// Un allenatore con slot disattivato, un admin senza nessuno slot e un admin che ha anche il
// ruolo allenatore: nessuno dei primi due può ricevere, il terzo riceve una sola notifica.
await scrivi(
`giocatori_squadra?id=eq.${INATTIVO}`,
"PATCH",
{ tipo: "allenatore", auth_user_id: inattivo.id, attivo: false },
tokenAdmin,
);
await scrivi("user_roles", "POST", { user_id: senzaSlot.id, role: "admin" });
await scrivi("user_roles", "POST", { user_id: admin.id, role: "allenatore" });
await svuota();
const [chi] = await leggi<{ nome: string; cognome: string }>(
`giocatori_squadra?id=eq.${RISPONDE}&select=nome,cognome`,
);
const nomeCompleto = `${chi!.nome} ${chi!.cognome}`.trim();
// Il seed può avere altri admin con uno slot attivo: l'elenco atteso lo calcola il test.
const attesi = async () => {
const ruoli = await leggi<{ user_id: string }>(
"user_roles?role=in.(admin,allenatore)&select=user_id",
);
const slot = await leggi<{ id: string; auth_user_id: string }>(
"giocatori_squadra?attivo=eq.true&auth_user_id=not.is.null&select=id,auth_user_id",
);
const idAdmin = new Set(ruoli.map((r) => r.user_id));
return slot
.filter((s) => idAdmin.has(s.auth_user_id) && s.id !== RISPONDE)
.map((s) => s.id)
.sort();
};
// --- Partita a 3 ore ---------------------------------------------------------------------
const ev = await creaEvento(`${PREFISSO}partita`, "partita", 3, "Partita di prova");
idEventi.push(`${PREFISSO}partita`);
const dataIt = `${ev.data.slice(8, 10)}/${ev.data.slice(5, 7)}/${ev.data.slice(0, 4)}`;
await prova(
"risposta nuova: avvisa admin e allenatori, con il testo del catalogo",
async () => {
await risposta(`${PREFISSO}partita`, "presente");
const righe = await notifiche();
assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), await attesi());
assert.ok(
righe.some((r) => r.giocatore_id === ADMIN),
"l'admin di prova è tra i destinatari",
);
const mia = righe.find((r) => r.giocatore_id === ADMIN)!;
assert.equal(mia.titolo, `Presenza modificata: ${nomeCompleto}`);
assert.equal(
mia.corpo,
[
`Evento: ${ev.titolo}`,
`Data: ${dataIt}, ore ${ev.ora}`,
"Da: nessuna risposta",
"A: presente",
].join("\n"),
);
},
);
await prova("il giocatore semplice non riceve nulla, l'allenatore sì", async () => {
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
assert.ok(!destinatari.includes(GIOCATORE));
assert.ok(destinatari.includes(ALLENATORE), "l'allenatore attivo è tra i destinatari");
assert.ok(!destinatari.includes(RISPONDE), "chi risponde non avvisa se stesso");
});
await prova("la riga ha la coda email e la coda push", async () => {
const [mia] = (await notifiche()).filter((r) => r.giocatore_id === ADMIN);
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=eq.${mia!.id}&select=notifica_id`,
);
assert.equal(email.length, 1, "email in coda");
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=eq.${mia!.id}&select=notifica_id`,
);
assert.equal(push.length, 1, "push in coda");
});
await prova("modifica: ogni cambio è una notifica a sé, con Da e A giusti", async () => {
const prima = (await notifiche()).length;
await aggiorna(`${PREFISSO}partita`, "ritardo");
const righe = await notifiche();
assert.equal(righe.length, prima + (await attesi()).length);
const ultima = righe.filter((r) => r.giocatore_id === ADMIN).at(-1)!;
assert.match(ultima.corpo, /\nDa: presente\nA: in ritardo$/);
});
await prova("stesso stato di prima: nessun avviso", async () => {
const prima = (await notifiche()).length;
await aggiorna(`${PREFISSO}partita`, "ritardo");
assert.equal((await notifiche()).length, prima);
});
await prova("ritiro della risposta: «nessuna risposta» come stato finale", async () => {
await ritira(`${PREFISSO}partita`);
const ultima = (await notifiche()).filter((r) => r.giocatore_id === ADMIN).at(-1)!;
assert.match(ultima.corpo, /\nDa: in ritardo\nA: nessuna risposta$/);
});
await prova("la presenza dell'admin non avvisa l'admin stesso", async () => {
await svuota();
await scrivi("risposte_presenze", "POST", {
evento_id: `${PREFISSO}partita`,
giocatore_id: ADMIN,
stato: "assente",
});
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
assert.ok(!destinatari.includes(ADMIN));
await scrivi(
`risposte_presenze?evento_id=eq.${PREFISSO}partita&giocatore_id=eq.${ADMIN}`,
"DELETE",
);
});
// --- Casi in cui non deve partire nulla --------------------------------------------------
await prova("allenamento a 5 ore: avvisa", async () => {
await svuota();
await creaEvento(`${PREFISSO}allenamento`, "allenamento", 5);
idEventi.push(`${PREFISSO}allenamento`);
await risposta(`${PREFISSO}allenamento`, "assente");
assert.equal((await notifiche()).length, (await attesi()).length);
});
for (const [nome, tipo, ore] of [
["oltre le 6 ore", "partita", 7],
["già iniziato", "partita", -1],
["un tipo che non è partita né allenamento", "evento", 3],
] as const) {
await prova(`nessun avviso: ${nome}`, async () => {
await svuota();
const id = `${PREFISSO}no-${idEventi.length}`;
await creaEvento(id, tipo, ore);
idEventi.push(id);
await risposta(id, "presente");
assert.equal((await notifiche()).length, 0);
});
}
await prova("cancellare l'evento elimina le risposte senza avvisare nessuno", async () => {
await svuota();
const id = `${PREFISSO}cancellato`;
await creaEvento(id, "partita", 2);
idEventi.push(id);
await risposta(id, "presente");
await svuota();
await scrivi(`eventi_app?id=eq.${id}`, "DELETE");
assert.equal((await notifiche()).length, 0);
});
await prova(
"destinatari: né inattivi né senza slot, e una sola notifica a chi ha due ruoli",
async () => {
await svuota();
const id = `${PREFISSO}destinatari`;
await creaEvento(id, "partita", 4);
idEventi.push(id);
await risposta(id, "presente");
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
assert.ok(!destinatari.includes(INATTIVO), "slot disattivato");
assert.deepEqual([...destinatari].sort(), await attesi());
assert.equal(
destinatari.filter((d) => d === ADMIN).length,
1,
"chi ha sia il ruolo admin sia allenatore riceve una sola notifica",
);
},
);
await prova(
"cambio di un giocatore: avvisati admin e allenatore, mai il giocatore stesso",
async () => {
await svuota();
const id = `${PREFISSO}per-conto`;
await creaEvento(id, "partita", 2);
idEventi.push(id);
await risposta(id, "presente");
await aggiorna(id, "assente");
const destinatari = new Set((await notifiche()).map((r) => r.giocatore_id));
assert.ok(destinatari.has(ALLENATORE) && destinatari.has(ADMIN));
assert.ok(!destinatari.has(RISPONDE));
},
);
await prova("tutti gli stati hanno il loro nome nel testo", async () => {
await svuota();
const id = `${PREFISSO}stati`;
await creaEvento(id, "allenamento", 1);
idEventi.push(id);
const sequenza = ["presente", "assente", "forse", "ritardo", "infortunato"];
await risposta(id, sequenza[0]!);
for (const stato of sequenza.slice(1)) await aggiorna(id, stato);
const corpi = (await notifiche())
.filter((r) => r.giocatore_id === ADMIN)
.map((r) => r.corpo.split("\n").slice(2).join("|"));
assert.deepEqual(corpi, [
"Da: nessuna risposta|A: presente",
"Da: presente|A: assente",
"Da: assente|A: forse",
"Da: forse|A: in ritardo",
"Da: in ritardo|A: infortunato",
]);
});
// Confini della finestra: la soglia è di 6 ore esatte (da 0 a 6).
for (const [nome, ore, avvisa] of [
["a 5 ore e 50 minuti", 5 + 50 / 60, true],
["a 6 ore e 10 minuti", 6 + 10 / 60, false],
["a 10 minuti", 10 / 60, true],
["iniziato da 10 minuti", -10 / 60, false],
] as const) {
await prova(`finestra: evento ${nome} ${avvisa ? "avvisa" : "non avvisa"}`, async () => {
await svuota();
const id = `${PREFISSO}soglia-${idEventi.length}`;
await creaEvento(id, "partita", ore);
idEventi.push(id);
await risposta(id, "presente");
assert.equal((await notifiche()).length > 0, avvisa);
});
}
await prova("ora dell'evento illeggibile: nessun avviso e nessun errore", async () => {
await svuota();
const id = `${PREFISSO}ora-illeggibile`;
const { data } = traOre(3);
await scrivi("eventi_app", "POST", {
id,
tipo: "partita",
titolo: "Ora strana",
data,
ora: "boh",
});
idEventi.push(id);
await risposta(id, "presente");
assert.equal((await notifiche()).length, 0);
});
await prova("la funzione non è chiamabile dai client", async () => {
for (const token of [ANON, admin.token]) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/avvisa_presenza_modificata`, {
method: "POST",
headers: {
apikey: ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: "{}",
});
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterla eseguire`);
}
});
} finally {
await svuota();
for (const id of idEventi) {
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("presenza-modificata");
}
}
+80
View File
@@ -7,7 +7,11 @@ import {
convocatiEvento,
daCategoria,
daRiga,
eConvocato,
eventoVisibile,
rosaConvocata,
eventoVuoto,
linkMapsValido,
type RigaEvento,
} from "@/lib/eventi";
@@ -32,6 +36,7 @@ assert.deepEqual(daRiga(riga), {
tipo: "partita",
titolo: "CRAP - Avversari",
luogo: "PalaCRAP",
linkMaps: "",
data: "2026-09-01",
ora: "21:00",
note: "portare la seconda maglia",
@@ -123,3 +128,78 @@ assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
console.log("eventi: ok");
// --- eventoVisibile: i non convocati non vedono l'evento, chi gestisce sì -----
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g1", false), true, "il convocato lo vede");
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", false), false, "il non convocato no");
assert.equal(eventoVisibile({ convocati: ["g1"] }, null, false), false, "senza slot no");
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", true), true, "admin/allenatore sempre");
assert.equal(eventoVisibile({ convocati: [] }, "g2", false), true, "vuoto = tutta la rosa");
// --- eConvocato: elenco vuoto = tutti ----------------------------------------
assert.equal(eConvocato({ convocati: ["g1"] }, "g1"), true, "convocato");
assert.equal(eConvocato({ convocati: ["g1"] }, "g2"), false, "non convocato");
assert.equal(eConvocato({ convocati: [] }, "g2"), true, "vuoto = tutti");
// --- Copertura per numero di convocati: 1, 2, 3, tutti, nessuno, id sconosciuti -
const squadra = ["g1", "g2", "g3", "g4"].map((id) => ({ id }));
const ids = (l: Array<{ id: string }>) => l.map((g) => g.id);
const casi: Array<{ nome: string; convocati: string[]; attesi: string[] }> = [
{ nome: "nessuno indicato = tutta la rosa", convocati: [], attesi: ["g1", "g2", "g3", "g4"] },
{ nome: "solo 1", convocati: ["g2"], attesi: ["g2"] },
{ nome: "2", convocati: ["g1", "g4"], attesi: ["g1", "g4"] },
{ nome: "3", convocati: ["g3", "g1", "g2"], attesi: ["g1", "g2", "g3"] },
{ nome: "tutti indicati", convocati: ["g1", "g2", "g3", "g4"], attesi: ["g1", "g2", "g3", "g4"] },
{ nome: "id sconosciuto ignorato", convocati: ["g2", "zz"], attesi: ["g2"] },
{ nome: "solo ids sconosciuti: nessuno", convocati: ["zz"], attesi: [] },
];
for (const { nome, convocati, attesi } of casi) {
assert.deepEqual(ids(rosaConvocata(squadra, convocati)), attesi, `rosaConvocata: ${nome}`);
const conv = { convocati };
for (const { id } of squadra) {
const dentro = attesi.includes(id) || convocati.length === 0;
assert.equal(
eConvocato(conv, id),
convocati.length === 0 || convocati.includes(id),
`eConvocato ${id}: ${nome}`,
);
assert.equal(eventoVisibile(conv, id, false), dentro, `visibile a ${id}: ${nome}`);
assert.equal(
eventoVisibile(conv, id, true),
true,
`admin/allenatore vede sempre (${id}): ${nome}`,
);
}
assert.equal(eventoVisibile(conv, null, false), convocati.length === 0, `senza slot: ${nome}`);
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
}
assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota");
// Link Google Maps: solo URL https di Google Maps, e vuoto se la riga non ne ha uno.
assert.equal(daRiga(riga).linkMaps, "");
assert.equal(daRiga({ ...riga, link_maps: null }).linkMaps, "");
assert.equal(
daRiga({ ...riga, link_maps: "https://maps.app.goo.gl/abc123" }).linkMaps,
"https://maps.app.goo.gl/abc123",
);
assert.equal(eventoVuoto().linkMaps, "");
for (const ok of [
"https://maps.app.goo.gl/abc123",
"https://goo.gl/maps/xyz",
"https://www.google.com/maps/place/PalaCRAP",
"https://google.it/maps?q=Cesena",
"https://maps.google.com/?q=Cesena",
]) {
assert.equal(linkMapsValido(ok), true, ok);
}
for (const ko of [
"",
"javascript:alert(1)",
"http://maps.app.goo.gl/abc123",
"https://example.com/maps",
"https://maps.app.goo.gl.evil.com/abc",
"https://www.google.com/search?q=maps",
"https://maps.app.goo.gl/a b",
]) {
assert.equal(linkMapsValido(ko), false, ko);
}
+1
View File
@@ -61,6 +61,7 @@ const tipiNuovi: TipoNotifica[] = [
"sollecita_presenze_6h",
"compleanno",
"compleanno_auguri",
"presenza_modificata",
];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) {
+32
View File
@@ -294,3 +294,35 @@ assert.equal(
);
console.log("presenze: ok");
// --- Solleciti per numero di convocati: 1, 2, 3, tutti ------------------------
{
const sq = (["g1", "g2", "g3", "g4"] as const).map((id) => ({
id,
attivo: true,
tipo: "giocatore" as const,
}));
const nessuna: Array<{ giocatore_id: string; stato: string }> = [];
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g2"]), ["g2"], "solo 1 convocato");
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g1", "g3"]), ["g1", "g3"], "2 convocati");
assert.deepEqual(
destinatariSollecito(sq, nessuna, ["g1", "g2", "g4"]),
["g1", "g2", "g4"],
"3 convocati",
);
assert.deepEqual(
destinatariSollecito(sq, [{ giocatore_id: "g1", stato: "presente" }], ["g1", "g3"]),
["g3"],
"il convocato che ha già risposto non va sollecitato",
);
assert.deepEqual(
destinatariSollecito(sq, [{ giocatore_id: "g4", stato: "presente" }], ["g1"]),
["g1"],
"chi ha risposto ma non è convocato non cambia i destinatari",
);
assert.deepEqual(
destinatariSollecito(sq, nessuna, []),
["g1", "g2", "g3", "g4"],
"nessuno = tutti",
);
}