18 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 9d047a6ecd Documentazione: DD-051 e DD-052, modulo MVP, database e changelog
Voto MVP definitivo, a pari merito, segreto e a tempo; nome del votato dalla rosa. PROJECT_STATE
segna che m35-m38 restano da applicare su Supabase (db push rinviato) e il lavoro su feature/gara.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:59:40 +01:00
davideandClaude Sonnet 5.5 5fed105850 Test del voto MVP: regole a database, segretezza, nome dalla rosa, chiusura
Integrazione (permessi, permessi-allenatore, mvp-badge, s-tiebreak-badge), unit (mvp-voti) e helper
delle date di Roma. Con le parità l'MVP vale per tutti i pari merito.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 127d5cb5b8 Voto MVP: definitivo, a pari merito, segreto e a tempo nell'app
Il pannello chiede conferma e non permette di cambiare il voto; con una parità in testa gli MVP
sono più d'uno; i conteggi vengono dalla vista mvp_voti_anonimi e il proprio voto dalla tabella;
il voto si chiude 24 ore dopo l'apertura.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 e5fac24916 Migration m38: nome del votato dalla rosa anche per i badge social
Stesso difetto dell'MVP: votato_nome lo scriveva il client. Riusa il trigger della m37.
Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 b17605ae76 Migration m37: voto MVP segreto, nome dalla rosa, chiusura a 24 ore
Lettura diretta di mvp_voti solo del votante e degli admin, conteggi dalla vista
mvp_voti_anonimi; trigger che riempie votato_nome dalla rosa; il voto chiude 24 ore dopo
l'apertura. Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 92e7195357 Migration m36: il voto MVP solo tra presenti e dopo 2 ore
voto_mvp_consentito() controlla partita, presenza (presente o ritardo) di votante e votato e
l'attesa di 2 ore dall'inizio. Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 215090d297 Migration m35: il voto MVP è definitivo
Il giocatore può solo inserire il proprio voto: UPDATE e DELETE restano agli admin.
Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
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
48 changed files with 2197 additions and 295 deletions

No files matched your search

+24 -14
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 06/10/2026 Ultimo aggiornamento: 26/10/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo [docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
@@ -9,33 +9,43 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## 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` (tag `v1.4.2`): ogni evento può avere un link Google
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0 Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). Su `feature/gara` c'è del lavoro
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home non ancora rilasciato (voto MVP, vedi «Lavoro in corso»). La 1.4.1: gli eventi con convocati si
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). 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 - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a - **Migration:** 49 file in `supabase/migrations/`, fino a `m38_nome_votato_badge_social`. In produzione
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il sono applicate 45 su 49, fino alla `m34_link_maps_evento` (verificato con
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie `npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m38`, nessuna
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato. migration remota è assente dal repository). **Da applicare ancora: `m35`-`m38`**
(voto MVP e badge social, DD-051 e DD-052, branch `feature/gara`): `npx supabase db push` è rinviato
per scelta, non è stato eseguito. Vanno applicate subito prima del rilascio del codice: con le sole
migration il voto MVP dell'app in produzione (1.4.2) smette di funzionare, con il solo codice la
vista dei conteggi non esiste.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **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 ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `main` dalla 1.3.0. è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano - **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018). collega al primo accesso (DD-018).
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in - **Lavoro in corso:** branch `feature/gara` per le funzioni di gara (correzioni e nuove
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». funzioni; in futuro anche lo scouting). Primo lavoro: il voto MVP definitivo, segreto, a tempo e
tra presenti, con i pari merito (DD-051, DD-052). Non ancora in `main`, non rilasciato, versione
invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
## Cose da sapere ## Cose da sapere
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo. - 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 - 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. 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 - Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
presenti.
## Note ## Note
+69
View File
@@ -13,6 +13,75 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
### Modificato
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
può più cambiare. Il database lo impone (solo gli admin possono correggere un voto).
- In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in
Home e Classifica compaiono i nomi uniti («Anna e Bruno»). Resta il minimo di 2 voti per eleggere
un MVP.
- Il voto MVP è segreto ([DD-052](decisions/DD-052.md)): nessuno vede chi ha votato chi, solo i
conteggi e il proprio voto.
- La votazione MVP si chiude 24 ore dopo l'apertura (cioè 26 ore dopo l'inizio della partita): le
partite più vecchie non si votano più.
### Sicurezza
- Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per
una partita, tra presenti (`presente` o `ritardo`) e dopo 2 ore dall'inizio (`m36`,
[DD-051](decisions/DD-051.md)). Gli admin possono ancora correggere un voto.
- Il nome del votato lo scrive il database dalla rosa, per i voti MVP (`m37`) e per i badge social
(`m38`): un client non può più salvare un nome diverso da quello del giocatore votato. In
produzione non risultavano voti MVP, quindi nessun dato da bonificare.
## [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 ## [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 Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
+40 -39
View File
@@ -12,21 +12,21 @@ allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011). sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere | | Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | | `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin | | `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | | `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli. Su `mvp_voti` (`m35`-`m37`, DD-051, DD-052) il giocatore può solo inserire il voto, per una partita aperta al voto (da 2 a 26 ore dopo l'inizio) e tra presenti (`voto_mvp_consentito()`), e leggere solo il proprio; modifica, cancellazione e lettura degli altri voti sono degli admin |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | | `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | | `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) | | `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) | | `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) | | `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. | | `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) | | `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | | `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | | `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) | | `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`. L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale. La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
@@ -74,13 +74,13 @@ Colonne di `giocatori_squadra` che meritano una nota:
## Eventi e presenze ## Eventi e presenze
| Tabella | Scopo | Note | | 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. | | `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. | | `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. | | `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). | | `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). | | `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout ## Scout
@@ -95,11 +95,11 @@ Colonne di `giocatori_squadra` che meritano una nota:
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato, Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027). votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- | | ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. | | `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`; voto definitivo per i giocatori (`m35`). |
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. | | `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. | | `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
## Turni e notifiche ## Turni e notifiche
@@ -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. - 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 - Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039). 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_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali. `sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi - 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`) ### 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 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. [modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` | | 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_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_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_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_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) | | `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. | — | | `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` |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | | `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. | — |
| `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). | — | | `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 ## 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, - [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) 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 ## 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).
+45
View File
@@ -0,0 +1,45 @@
# DD-051 — Voto MVP definitivo e MVP a pari merito
**Data:** 26 ottobre 2026
**Stato:** Accettata (migration `m35` e `m36`)
**Contesto**
Il voto MVP si poteva cambiare senza limiti e senza storico (`mvp.md`), e una parità in testa non
assegnava l'MVP a nessuno finché non arrivavano altri voti. Con voti modificabili la classifica di una
partita poteva ribaltarsi dopo che la squadra l'aveva già vista; e una parità, che a fine serata non si
risolve più perché nessuno vota ancora, lasciava la partita senza MVP per sempre.
**Decisione**
- **Il voto è definitivo.** Il giocatore lo registra una volta sola per partita. L'interfaccia chiede una
conferma prima di inviarlo e non offre più «Cambia voto»; la mutazione è un `INSERT` e un secondo voto
viene rifiutato dal vincolo `UNIQUE (match_id, votante_id)`. A database (`m35`) il giocatore ha solo la
policy di `INSERT`: `UPDATE` e `DELETE` restano agli admin (policy di M11), che possono correggere un
voto sbagliato.
- **Una parità in testa dà più MVP.** `mvpPartita()` restituisce tutti i più votati; ognuno conta un
titolo in `mvpVintiPerGiocatore()`, e dove si mostra un solo nome per partita (Home, Classifica)
i nomi sono uniti («Anna e Bruno»). Il quorum di DD-028 (`VOTI_MINIMI_MVP = 2`) resta: sotto due voti
totali nessuno è MVP.
- **Presenza e orario a database (`m36`).** Il voto MVP richiede, oltre a convocati e non-allenatori, che
l'evento sia una partita, che votante e votato siano `presente` o `ritardo` e che siano passate 2 ore
dall'inizio. Sta in una funzione dedicata, `voto_mvp_consentito()`, perché `evento_permette_voto()` è
condivisa con pagelle e badge social, che non hanno queste regole. Gli admin restano esclusi dai
vincoli.
**Alternative scartate**
- Lasciare il voto modificabile entro una scadenza → serve una scadenza che non esiste (`mvp.md`,
«Evoluzioni possibili») e un cambio di idea tardivo resta possibile.
- Spareggio con un criterio automatico (media pagella, presenze) → introduce regole nascoste che i
giocatori non vedono; il pari merito è il risultato più semplice da spiegare.
**Conseguenze**
- Un voto dato per errore si corregge solo da un admin.
- I voti già dati restano, ma le partite con una parità in testa assegnano ora i titoli: i conteggi MVP
dei giocatori possono salire (badge «MVP» e «Uomo tie-break» compresi).
- Con tre o più pari merito (per esempio tre voti a tre persone diverse) gli MVP sono tre.
**Riesame**
Se i pari merito risultano troppo frequenti, alzare il quorum (DD-028) o introdurre uno spareggio.
+44
View File
@@ -0,0 +1,44 @@
# DD-052 — Voto MVP segreto, nome dal database e votazione che si chiude
**Data:** 26 ottobre 2026
**Stato:** Accettata (migration `m37` e `m38`)
**Contesto**
Dopo DD-051 il voto è definitivo e controllato a database, ma restavano tre difetti: chiunque poteva
leggere da `mvp_voti` chi aveva votato chi; `votato_nome` lo scriveva il client e poteva non
corrispondere a `votato_id` (il titolo andava all'id, il nome mostrato era un altro); e la votazione,
una volta aperta, non si chiudeva mai.
**Decisione**
- **Voto segreto.** La lettura diretta di `mvp_voti` è del solo votante (il proprio voto) e degli admin.
La squadra legge i conteggi dalla vista `mvp_voti_anonimi`, una riga per voto senza `votante_id` e
ordinata per candidato (la sequenza di arrivo non rivela nulla). La vista gira con i permessi del
proprietario: è ciò che le permette di leggere tutte le righe senza esporre chi ha votato. Non è
leggibile senza account.
- **Nome dal database.** Un trigger riempie `votato_nome` da `giocatori_squadra` a partire da
`votato_id`, ignorando quello inviato (anche se omesso, vuoto o diverso; e anche quando un admin corregge
un voto). Se l'id non è in rosa resta il nome ricevuto. Lo stesso trigger vale per i badge social
(`m38`), che avevano lo stesso difetto; le pagelle non hanno un nome del votato.
- **Chiusura.** Il voto si apre 2 ore dopo l'inizio della partita e si chiude 24 ore dopo l'apertura
(cioè 26 ore dopo l'inizio). A database lo impone `voto_mvp_consentito()`; nell'app `votoMvpChiuso()`.
Gli admin restano fuori dal vincolo.
- **Tutte le partite.** Il voto vale per ogni evento di tipo partita, amichevoli comprese (invariato).
**Alternative scartate**
- Filtrare i voti solo nell'app → chi scrive su PostgREST li leggerebbe comunque.
- Togliere la lettura anche agli admin → non potrebbero più correggere un voto sbagliato; restano gli
unici a poter leggere la tabella, e l'app non mostra a nessuno chi ha votato chi.
- Chiudere 24 ore dopo l'inizio della partita (22 ore di voto) → si è scelto 24 ore piene dall'apertura.
**Conseguenze**
- Le partite già chiuse da più di 26 ore non si votano più: chi non aveva votato perde la possibilità.
I voti già dati restano e contano.
- I conteggi restano visibili durante la votazione: chi vota può vedere chi è in testa.
- Un admin che legge la tabella dal database vede ancora chi ha votato chi.
**Riesame**
Se si vuole un voto davvero cieco, nascondere i risultati fino alla chiusura (la chiusura a 24 ore lo
rende possibile).
+52 -49
View File
@@ -46,55 +46,58 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
**Accettate** **Accettate**
| ID | Titolo | | ID | Titolo |
| ------------------- | ------------------------------------------- | | ------------------- | --------------------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable | | [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first | | [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive | | [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first | | [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile | | [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB | | [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli | | [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API | | [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati | | [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo | | [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID | | [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack | | [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB | | [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore | | [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore | | [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email | | [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente | | [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi | | [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion | | [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara | | [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo | | [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate | | [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale | | [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato | | [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati | | [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP | | [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato | | [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app | | [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata | | [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare | | [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario | | [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore | | [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home | | [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail | | [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni | | [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica | | [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app | | [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
| [DD-046](DD-046.md) | Avviso certificati mancanti | | [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo | | [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore | | [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore | | [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni | | [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** **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. | | 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. | | 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. | | 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. | | 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 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 extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`. `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`) 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 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 nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
+38 -19
View File
@@ -2,6 +2,7 @@
**Stato:** implementato **Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx` **File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
**Test:** `test/unit/mvp-voti.test.ts`, `test/integration/permessi.test.ts` (regole del voto), `mvp-badge`, `s-tiebreak-badge`
--- ---
@@ -15,7 +16,10 @@ calcolato a runtime.
## Dati ## Dati
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
partita, sovrascrivibile. partita, **definitivo** (DD-051) e **segreto** (DD-052): la tabella la legge solo chi ha votato (il
proprio voto) e gli admin; la squadra legge i conteggi dalla vista `mvp_voti_anonimi` (`match_id`,
`votato_id`, `votato_nome`, senza `votante_id`). `votato_nome` lo riempie un trigger dalla rosa a
partire da `votato_id`: il nome inviato dal client è ignorato.
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la `match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
@@ -27,44 +31,59 @@ restano nel database ma non vengono più letti da nessuna schermata).
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()` - Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
quando aprirà. Nessun legame con il risultato caricato. quando aprirà; `votoMvpChiuso()` lo chiude `DURATA_VOTO_MVP_ORE` (24) ore dopo l'apertura
(DD-052). A votazione chiusa il pannello mostra «Votazione chiusa» e i risultati, senza il
pulsante «Vota». Nessun legame con il risultato caricato.
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in - Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco. `usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza - `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
limiti, senza storico. tabella solo i voti dell'utente (gli unici che la policy gli lascia), per «Hai votato X».
- `useVotaMvp()` fa un `INSERT`: il voto è definitivo. Il pannello chiede di scegliere un nome e di
confermarlo, poi mostra «Hai votato X: il voto non si può cambiare». A database (`m35`, DD-051) il
giocatore ha solo la policy di `INSERT`: modificare o cancellare un voto è possibile solo agli admin.
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo - Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive `mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle. direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità, - `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti. (DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali - `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza. incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il - `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile (anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home. nel profilo e in home.
--- ---
## Limiti noti ## Limiti noti
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente. - Il voto si chiude 24 ore dopo l'apertura (DD-052). Le partite più vecchie sono quindi chiuse:
chi non aveva votato non può più farlo. Gli admin possono ancora scrivere un voto dal database.
- Gli admin leggono la tabella `mvp_voti` (gli serve per correggere un voto), quindi a livello di
database vedono chi ha votato chi; l'app non lo mostra a nessuno.
- I conteggi sono visibili anche durante la votazione. Nascondere i risultati fino alla chiusura è una
possibile evoluzione.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che - Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico `votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`). vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non = tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti** sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto vale mezzanotte). Sono le stesse
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque. e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella (`m37`); un test unitario li tiene allineati. Gli
partita. admin restano fuori da questi vincoli (possono correggere un voto). La presenza si controlla solo
al momento del voto: se qualcuno cambia risposta dopo, il voto già dato resta.
- Sotto il quorum minimo di voti nessun MVP viene assegnato per quella partita. Una parità in testa,
invece, assegna l'MVP a tutti i pari merito.
- Un voto sbagliato si corregge solo da un admin (dal database): non c'è un'interfaccia per farlo.
--- ---
## Evoluzioni possibili ## Evoluzioni possibili
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita). - Nascondere i risultati finché la votazione non è chiusa.
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
+1 -1
View File
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi ## 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 `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 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 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ì | | 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 | | 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ì | | 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 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 promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
lo stesso evento (vedi «Centro notifiche in-app»). 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.` | | 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 | | 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 | | 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` 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`, (`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 - 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. 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. - 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. - Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore. - La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035). - 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 ### Test che verificano il catalogo
| Cosa | Test | | 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) | | 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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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) |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) | | 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 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 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", "name": "crapp",
"version": "1.3.1", "version": "1.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.3.1", "version": "1.4.2",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.3.1", "version": "1.4.2",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+4 -3
View File
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data"; 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 { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store"; 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. // Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreInCampo(); const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra(); 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 stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter( const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -187,7 +188,7 @@ export function EventoCard({
</p> </p>
) : null} ) : null}
{io ? ( {io && eConvocato(evento, io.id) ? (
<div <div
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")} className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group" role="group"
+7 -3
View File
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra"; import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data"; 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 { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store"; import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
@@ -20,9 +20,12 @@ export function RosaPresenze({
eventoId, eventoId,
data, data,
tipo = "allenamento", tipo = "allenamento",
convocati = [],
}: { }: {
eventoId: string; eventoId: string;
data: 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. */ /** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo; 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). // Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase(); const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi(); const puoSollecitare = usePuoGestireEventi();
const rosa = useAnagraficaRosa(); const anagrafica = useAnagraficaRosa();
const rosa = rosaConvocata(anagrafica, convocati);
const [sollecito, setSollecito] = useState(false); const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi(); const passato = data < dataOggi();
@@ -97,7 +101,7 @@ export function RosaPresenze({
</span> </span>
</div> </div>
{io ? ( {io && rosa.some((g) => g.id === io.id) ? (
<div className="mt-4 border-t border-border pt-3"> <div className="mt-4 border-t border-border pt-3">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
La tua risposta La tua risposta
+82 -44
View File
@@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi";
import { import {
conteggioPartita, conteggioPartita,
mioVoto, mioVoto,
mvpPartita,
ORE_ATTESA_MVP, ORE_ATTESA_MVP,
useMieiVotiMvp,
useVotaMvp, useVotaMvp,
useVotiMvp, useVotiMvp,
VOTI_MINIMI_MVP,
votoMvpAperto, votoMvpAperto,
type VotoMvp, votoMvpChiuso,
DURATA_VOTO_MVP_ORE,
type VotoMvpPubblico,
} from "@/lib/mvp-voti"; } from "@/lib/mvp-voti";
/** /**
* Pannello di votazione MVP di una partita: un voto a testa, modificabile. * Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in
* testa gli MVP sono più d'uno.
* *
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche * I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili * senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
@@ -28,20 +34,22 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo(); const io = useGiocatoreInCampo();
const voti = useVotiMvp(); const voti = useVotiMvp();
const miei = useMieiVotiMvp(io?.id);
const vota = useVotaMvp(); const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const { risposte } = usePresenzeEvento(evento.id); const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo"; const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id)); const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
const [aperto, setAperto] = useState(false); const [aperto, setAperto] = useState(false);
const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null);
const tutti: VotoMvp[] = voti.data ?? []; const tutti: VotoMvpPubblico[] = voti.data ?? [];
const conteggio = conteggioPartita(tutti, matchId); const conteggio = conteggioPartita(tutti, matchId);
const mio = io ? mioVoto(tutti, matchId, io.id) : null; const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null;
const chiuso = votoMvpChiuso(evento.data, evento.ora);
const totale = conteggio.reduce((s, c) => s + c.voti, 0); const totale = conteggio.reduce((s, c) => s + c.voti, 0);
const testa = conteggio[0]; const mvp = mvpPartita(tutti, matchId);
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti; const puoVotare = !!io && !mio && !chiuso && presente(io.id);
const puoVotare = !!io && presente(io.id);
if (!votoMvpAperto(evento.data, evento.ora)) { if (!votoMvpAperto(evento.data, evento.ora)) {
return ( return (
@@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
votato_nome: nome, votato_nome: nome,
}); });
setAperto(false); setAperto(false);
setScelto(null);
toast.success(`Voto MVP registrato: ${nome}`); toast.success(`Voto MVP registrato: ${nome}`);
} catch { } catch {
toast.error("Voto non riuscito, riprova"); toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare");
} }
} }
@@ -78,58 +87,87 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"} <Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
</p> </p>
<button {mio || chiuso ? null : (
type="button" <button
onClick={() => setAperto((v) => !v)} type="button"
disabled={!puoVotare} onClick={() => setAperto((v) => !v)}
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50" disabled={!puoVotare}
> className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
{mio ? "Cambia voto" : "Vota"} >
</button> Vota
</button>
)}
</div> </div>
<p className="mt-2 text-[13px]"> <p className="mt-2 text-[13px]">
{testa && !pareggio ? ( {mvp.length > 0 ? (
<span className="inline-flex items-center gap-1 font-bold"> <span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 font-bold">
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti}) {mvp.map((c) => (
<span key={c.id} className="inline-flex items-center gap-1">
<Crown className="h-3.5 w-3.5 text-warning" /> {c.nome} ({c.voti})
</span>
))}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"} {totale === 0
? "Nessun voto: MVP da eleggere"
: `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`}
</span> </span>
)} )}
</p> </p>
{mio ? ( {mio ? (
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p> <p className="mt-1 text-[13px] text-muted-foreground">
Hai votato {mio.votato_nome}: il voto non si può cambiare.
</p>
) : chiuso ? (
<p className="mt-1 text-[13px] text-muted-foreground">Votazione chiusa.</p>
) : !puoVotare ? ( ) : !puoVotare ? (
<p className="mt-1 text-[13px] text-muted-foreground"> <p className="mt-1 text-[13px] text-muted-foreground">
Vota chi era presente a questa partita. Vota chi era presente a questa partita.
</p> </p>
) : null} ) : (
<p className="mt-1 text-[13px] text-muted-foreground">
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
</p>
)}
{aperto ? ( {aperto && !mio ? (
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto"> <>
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato <div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
anche dal vincolo `mvp_no_autovoto` (M12). */} anche dal vincolo `mvp_no_autovoto` (M12). */}
{rosa {rosa
.filter((g) => g.id !== io?.id) .filter((g) => g.id !== io?.id)
.map((g) => ( .map((g) => (
<button <button
key={g.id} key={g.id}
type="button" type="button"
disabled={vota.isPending} disabled={vota.isPending}
onClick={() => invia(g.id, nomeCompleto(g))} onClick={() => setScelto({ id: g.id, nome: nomeCompleto(g) })}
className={cn( className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors", "truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
mio?.votato_id === g.id scelto?.id === g.id
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "bg-card text-foreground", : "bg-card text-foreground",
)} )}
> >
{nomeCompleto(g)} {nomeCompleto(g)}
</button> </button>
))} ))}
</div> </div>
<button
type="button"
disabled={!scelto || vota.isPending}
onClick={() => scelto && invia(scelto.id, scelto.nome)}
className="mt-2 w-full rounded-full bg-accent px-3 py-2 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{scelto ? `Conferma il voto per ${scelto.nome}` : "Scegli chi votare"}
</button>
<p className="mt-1 text-center text-[13px] text-muted-foreground">
Il voto è definitivo: non si può cambiare.
</p>
</>
) : conteggio.length > 0 ? ( ) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{conteggio.map((c) => ( {conteggio.map((c) => (
+11 -1
View File
@@ -145,6 +145,7 @@ export type Database = {
data: string; data: string;
id: string; id: string;
luogo: string; luogo: string;
link_maps: string | null;
note: string; note: string;
ora: string; ora: string;
pagelle_chiuse: boolean; pagelle_chiuse: boolean;
@@ -160,6 +161,7 @@ export type Database = {
data: string; data: string;
id: string; id: string;
luogo?: string; luogo?: string;
link_maps?: string | null;
note?: string; note?: string;
ora?: string; ora?: string;
pagelle_chiuse?: boolean; pagelle_chiuse?: boolean;
@@ -175,6 +177,7 @@ export type Database = {
data?: string; data?: string;
id?: string; id?: string;
luogo?: string; luogo?: string;
link_maps?: string | null;
note?: string; note?: string;
ora?: string; ora?: string;
pagelle_chiuse?: boolean; pagelle_chiuse?: boolean;
@@ -622,7 +625,14 @@ export type Database = {
}; };
}; };
Views: { Views: {
[_ in never]: never; mvp_voti_anonimi: {
Row: {
match_id: string | null;
votato_id: string | null;
votato_nome: string | null;
};
Relationships: [];
};
}; };
Functions: { Functions: {
has_role: { has_role: {
+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"; import { daRiga, type Evento, type RigaEvento } from "./eventi";
const COLONNE = 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. */ /** Lettura eventi lato server (route API): stessa conversione del client. */
export async function leggiEventi(): Promise<Evento[]> { export async function leggiEventi(): Promise<Evento[]> {
+44 -3
View File
@@ -9,6 +9,8 @@ export type Evento = {
tipo: EventoTipo; tipo: EventoTipo;
titolo: string; titolo: string;
luogo: string; luogo: string;
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
linkMaps?: string | undefined;
data: string; data: string;
ora: string; ora: string;
note: string; note: string;
@@ -30,6 +32,7 @@ export type RigaEvento = {
tipo: string; tipo: string;
titolo: string; titolo: string;
luogo: string; luogo: string;
link_maps?: string | null;
data: string; data: string;
ora: string; ora: string;
note: string | null; note: string | null;
@@ -47,6 +50,7 @@ export function daRiga(r: RigaEvento): Evento {
tipo: (r.tipo as EventoTipo) ?? "evento", tipo: (r.tipo as EventoTipo) ?? "evento",
titolo: r.titolo, titolo: r.titolo,
luogo: r.luogo ?? "", luogo: r.luogo ?? "",
linkMaps: r.link_maps ?? "",
data: r.data, data: r.data,
ora: r.ora ?? "", ora: r.ora ?? "",
note: r.note ?? "", note: r.note ?? "",
@@ -59,7 +63,7 @@ export function daRiga(r: RigaEvento): Evento {
} }
const COLONNE = 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. */ /** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento"; 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 }; 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; export const EVENTI_KEY = ["eventi"] as const;
async function fetchEventi(): Promise<Evento[]> { async function fetchEventi(): Promise<Evento[]> {
@@ -90,6 +105,25 @@ export function useEventi() {
return { ...query, eventi: query.data ?? [] }; 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) { export function useEvento(id: string) {
const { eventi, ...resto } = useEventi(); const { eventi, ...resto } = useEventi();
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null }; return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
@@ -105,6 +139,7 @@ export function eventoVuoto(): Evento {
tipo: "allenamento", tipo: "allenamento",
titolo: "", titolo: "",
luogo: "", luogo: "",
linkMaps: "",
data: new Date().toISOString().slice(0, 10), data: new Date().toISOString().slice(0, 10),
ora: "20:30", ora: "20:30",
note: "", note: "",
@@ -125,6 +160,7 @@ export function useSalvaEvento() {
tipo: evento.tipo, tipo: evento.tipo,
titolo: evento.titolo, titolo: evento.titolo,
luogo: evento.luogo, luogo: evento.luogo,
link_maps: evento.linkMaps?.trim() || null,
data: evento.data, data: evento.data,
ora: evento.ora, ora: evento.ora,
note: evento.note, note: evento.note,
@@ -198,6 +234,11 @@ export function compleanniEventi(
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */ /** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) { export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
if (!evento || evento.convocati.length === 0) return rosa; return rosaConvocata(rosa, evento?.convocati ?? []);
return rosa.filter((g) => evento.convocati.includes(g.id)); }
/** 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));
} }
+79 -47
View File
@@ -8,52 +8,91 @@ export type VotoMvp = {
votato_nome: string; votato_nome: string;
}; };
/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */
export type VotoMvpPubblico = Pick<VotoMvp, "match_id" | "votato_id" | "votato_nome">;
const CHIAVE = ["mvp-voti"] as const; const CHIAVE = ["mvp-voti"] as const;
const chiaveMiei = (votanteId: string | undefined) => ["mvp-voti-miei", votanteId] as const;
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */ /** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
export const ORE_ATTESA_MVP = 2; export const ORE_ATTESA_MVP = 2;
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */ /** Per quante ore resta aperto il voto, dall'apertura (DD-052). */
export const DURATA_VOTO_MVP_ORE = 24;
/** Il voto MVP apre 2 ore dopo l'inizio della partita. */
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean { export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
const inizio = new Date(`${data}T${ora || "00:00"}:00`); const inizio = new Date(`${data}T${ora || "00:00"}:00`);
if (Number.isNaN(inizio.getTime())) return false; if (Number.isNaN(inizio.getTime())) return false;
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000; return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
} }
/** Tutti i voti MVP della squadra (poche righe, si carica tutto). /** Il voto si è chiuso: sono passate `DURATA_VOTO_MVP_ORE` ore dall'apertura. */
export function votoMvpChiuso(data: string, ora: string, adesso = new Date()): boolean {
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
if (Number.isNaN(inizio.getTime())) return false;
const ore = ORE_ATTESA_MVP + DURATA_VOTO_MVP_ORE;
return adesso.getTime() >= inizio.getTime() + ore * 60 * 60_000;
}
/** Si può votare adesso: aperto e non ancora chiuso. */
export function votoMvpInCorso(data: string, ora: string, adesso = new Date()): boolean {
return votoMvpAperto(data, ora, adesso) && !votoMvpChiuso(data, ora, adesso);
}
/** Tutti i voti MVP della squadra, senza chi li ha dati (poche righe, si carica tutto).
* Vengono dalla vista `mvp_voti_anonimi`: la tabella è leggibile solo dal votante (DD-052).
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */ * Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
export function useVotiMvp() { export function useVotiMvp() {
return useQuery({ return useQuery({
queryKey: CHIAVE, queryKey: CHIAVE,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoMvpPubblico[]> => {
const { data, error } = await supabase
.from("mvp_voti_anonimi")
.select("match_id, votato_id, votato_nome");
if (error) throw error;
return (data ?? []) as VotoMvpPubblico[];
},
});
}
/** I voti che ho dato io: sono gli unici che la tabella mi lascia leggere. */
export function useMieiVotiMvp(votanteId: string | undefined) {
return useQuery({
queryKey: chiaveMiei(votanteId),
enabled: !!votanteId,
staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoMvp[]> => { queryFn: async (): Promise<VotoMvp[]> => {
const { data, error } = await supabase const { data, error } = await supabase
.from("mvp_voti") .from("mvp_voti")
.select("match_id, votante_id, votato_id, votato_nome"); .select("match_id, votante_id, votato_id, votato_nome")
.eq("votante_id", votanteId!);
if (error) throw error; if (error) throw error;
return (data ?? []) as VotoMvp[]; return (data ?? []) as VotoMvp[];
}, },
}); });
} }
/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */
export function useVotaMvp() { export function useVotaMvp() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (voto: VotoMvp) => { mutationFn: async (voto: VotoMvp) => {
const { error } = await supabase const { error } = await supabase.from("mvp_voti").insert(voto);
.from("mvp_voti")
.upsert(voto, { onConflict: "match_id,votante_id" });
if (error) throw error; if (error) throw error;
return voto; return voto;
}, },
// Aggiorna la cache localmente: nessuna rilettura dal database. // Aggiorna la cache localmente: nessuna rilettura dal database.
onSuccess: (voto) => { onSuccess: (voto) => {
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => { qc.setQueryData<VotoMvp[]>(chiaveMiei(voto.votante_id), (prec) => [
const altri = (prec ?? []).filter( ...(prec ?? []).filter((v) => v.match_id !== voto.match_id),
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id), voto,
); ]);
return [...altri, voto]; qc.setQueryData<VotoMvpPubblico[]>(CHIAVE, (prec) => [
}); ...(prec ?? []),
{ match_id: voto.match_id, votato_id: voto.votato_id, votato_nome: voto.votato_nome },
]);
}, },
}); });
} }
@@ -64,7 +103,7 @@ export type ConteggioMvp = { id: string; nome: string; voti: number };
export const VOTI_MINIMI_MVP = 2; export const VOTI_MINIMI_MVP = 2;
/** Conteggio voti di una partita, dal più votato. */ /** Conteggio voti di una partita, dal più votato. */
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] { export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>(); const map = new Map<string, ConteggioMvp>();
for (const v of voti) { for (const v of voti) {
if (v.match_id !== matchId) continue; if (v.match_id !== matchId) continue;
@@ -75,26 +114,29 @@ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome)); return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
} }
/** Vincitore per ogni partita votata: matchId -> nome MVP. */ /**
export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> { * MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari
const perMatch = new Map<string, VotoMvp[]>(); * merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora.
for (const v of voti) { */
const arr = perMatch.get(v.match_id) ?? []; export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
arr.push(v); const top = conteggioPartita(voti, matchId);
perMatch.set(v.match_id, arr); const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
} if (totaleVoti < VOTI_MINIMI_MVP) return [];
return top.filter((c) => c.voti === top[0]!.voti);
}
/** «Anna», «Anna e Bruno», «Anna, Bruno e Carla». */
export function elencoNomi(nomi: string[]): string {
if (nomi.length <= 1) return nomi.join("");
return `${nomi.slice(0, -1).join(", ")} e ${nomi[nomi.length - 1]}`;
}
/** MVP per ogni partita votata: matchId -> nome (più nomi uniti se c'è una parità in testa). */
export function vincitoriMvp(voti: VotoMvpPubblico[]): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const [matchId] of perMatch) { for (const matchId of new Set(voti.map((v) => v.match_id))) {
const top = conteggioPartita(voti, matchId); const mvp = mvpPartita(voti, matchId);
const totaleVoti = top.reduce((s, c) => s + c.voti, 0); if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome));
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
out[matchId] = top[0]!.nome;
}
} }
return out; return out;
} }
@@ -104,24 +146,14 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
} }
/** /**
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio * MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti * parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti
* restituisce una mappa vuota. * restituisce una mappa vuota.
*/ */
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> { export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id)); for (const matchId of new Set(voti.map((v) => v.match_id))) {
for (const matchId of matchIds) { for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1;
const top = conteggioPartita(voti, matchId);
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
const id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
} }
return out; return out;
} }
+2 -1
View File
@@ -26,7 +26,8 @@ export type TipoNotifica =
| "sollecita_presenze_6h" | "sollecita_presenze_6h"
| "sondaggio_cacche" | "sondaggio_cacche"
| "compleanno" | "compleanno"
| "compleanno_auguri"; | "compleanno_auguri"
| "presenza_modificata";
export type NotificaUtente = { export type NotificaUtente = {
id: string; id: string;
+16 -1
View File
@@ -75,6 +75,16 @@ function AllenamentoDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </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> </div>
{evento.note ? ( {evento.note ? (
@@ -93,7 +103,12 @@ function AllenamentoDetail() {
</Section> </Section>
<Section titolo="Rosa e presenze"> <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> </Section>
</> </>
); );
+3 -2
View File
@@ -8,7 +8,8 @@ import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion"; import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; 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 { useAnagraficaRosa } from "@/lib/rosa";
import { usePuoGestireEventi } from "@/lib/ruoli"; import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
@@ -56,7 +57,7 @@ function Calendario() {
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null); const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false); const [drawerAperto, setDrawerAperto] = useState(false);
const puoGestire = usePuoGestireEventi(); const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi(); const { eventi } = useEventiVisibili();
const rosa = useAnagraficaRosa({ conAllenatori: true }); const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo // `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi. // `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 { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react"; import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
@@ -21,8 +22,8 @@ import {
categoriaEvento, categoriaEvento,
daCategoria, daCategoria,
eventoVuoto, eventoVuoto,
linkMapsValido,
useEliminaEvento, useEliminaEvento,
useEventi,
useSalvaEvento, useSalvaEvento,
type CategoriaEvento, type CategoriaEvento,
type Evento, type Evento,
@@ -65,7 +66,7 @@ function GestioneEventi() {
const io = useGiocatoreBase(); const io = useGiocatoreBase();
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso. // Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
const puoGestire = usePuoGestireEventi(); const puoGestire = usePuoGestireEventi();
const { eventi, isPending, isError, error, refetch } = useEventi(); const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento(); const salva = useSalvaEvento();
const elimina = useEliminaEvento(); const elimina = useEliminaEvento();
@@ -111,6 +112,12 @@ function GestioneEventi() {
toast.error("Serve un titolo per l'evento"); toast.error("Serve un titolo per l'evento");
return; 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) { if (modificaEsistente) {
setConfermaModifica(true); setConfermaModifica(true);
return; return;
@@ -121,7 +128,11 @@ function GestioneEventi() {
async function conferma() { async function conferma() {
if (!bozza) return; if (!bozza) return;
try { 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"); toast.success("Evento salvato");
setBozza(null); setBozza(null);
} catch { } catch {
@@ -373,6 +384,18 @@ function GestioneEventi() {
/> />
</Campo> </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"> <Campo label="Note">
<textarea <textarea
value={bozza.note} value={bozza.note}
+16 -1
View File
@@ -72,6 +72,16 @@ function EventoDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </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> </div>
{evento.note ? ( {evento.note ? (
@@ -88,7 +98,12 @@ function EventoDetail() {
</Section> </Section>
<Section titolo="Rosa e presenze"> <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> </Section>
</> </>
); );
+4 -3
View File
@@ -11,7 +11,8 @@ import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero"; import { Numero } from "@/components/motion/Numero";
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; 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 { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa"; import { useIo, useObiettivi } from "@/lib/rosa";
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra"; import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
@@ -47,14 +48,14 @@ function Index() {
const base = useGiocatoreBase(); const base = useGiocatoreBase();
const giocatore = useIo(); const giocatore = useIo();
const allenatore = isAllenatore(base); const allenatore = isAllenatore(base);
const { eventi } = useEventi(); const { eventi } = useEventiVisibili();
const { presenze } = useRispostePresenze(); const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10); const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3); const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null; const prossimo = prossimi[0] ?? null;
const daConfermare = prossimi const daConfermare = prossimi
.slice(1) .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 linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const { data: csi } = useCsi(); const { data: csi } = useCsi();
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra)); 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"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </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> </div>
{evento.note ? ( {evento.note ? (
@@ -297,7 +307,12 @@ function PartitaDetail() {
) : null} ) : null}
<Section titolo="Rosa e presenze"> <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> </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*$'
)
);
@@ -0,0 +1,18 @@
-- M35 — Il voto MVP è definitivo (DD-051)
--
-- Prima un giocatore poteva cambiare o togliere il proprio voto (policy FOR ALL, M11/M13). Ora può
-- solo inserirlo: UPDATE e DELETE restano agli admin (policy «Gli admin gestiscono tutti i voti
-- MVP», M11), che possono correggere un voto sbagliato. Il vincolo UNIQUE (match_id, votante_id)
-- fa il resto: un secondo voto sulla stessa partita viene rifiutato.
DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
);
@@ -0,0 +1,48 @@
-- M36 — Il voto MVP si controlla anche sulla presenza e sull'orario (DD-051)
--
-- Fino alla M35 il database controllava solo convocati e allenatori (`evento_permette_voto`): che
-- votante e votato fossero presenti, e che fossero passate 2 ore dall'inizio, lo faceva solo l'app.
-- Ora la policy di INSERT su `mvp_voti` usa `voto_mvp_consentito()`, che aggiunge:
-- - l'evento è una partita;
-- - votante e votato hanno risposto `presente` o `ritardo`;
-- - sono passate 2 ore dall'inizio (stesso valore di ORE_ATTESA_MVP in src/lib/mvp-voti.ts).
--
-- È una funzione nuova e non una modifica di `evento_permette_voto`, perché questa è usata anche da
-- pagelle e badge social, che non hanno queste regole. Gli admin restano fuori (policy M11): possono
-- ancora correggere. L'ora si legge in Europe/Rome; un `ora` vuoto o non valido vale mezzanotte,
-- come in `votoMvpAperto()`.
CREATE OR REPLACE FUNCTION public.voto_mvp_consentito(
p_match_id text, p_votante_id text, p_votato_id text
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT public.evento_permette_voto(p_match_id, p_votante_id, p_votato_id, false)
AND EXISTS (
SELECT 1 FROM public.eventi_app e
WHERE e.id = p_match_id
AND e.tipo = 'partita'
AND ((e.data + CASE WHEN e.ora ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
THEN e.ora::time ELSE time '00:00' END)
AT TIME ZONE 'Europe/Rome') + interval '2 hours' <= now()
)
-- votante e votato sono diversi (mvp_no_autovoto): due righe = entrambi presenti
AND (
SELECT count(DISTINCT r.giocatore_id)
FROM public.risposte_presenze r
WHERE r.evento_id = p_match_id
AND r.giocatore_id IN (p_votante_id, p_votato_id)
AND r.stato IN ('presente', 'ritardo')
) = 2;
$$;
DROP POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.voto_mvp_consentito(match_id, votante_id, votato_id)
);
@@ -0,0 +1,89 @@
-- M37 — Voto MVP segreto, nome del votato dal database, votazione che si chiude (DD-052)
--
-- 1. Segretezza. Finora `mvp_voti` era leggibile da tutti, quindi chiunque vedeva chi aveva votato chi.
-- Ora la lettura diretta della tabella è del solo votante (il proprio voto) e degli admin (policy
-- M11, che servono per correggere un voto: dall'app non vedono chi ha votato chi). Tutti gli altri
-- leggono solo i conteggi, dalla vista `mvp_voti_anonimi`: una riga per voto, senza `votante_id`.
-- 2. Nome. `votato_nome` lo scriveva il client, quindi poteva non corrispondere a `votato_id`. Un trigger
-- lo riempie dalla rosa (`giocatori_squadra`) a partire da `votato_id`, ignorando quello inviato.
-- 3. Chiusura. Il voto si apre 2 ore dopo l'inizio della partita (M36) e si chiude 24 ore dopo
-- l'apertura. I valori sono gli stessi di ORE_ATTESA_MVP e DURATA_VOTO_MVP_ORE in
-- src/lib/mvp-voti.ts. Gli admin restano fuori da questo vincolo.
-- --- 1. Segretezza ------------------------------------------------------------------------------
DROP POLICY "Chiunque puo leggere i voti MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno legge il proprio voto MVP" ON public.mvp_voti
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
);
-- La vista gira con i permessi del proprietario (security_invoker = false): è ciò che le permette di
-- leggere tutte le righe pur senza esporre il votante. L'ordine è per candidato, non per arrivo, così
-- la sequenza dei voti non rivela nulla.
CREATE VIEW public.mvp_voti_anonimi WITH (security_invoker = false) AS
SELECT match_id, votato_id, votato_nome
FROM public.mvp_voti
ORDER BY match_id, votato_id;
REVOKE ALL ON public.mvp_voti_anonimi FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.mvp_voti_anonimi TO authenticated;
GRANT ALL ON public.mvp_voti_anonimi TO service_role;
-- --- 2. Nome del votato dalla rosa ----------------------------------------------------------------
-- Se `votato_id` non è in rosa (id di prova) resta il nome ricevuto: non c'è niente da sostituire.
CREATE OR REPLACE FUNCTION public.mvp_voti_nome_dalla_rosa()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
nome_rosa text;
BEGIN
SELECT btrim(g.nome || ' ' || g.cognome) INTO nome_rosa
FROM public.giocatori_squadra g
WHERE g.id = NEW.votato_id;
IF FOUND THEN
NEW.votato_nome := nome_rosa;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER mvp_voti_nome_dalla_rosa
BEFORE INSERT OR UPDATE OF votato_id, votato_nome ON public.mvp_voti
FOR EACH ROW
EXECUTE FUNCTION public.mvp_voti_nome_dalla_rosa();
-- --- 3. Finestra di voto: apre a +2 ore, chiude 24 ore dopo l'apertura ------------------------------
CREATE OR REPLACE FUNCTION public.voto_mvp_consentito(
p_match_id text, p_votante_id text, p_votato_id text
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT public.evento_permette_voto(p_match_id, p_votante_id, p_votato_id, false)
AND EXISTS (
SELECT 1
FROM public.eventi_app e
CROSS JOIN LATERAL (
SELECT ((e.data + CASE WHEN e.ora ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
THEN e.ora::time ELSE time '00:00' END)
AT TIME ZONE 'Europe/Rome') AS inizio
) t
WHERE e.id = p_match_id
AND e.tipo = 'partita'
AND t.inizio + interval '2 hours' <= now()
AND now() < t.inizio + interval '2 hours' + interval '24 hours'
)
-- votante e votato sono diversi (mvp_no_autovoto): due righe = entrambi presenti
AND (
SELECT count(DISTINCT r.giocatore_id)
FROM public.risposte_presenze r
WHERE r.evento_id = p_match_id
AND r.giocatore_id IN (p_votante_id, p_votato_id)
AND r.stato IN ('presente', 'ritardo')
) = 2;
$$;
@@ -0,0 +1,11 @@
-- M38 — Anche i badge social prendono il nome del votato dalla rosa (DD-052)
--
-- Stesso difetto dell'MVP (M37): `badge_social_voti.votato_nome` lo scrive il client, quindi può non
-- corrispondere a `votato_id`, e l'app lo mostra («Hai votato …», elenco dei più votati). Si riusa il
-- trigger della M37: le due tabelle hanno le stesse colonne `votato_id` e `votato_nome`.
-- Le pagelle non hanno un nome del votato, quindi non serve altro.
CREATE TRIGGER badge_social_voti_nome_dalla_rosa
BEFORE INSERT OR UPDATE OF votato_id, votato_nome ON public.badge_social_voti
FOR EACH ROW
EXECUTE FUNCTION public.mvp_voti_nome_dalla_rosa();
+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | 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 | | `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ì | | `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ì | | `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 | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Server di sviluppo con Node vecchio ## Server di sviluppo con Node vecchio
+29
View File
@@ -0,0 +1,29 @@
/**
* Data e ora (fuso Europe/Rome) di un istante a `ore` ore da adesso, nel formato di `eventi_app`
* (`data` AAAA-MM-GG, `ora` HH:MM). Serve ai test del voto MVP, che dipende dall'orario di inizio.
*/
export function romaTra(ore: number): { data: string; ora: string } {
const p: Record<string, string> = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
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((x) => [x.type, x.value]),
);
return { data: `${p["year"]}-${p["month"]}-${p["day"]}`, ora: `${p["hour"]}:${p["minute"]}` };
}
/**
* Una data con ora vuota vale mezzanotte. Ritorna il giorno la cui mezzanotte cade tra 2 e 26 ore
* fa (il voto è aperto): oggi se sono passate almeno 2 ore dalla mezzanotte, altrimenti ieri.
*/
export function giornoSenzaOraAperto(): string {
const adesso = romaTra(0);
return Number(adesso.ora.slice(0, 2)) >= 2 ? adesso.data : romaTra(-24).data;
}
+12 -14
View File
@@ -14,7 +14,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges"; import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -52,11 +52,11 @@ if (!locale) {
} }
/** Rilegge esattamente come `useVotiMvp()`. */ /** Rilegge esattamente come `useVotiMvp()`. */
async function leggiVoti(): Promise<VotoMvp[]> { async function leggiVoti(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */ /** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
@@ -68,8 +68,8 @@ if (!locale) {
await prova( await prova(
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database", "il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
async () => { async () => {
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in // Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo, // voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge. // non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({ await upsert({
match_id: `${PREFISSO}-m1`, match_id: `${PREFISSO}-m1`,
@@ -107,17 +107,15 @@ if (!locale) {
votato_id: "vg1", votato_id: "vg1",
votato_nome: "Uno", votato_nome: "Uno",
}); });
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "vb",
votato_id: "vg2",
votato_nome: "Due",
});
const voti = await leggiVoti(); const voti = await leggiVoti();
const vinti = mvpVintiPerGiocatore(voti); const vinti = mvpVintiPerGiocatore(voti);
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)"); assert.equal(
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente"); vinti["vg1"],
2,
"vg1 vince m1 e m2, m3 ha un solo voto (sotto quorum, non conta)",
);
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0)); const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta"); assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
+28 -1
View File
@@ -8,6 +8,7 @@
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato), * Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`. * lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
*/ */
import { romaTra } from "../helpers/roma";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -259,8 +260,34 @@ if (!locale) {
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque. // Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ convocati: [] }), // M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
}); });
// M36: perché il rifiuto dipenda solo dal ruolo, tutti sono presenti (lo slot dell'allenatore
// lo segniamo presente a mano: da sé non potrebbe rispondere) e la partita è passata.
for (const g of [GIOCATORE, SLOT, "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: g, stato: "presente" }),
});
}
// Controllo positivo: due giocatori presenti, il voto passa.
const valido = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: GIOCATORE,
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(
await righeToccate(valido),
1,
`un giocatore vota un giocatore (${valido.status})`,
);
const vota = await rest("mvp_voti", tokenAllenatore, { const vota = await rest("mvp_voti", tokenAllenatore, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
+365 -10
View File
@@ -16,6 +16,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale(); const locale = statoLocale();
@@ -310,12 +311,47 @@ if (!locale) {
}), }),
}); });
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`); assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
});
// --- Voto MVP: definitivo (M35), solo tra presenti e dopo 2 ore dall'inizio (M36) ----------
let contatoreMvp = 0;
/** Crea una partita (o altro tipo) con le presenze indicate; ritorna l'id. */
async function eventoMvp(
quando: { data: string; ora: string },
presenze: Record<string, string>,
tipo = "partita",
): Promise<string> {
const id = `${PREFISSO}-mvp-${++contatoreMvp}`;
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ id, tipo, titolo: "Voto MVP", luogo: "Palestra", ...quando }),
});
assert.equal(await righeToccate(creato), 1, "l'evento di prova si crea");
for (const [giocatore_id, stato] of Object.entries(presenze)) {
const r = await rest("risposte_presenze", SERVIZIO, {
method: "POST",
body: JSON.stringify({ evento_id: id, giocatore_id, stato }),
});
assert.ok(r.ok, `presenza di prova ${giocatore_id}: ${r.status}`);
}
return id;
}
const votaMvp = (token: string, match_id: string, votante_id: string, votato_id: string) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id, votato_id, votato_nome: votato_id }),
});
const passata = romaTra(-3);
await prova("il voto MVP è uno solo, definitivo e firmato (M35)", async () => {
const EVENTO_MVP = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const mioMvp = await rest("mvp_voti", tokenGiocatore, { const mioMvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g1", votante_id: "g1",
votato_id: "g5", votato_id: "g5",
votato_nome: "Cinque", votato_nome: "Cinque",
@@ -323,11 +359,55 @@ if (!locale) {
}); });
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra"); assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
// Il voto MVP è definitivo (M35): né un secondo voto, né una modifica, né la cancellazione.
const secondo = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO_MVP,
votante_id: "g1",
votato_id: "g6",
votato_nome: "Sei",
}),
});
assert.ok(
!secondo.ok,
`un secondo voto MVP sulla stessa partita è rifiutato (${secondo.status})`,
);
const modifica = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Sei" }),
},
);
assert.equal(await righeToccate(modifica), 0, "il proprio voto MVP non si modifica");
const cancella = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "DELETE",
headers: { Prefer: "return=representation" },
},
);
assert.equal(await righeToccate(cancella), 0, "il proprio voto MVP non si cancella");
const dopo = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1&select=votato_id`,
SERVIZIO,
);
assert.deepEqual(
await dopo.json(),
[{ votato_id: "g5" }],
"il voto è rimasto quello iniziale",
);
const mvp = await rest("mvp_voti", tokenGiocatore, { const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g5", votante_id: "g5",
votato_id: "g1", votato_id: "g1",
votato_nome: "Uno", votato_nome: "Uno",
@@ -336,6 +416,265 @@ if (!locale) {
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`); assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
}); });
await prova("il voto MVP vale solo tra presenti (M36)", async () => {
const q = passata;
// Controllo positivo: presente e in ritardo possono votare e farsi votare.
const ok = await eventoMvp(q, { g1: "presente", g5: "ritardo" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, ok, "g1", "g5")), 1);
const okRitardo = await eventoMvp(q, { g1: "ritardo", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, okRitardo, "g1", "g5")), 1);
for (const [stato, nome] of [
["forse", "forse"],
["assente", "assente"],
["infortunato", "infortunato"],
[null, "senza risposta"],
] as const) {
const votatoNo = await eventoMvp(q, {
g1: "presente",
...(stato ? { g5: stato } : {}),
});
const r = await votaMvp(tokenGiocatore, votatoNo, "g1", "g5");
assert.ok(!r.ok, `un votato ${nome} non si può votare (${r.status})`);
const votanteNo = await eventoMvp(q, {
g5: "presente",
...(stato ? { g1: stato } : {}),
});
const r2 = await votaMvp(tokenGiocatore, votanteNo, "g1", "g5");
assert.ok(!r2.ok, `un votante ${nome} non può votare (${r2.status})`);
}
});
await prova(
"il voto MVP apre 2 ore dopo l'inizio e chiude 24 ore dopo (M36, M37)",
async () => {
const presenti = { g1: "presente", g5: "presente" };
const tre = await eventoMvp(romaTra(-3), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, tre, "g1", "g5")),
1,
"3 ore dopo",
);
const una = await eventoMvp(romaTra(-1), presenti);
const rUna = await votaMvp(tokenGiocatore, una, "g1", "g5");
assert.ok(!rUna.ok, `1 ora dopo l'inizio: ancora chiuso (${rUna.status})`);
const venticinque = await eventoMvp(romaTra(-25), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, venticinque, "g1", "g5")),
1,
"25 ore dopo l'inizio: ancora aperto (chiude a 26)",
);
const ventisette = await eventoMvp(romaTra(-27), presenti);
const rChiuso = await votaMvp(tokenGiocatore, ventisette, "g1", "g5");
assert.ok(!rChiuso.ok, `27 ore dopo l'inizio: chiuso (${rChiuso.status})`);
const futura = await eventoMvp(romaTra(48), presenti);
const rFutura = await votaMvp(tokenGiocatore, futura, "g1", "g5");
assert.ok(!rFutura.ok, `partita non ancora giocata (${rFutura.status})`);
// Un'ora vuota vale mezzanotte, come nell'app.
const senzaOra = await eventoMvp({ data: giornoSenzaOraAperto(), ora: "" }, presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, senzaOra, "g1", "g5")),
1,
"senza ora vale mezzanotte",
);
},
);
await prova("il voto MVP esiste solo per le partite (M36)", async () => {
const presenti = { g1: "presente", g5: "presente" };
for (const tipo of ["allenamento", "evento"]) {
const e = await eventoMvp(passata, presenti, tipo);
const r = await votaMvp(tokenGiocatore, e, "g1", "g5");
assert.ok(!r.ok, `nessun MVP per ${tipo} (${r.status})`);
}
});
await prova("l'admin può registrare un voto MVP anche fuori da queste regole", async () => {
const futura = await eventoMvp(romaTra(48), { g1: "assente" });
const r = await votaMvp(tokenAdmin, futura, "g1", "g5");
assert.equal(await righeToccate(r), 1, "l'admin corregge/inserisce senza vincoli di M36");
const chiusa = await eventoMvp(romaTra(-48), { g1: "assente" });
const rc = await votaMvp(tokenAdmin, chiusa, "g1", "g5");
assert.equal(await righeToccate(rc), 1, "l'admin può votare anche a votazione chiusa");
});
await prova("il voto MVP è segreto: nessuno legge chi ha votato chi (M37)", async () => {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, e, "g1", "g5")), 1);
// Un secondo voto di un altro votante (inserito dall'admin) per avere due righe.
assert.equal(await righeToccate(await votaMvp(tokenAdmin, e, "g5", "g1")), 1);
const tabella = await rest(
`mvp_voti?match_id=eq.${e}&select=votante_id,votato_id`,
tokenGiocatore,
);
assert.deepEqual(
await tabella.json(),
[{ votante_id: "g1", votato_id: "g5" }],
"dalla tabella un giocatore legge solo il proprio voto",
);
const vista = await rest(`mvp_voti_anonimi?match_id=eq.${e}&select=*`, tokenGiocatore);
assert.ok(vista.ok, `la vista dei conteggi si legge (${vista.status})`);
const righe = (await vista.json()) as Record<string, string>[];
assert.deepEqual(
righe.map((r) => r["votato_id"]).sort(),
["g1", "g5"],
"ci sono tutti i voti",
);
assert.ok(
righe.every((r) => !("votante_id" in r)),
"ma la vista non dice chi li ha dati",
);
const anonimo = await rest(`mvp_voti_anonimi?match_id=eq.${e}`, ANON);
assert.ok(!anonimo.ok, `senza account non si legge nemmeno la vista (${anonimo.status})`);
const anonimoTabella = await rest(`mvp_voti?match_id=eq.${e}`, ANON);
const corpo = anonimoTabella.ok ? ((await anonimoTabella.json()) as unknown[]) : [];
assert.equal(corpo.length, 0, "né la tabella");
const admin = await rest(`mvp_voti?match_id=eq.${e}&select=votante_id`, tokenAdmin);
assert.equal(
((await admin.json()) as unknown[]).length,
2,
"l'admin vede i voti dalla tabella: gli servono per correggerli",
);
});
/** Nome in rosa di uno slot, come lo compone il trigger (nome + cognome, senza spazi in testa). */
async function nomeInRosa(id: string): Promise<string> {
const r = await rest(`giocatori_squadra?id=eq.${id}&select=nome,cognome`, SERVIZIO);
const [g] = (await r.json()) as { nome: string; cognome: string }[];
return `${g!.nome} ${g!.cognome}`.trim();
}
const votoNome = (token: string, match_id: string, votato_id: string, resto: object) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id: "g1", votato_id, ...resto }),
});
const nomeRiga = async (res: Response) =>
((await res.json()) as { votato_nome: string }[])[0]?.votato_nome;
await prova("MVP: il nome del votato lo mette il database, non il client (M37)", async () => {
const g5 = await nomeInRosa("g5");
// Nome inventato: ignorato.
const falso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(
await votoNome(tokenGiocatore, falso, "g5", { votato_nome: "Nome Inventato" }),
),
g5,
);
const visto = await rest(
`mvp_voti_anonimi?match_id=eq.${falso}&select=votato_nome`,
tokenGiocatore,
);
assert.deepEqual(await visto.json(), [{ votato_nome: g5 }], "e così lo vede la squadra");
// Nome omesso (la colonna è NOT NULL): lo riempie il trigger, il voto passa.
const omesso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, omesso, "g5", {})),
g5,
"nome omesso",
);
// Nome vuoto, con spazi, con un altro giocatore: sempre quello della rosa.
for (const [i, strano] of ["", " ", await nomeInRosa("g1"), "<b>x</b>"].entries()) {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, e, "g5", { votato_nome: strano })),
g5,
`caso ${i}`,
);
}
// Nome già corretto: resta lo stesso.
const giusto = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, giusto, "g5", { votato_nome: g5 })),
g5,
);
});
await prova(
"MVP: l'admin che corregge un voto non può far divergere il nome (M37)",
async () => {
const [g5, g6] = [await nomeInRosa("g5"), await nomeInRosa("g6")];
const e = await eventoMvp(passata, { g1: "presente", g5: "presente", g6: "presente" });
await votoNome(tokenGiocatore, e, "g5", {});
const filtro = `mvp_voti?match_id=eq.${e}&votante_id=eq.g1`;
// Cambia solo il nome: torna quello della rosa.
const soloNome = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(soloNome), g5, "nome riscritto da solo");
// Cambia il votato: il nome lo segue.
const altro = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(altro), g6, "nome che segue il nuovo votato");
},
);
await prova("MVP: un id fuori rosa conserva il nome ricevuto (M37)", async () => {
// Solo la service role (come i test sui badge) può scrivere id che non sono in rosa.
const e = `${PREFISSO}-mvp-fuori-rosa`;
const r = await rest("mvp_voti", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: e,
votante_id: "xx1",
votato_id: "xx2",
votato_nome: "Ospite",
}),
});
assert.equal(await nomeRiga(r), "Ospite", "niente da sostituire: resta il nome ricevuto");
});
await prova("badge social: il nome del votato lo mette il database (M38)", async () => {
const g5 = await nomeInRosa("g5");
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const base = { match_id: e, categoria: "sorriso", votante_id: "g1", votato_id: "g5" };
const post = (resto: object) =>
rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ ...base, ...resto }),
});
assert.equal(
await nomeRiga(await post({ votato_nome: "Nome Inventato" })),
g5,
"nome inventato",
);
// Stesso voto rinviato con un altro nome (upsert): il nome resta quello della rosa.
assert.equal(
await nomeRiga(await post({ votato_nome: "Un Altro" })),
g5,
"upsert con altro nome",
);
const omesso = await rest("badge_social_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ ...base, categoria: "meme" }),
});
assert.equal(await nomeRiga(omesso), g5, "nome omesso");
});
await prova("le cacche le dichiara il diretto interessato", async () => { await prova("le cacche le dichiara il diretto interessato", async () => {
const mie = await rest("cacche_partita", tokenGiocatore, { const mie = await rest("cacche_partita", tokenGiocatore, {
method: "POST", method: "POST",
@@ -455,8 +794,8 @@ if (!locale) {
id: EVENTO_CON_G1, id: EVENTO_CON_G1,
tipo: "partita", tipo: "partita",
titolo: "Partita con g1 convocato, g2 no", titolo: "Partita con g1 convocato, g2 no",
data: "2026-01-02", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
convocati: ["g1", "g5"], convocati: ["g1", "g5"],
}), }),
@@ -482,9 +821,17 @@ if (!locale) {
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui // Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo // convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
// (es. un evento inesistente) e sembrare comunque corretto. // (es. un evento inesistente) e sembrare comunque corretto.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CON_G1, giocatore_id: g, stato: "presente" }),
});
}
const votoValido = await rest("mvp_voti", tokenGiocatore, { const votoValido = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CON_G1, match_id: EVENTO_CON_G1,
votante_id: "g1", votante_id: "g1",
@@ -503,8 +850,8 @@ if (!locale) {
id: EVENTO_CHIUSO, id: EVENTO_CHIUSO,
tipo: "partita", tipo: "partita",
titolo: "Partita con pagelle chiuse", titolo: "Partita con pagelle chiuse",
data: "2026-01-03", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
pagelle_chiuse: true, pagelle_chiuse: true,
}), }),
@@ -529,9 +876,17 @@ if (!locale) {
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di // Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano // "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
// votabili sullo stesso evento. // votabili sullo stesso evento.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CHIUSO, giocatore_id: g, stato: "presente" }),
});
}
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, { const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CHIUSO, match_id: EVENTO_CHIUSO,
votante_id: "g1", votante_id: "g1",
@@ -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");
}
}
+4 -4
View File
@@ -20,7 +20,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle"; import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
@@ -67,11 +67,11 @@ if (!locale) {
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
} }
async function leggiMvp(): Promise<VotoMvp[]> { async function leggiMvp(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
async function leggiPagelle(): Promise<VotoPagella[]> { async function leggiPagelle(): Promise<VotoPagella[]> {
+80
View File
@@ -7,7 +7,11 @@ import {
convocatiEvento, convocatiEvento,
daCategoria, daCategoria,
daRiga, daRiga,
eConvocato,
eventoVisibile,
rosaConvocata,
eventoVuoto, eventoVuoto,
linkMapsValido,
type RigaEvento, type RigaEvento,
} from "@/lib/eventi"; } from "@/lib/eventi";
@@ -32,6 +36,7 @@ assert.deepEqual(daRiga(riga), {
tipo: "partita", tipo: "partita",
titolo: "CRAP - Avversari", titolo: "CRAP - Avversari",
luogo: "PalaCRAP", luogo: "PalaCRAP",
linkMaps: "",
data: "2026-09-01", data: "2026-09-01",
ora: "21:00", ora: "21:00",
note: "portare la seconda maglia", 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"); assert.notEqual(bozza.id, "", "ogni bozza ha un id");
console.log("eventi: ok"); 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);
}
+71 -7
View File
@@ -1,11 +1,18 @@
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ /** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { import {
conteggioPartita, conteggioPartita,
elencoNomi,
mioVoto, mioVoto,
mvpPartita,
mvpVintiPerGiocatore, mvpVintiPerGiocatore,
DURATA_VOTO_MVP_ORE,
ORE_ATTESA_MVP,
vincitoriMvp, vincitoriMvp,
votoMvpAperto, votoMvpAperto,
votoMvpChiuso,
votoMvpInCorso,
VOTI_MINIMI_MVP, VOTI_MINIMI_MVP,
type VotoMvp, type VotoMvp,
} from "@/lib/mvp-voti"; } from "@/lib/mvp-voti";
@@ -44,9 +51,13 @@ assert.deepEqual(
["Anna", "Zeno"], ["Anna", "Zeno"],
); );
// --- vincitoriMvp: la parità non assegna nessun MVP -------------------------- // --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito --------------------------
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); assert.deepEqual(
vincitoriMvp(pari),
{ m3: "Anna e Zeno" },
"due voti pari in testa: sono MVP entrambi, a pari merito",
);
assert.deepEqual(vincitoriMvp([]), {}); assert.deepEqual(vincitoriMvp([]), {});
// --- vincitoriMvp: quorum minimo di voti ------------------------------------- // --- vincitoriMvp: quorum minimo di voti -------------------------------------
@@ -69,13 +80,38 @@ const triplaPari = [
v("m6", "g2", "g8", "Anna"), v("m6", "g2", "g8", "Anna"),
v("m6", "g3", "g7", "Bea"), v("m6", "g3", "g7", "Bea"),
]; ];
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie"); assert.deepEqual(
vincitoriMvp(triplaPari),
{ m6: "Anna, Bea e Zeno" },
"tre pari in testa: tre MVP, in ordine alfabetico",
);
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore(triplaPari), mvpVintiPerGiocatore(triplaPari),
{}, { g9: 1, g8: 1, g7: 1 },
"stessa parità: nessuna vittoria netta da contare", "ognuno dei pari merito conta un titolo",
); );
// Primo pari col secondo, terzo staccato: il terzo resta fuori.
const duePariUnoSotto = [
v("m7", "g1", "g9", "Zeno"),
v("m7", "g2", "g9", "Zeno"),
v("m7", "g3", "g8", "Anna"),
v("m7", "g4", "g8", "Anna"),
v("m7", "g5", "g7", "Bea"),
];
assert.deepEqual(
mvpPartita(duePariUnoSotto, "m7").map((c) => c.nome),
["Anna", "Zeno"],
);
assert.deepEqual(mvpPartita([v("m8", "g1", "g2", "Solo")], "m8"), [], "sotto quorum: nessuno");
assert.deepEqual(mvpPartita([], "m8"), []);
// --- elencoNomi ---
assert.equal(elencoNomi([]), "");
assert.equal(elencoNomi(["Anna"]), "Anna");
assert.equal(elencoNomi(["Anna", "Bruno"]), "Anna e Bruno");
assert.equal(elencoNomi(["Anna", "Bruno", "Carla"]), "Anna, Bruno e Carla");
// --- mioVoto ----------------------------------------------------------------- // --- mioVoto -----------------------------------------------------------------
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
@@ -89,8 +125,8 @@ assert.deepEqual(
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, ...pari]), mvpVintiPerGiocatore([...partita, ...pari]),
{ g2: 1, g5: 1 }, { g2: 1, g5: 1, g9: 1, g8: 1 },
"la parità non assegna", "m3 è pari: i due pari merito prendono un titolo ciascuno",
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]), mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
@@ -114,4 +150,32 @@ assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita pa
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte"); assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true); assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
// --- votoMvpChiuso: 24 ore dopo l'apertura (2 ore dopo l'inizio) -----------------
assert.equal(DURATA_VOTO_MVP_ORE, 24);
const giorno = (g: number, o: number, m = 0) => new Date(2026, 8, g, o, m);
// partita del 5/9 alle 20:30: apre il 5 alle 22:30, chiude il 6 alle 22:30
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 29)), false, "un minuto prima");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 30)), true, "24 ore in punto");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(20, 12)), true, "partita vecchia");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(5, 12)), false, "prima della partita");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 21)), false, "non ancora aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 23)), true, "aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(7, 8)), false, "chiuso");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 2)), true);
// --- l'app e il database (M36, M37) hanno la stessa finestra -----------------------
const m37 = readFileSync(
"supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql",
"utf8",
);
assert.ok(
m37.includes(`interval '${ORE_ATTESA_MVP} hours'`),
"ORE_ATTESA_MVP e l'attesa di voto_mvp_consentito() devono coincidere",
);
assert.ok(
m37.includes(`interval '${DURATA_VOTO_MVP_ORE} hours'`),
"DURATA_VOTO_MVP_ORE e la chiusura di voto_mvp_consentito() devono coincidere",
);
console.log("mvp-voti: ok"); console.log("mvp-voti: ok");
+1
View File
@@ -61,6 +61,7 @@ const tipiNuovi: TipoNotifica[] = [
"sollecita_presenze_6h", "sollecita_presenze_6h",
"compleanno", "compleanno",
"compleanno_auguri", "compleanno_auguri",
"presenza_modificata",
]; ];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP"; const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) { for (const tipo of tipiNuovi) {
+32
View File
@@ -294,3 +294,35 @@ assert.equal(
); );
console.log("presenze: ok"); 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",
);
}