diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e772b8a..6b2fba3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -38,6 +38,9 @@ Prima versione, pre-release. della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock. - Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md). +- Badge Pagellone: la media pagelle conta per il badge solo con almeno `VOTI_MINIMI_PAGELLA` + (5) voti ricevuti — prima un singolo voto poteva sbloccarlo o farlo sparire senza nessuna + significatività statistica — [modules/badge.md](modules/badge.md). - Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio (poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale. @@ -79,6 +82,10 @@ Prima versione, pre-release. `pagelle_voti`. - Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è applicativo, non RLS ([modules/mvp.md](modules/mvp.md)). +- Migration `m13_convocati_e_pagelle_chiuse` (DD-027): la policy di M11 su `pagelle_voti`, + `mvp_voti` e `badge_social_voti` verifica ora anche che votante e votato siano tra i + convocati dell'evento, e per le sole pagelle che `pagelle_chiuse` sia falso — prima erano + filtri solo applicativi, aggirabili scrivendo direttamente su PostgREST. - La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di `badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva `risposto_il` (trigger di `m9`). diff --git a/docs/DATABASE.md b/docs/DATABASE.md index a91d0bd..fad509b 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -15,7 +15,7 @@ nessuna di esse da M4 (DD-011). | ---------------------------------------------------------------- | ------------------------------------------------------------------- | | `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) | | `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin | -| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), 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`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | | `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | | `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) | @@ -63,9 +63,9 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa | Tabella | Scopo | Note | | ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`). | -| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0. | -| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`). | +| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). | +| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. | +| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). | ## Turni e notifiche diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 1576cdf..2721e84 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -42,6 +42,7 @@ Serve a rispondere a domande del tipo: | [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate | | [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale | | [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | +| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati | **In valutazione** @@ -970,3 +971,56 @@ prima di inviare. **Riesame** Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard). + +### DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso + +**Stato:** accettata · **Data:** 8 settembre 2026 + +**Contesto** +Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo +applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato +all'account di chi scrive, ma non controlla che **votante e votato fossero convocati** +all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva +votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`, +`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva +solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile. + +**Decisione** +La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e +`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione +`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia +`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id` +(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in +`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin +restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori +convocazione o dopo la chiusura. + +Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad +esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo +(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma +assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto +leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato +dall'audit che ha originato questa decisione. + +**Alternative scartate** + +- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key + e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo + alla policy non-admin, li esclude naturalmente. +- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione → + scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non + hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale). + +**Conseguenze** + +- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia + comportamento: tutta la rosa resta votabile, come prima. +- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di + questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse` + rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora). +- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse + davvero presente, non solo convocato, per MVP/pagelle/badge social. + +**Riesame** +Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database, +non solo in UI. diff --git a/docs/modules/badge.md b/docs/modules/badge.md index 063ed5c..829525f 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -31,8 +31,16 @@ badge assegnati per voto dai compagni. inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni, ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a - confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, l'MVP e i - badge social. + confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il + Pagellone, l'MVP e i badge social. +- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da + contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima + che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche + con una media altissima. Aggiunto perché senza minimo un singolo voto poteva + sbloccare/far sparire il badge senza nessuna significatività statistica (vedi + [pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti + ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla + stessa `mediePagelle()`. - **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita @@ -72,7 +80,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro. | id | nome | come si guadagna | soglie B/A/O | | --- | --- | --- | --- | | `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 | -| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`) | 6.5 / 7.5 / 8.5 | +| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 | | `palloni` | Sherpa dei palloni | quante volte ti sei incaricato di portare la sacca palloni (`g.palloni`) | 3 / 6 / 10 | | `presenze` | Presenza fissa | totale presenze a eventi/partite in stagione (`g.presenze`) | 5 / 15 / 30 | | `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti, senza saltarne uno (`g.serieAllenamenti`) | 3 / 6 / 10 | @@ -126,18 +134,17 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge. **Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado, `badges.test.ts`) sull'output di altri moduli: - `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro, - `badges.test.ts:44-48`), progresso a metà (`:52-56`). **Unico badge normale con integration - dedicato** (vedi sotto) perché la sua fonte, a differenza degli altri 5, passa da un'altra - tabella di voto (`mvp_voti`) invece che da un contatore già calcolato altrove. + `badges.test.ts:44-48`), progresso a metà (`:52-56`). - `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 → nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi - bronzo". + bronzo". Più la soglia minima di voti (vedi sotto). - `palloni`, `presenze`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già testata a fondo su `mvp`/`pagella`, coperti dagli invarianti generali (`badges.test.ts:154-159`: soglie crescenti, testi presenti, id unici) e da `collezioneBadge`/`prossimoTraguardo` con valori al massimo (`:119-144`). -- Nessun integration dedicato per questi 5: non toccano il database, le statistiche sorgente +- Nessun integration dedicato per questi 4: non toccano il database, le statistiche sorgente (`presenze.test.ts`, `palloni-core.test.ts`, ecc.) sono già coperte nei rispettivi moduli. + `mvp` e `pagella` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto. **Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto (`badges.test.ts:77-109`): `s-tiebreak` (mediaVoto 7.9 non basta, serve 8), `s-mai-forfait` @@ -154,12 +161,29 @@ copertura di questo badge): partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database (`mvp_no_autovoto`). - `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non - si può votare a nome di un altro (caso negativo). + si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un + votato non convocati vengono rifiutati. - `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non deve contare come vittoria. +**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra, +dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"): +- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche + con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le + normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro). +- Integration (`npx supabase start` richiesto): + - `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto + (`pagelle_no_autovoto`), già presente prima di questa sessione. + - `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati + (per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo + `pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI. + - `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge + via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()` + tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e + applichi le soglie normali sopra soglia. + **Badge social** — nessuna delle 5 categorie ha logica *propria* nel codice: l'id è solo una chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero @@ -176,7 +200,7 @@ meccanismo: | # | id | tipo | test unit | test integration | | - | --- | --- | --- | --- | | 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) | -| 2 | `pagella` | normale | ✅ | non necessario | +| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) | | 3 | `palloni` | normale | ✅ | non necessario | | 4 | `presenze` | normale | ✅ | non necessario | | 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario | @@ -219,12 +243,19 @@ Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo): risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione. - Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già "ottenuto" può sparire o apparire retroattivamente. -- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non - che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo - applicativo (vale per MVP, pagelle e badge social). - Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando browser o dispositivo. +**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11 +garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il +votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo +direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per +`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa +convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che +`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente +possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può +ancora correggere un voto anche fuori convocazione o dopo la chiusura. + --- ## Evoluzioni possibili diff --git a/docs/modules/mvp.md b/docs/modules/mvp.md index 8d11dbf..129b403 100644 --- a/docs/modules/mvp.md +++ b/docs/modules/mvp.md @@ -48,9 +48,15 @@ restano nel database ma non vengono più letti da nessuna schermata). - Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente. - 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 - vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero - presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo - applicativi — chi scrive su PostgREST li aggira. + 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 + 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 + può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo + applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti** + (non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto + della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento + (`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque. - In caso di parità, nessun MVP viene assegnato per quella partita. --- diff --git a/docs/modules/pagelle.md b/docs/modules/pagelle.md index 2db540c..0277757 100644 --- a/docs/modules/pagelle.md +++ b/docs/modules/pagelle.md @@ -29,9 +29,13 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`. - `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più volte, l'ultimo voto sovrascrive il precedente. - `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su - tutti i voti della stagione; `pagellePartita()` la calcola per singola partita; - `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`. -- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore. + **tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è + "la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per + singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in + `squadra.tsx`. +- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al + numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi + [badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti. --- @@ -39,25 +43,27 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`. - Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI). - L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di - voto in UI. + voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS + `evento_permette_voto()`, `pagelle_voti`). +- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a + database, non solo in UI (stessa RLS di sopra). --- ## Limiti noti -- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto - "fuori tempo" resta tecnicamente possibile bypassando l'interfaccia. - **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id` sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non mostra mai il votante, ma il dato non è né aggregato né mascherato lato server. -- Nessun controllo a livello database che il votante sia realmente un convocato della - partita: solo filtro applicativo. -- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media - coincide con quel voto. +- La media mostrata nel profilo non richiede un numero minimo di voti: con un solo voto + ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`) applica invece un + minimo di voti prima di considerarla — la StatTile del profilo no. +- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno + gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori + convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore). --- ## Evoluzioni possibili - Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati. -- Far rispettare `pagelleChiuse` anche via RLS. diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 719e2c3..ba3803c 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -58,6 +58,12 @@ export type BadgeDef = { notificaPush?: string; }; +/** + * Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola + * pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica. + */ +export const VOTI_MINIMI_PAGELLA = 5; + export const badgeDefs: BadgeDef[] = [ { id: "mvp", @@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [ unita: "di media voto", icon: ClipboardCheck, soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 }, - valore: (g) => g.mediaVoto, + valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0), }, { id: "palloni", diff --git a/src/lib/crapp-data.ts b/src/lib/crapp-data.ts index 3de2b53..1abdff5 100644 --- a/src/lib/crapp-data.ts +++ b/src/lib/crapp-data.ts @@ -37,6 +37,8 @@ export type Giocatore = { mvp: number; /** Media delle pagelle ricevute dai compagni (1-10). */ mediaVoto: number; + /** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */ + votiPagella: number; /** Quante volte ha portato i palloni. */ palloni: number; /** Partite di campionato con almeno 3 cacche dichiarate. */ @@ -105,6 +107,7 @@ export const giocatori: Giocatore[] = rosaCSI serieConferme: 0, mvp: 0, mediaVoto: 0, + votiPagella: 0, infortuni: 0, ritardi: 0, palloni: 0, diff --git a/src/lib/pagelle.ts b/src/lib/pagelle.ts index 3c89270..be1f684 100644 --- a/src/lib/pagelle.ts +++ b/src/lib/pagelle.ts @@ -60,7 +60,10 @@ function arrotonda(n: number) { return Math.round(n * 10) / 10; } -/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */ +/** + * Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto + * di stagione/reset): giocatoreId -> media e numero di voti. + */ export function mediePagelle(voti: VotoPagella[]): Record { const somma: Record = {}; for (const v of voti) { diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 2f5b3ea..68e5dc1 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -91,6 +91,7 @@ export function useRosa(): Giocatore[] { serieConferme: serieConferme(g.id, eventi, tempi), mvp: mvpVinti[g.id] ?? 0, mediaVoto: medie[g.id]?.media ?? 0, + votiPagella: medie[g.id]?.voti ?? 0, palloni: palloni[g.id] ?? 0, cacche: statCacche[g.id]?.giornateTop ?? 0, cacchePartita: statCacche[g.id]?.media ?? 0, diff --git a/supabase/migrations/20260908120000_m13_convocati_e_pagelle_chiuse.sql b/supabase/migrations/20260908120000_m13_convocati_e_pagelle_chiuse.sql new file mode 100644 index 0000000..663e6b4 --- /dev/null +++ b/supabase/migrations/20260908120000_m13_convocati_e_pagelle_chiuse.sql @@ -0,0 +1,88 @@ +-- M13 — Le tabelle di voto controllano anche a database chi può votare chi. +-- +-- Due gap segnalati in `docs/modules/badge.md` (audit del modulo Badge), entrambi finora +-- solo filtri applicativi, aggirabili da chi scrive direttamente su PostgREST: +-- +-- 1. Niente impediva di votare (o essere votati) da/per un giocatore non convocato +-- all'evento — vale per pagelle_voti, mvp_voti, badge_social_voti. +-- 2. `eventi_app.pagelle_chiuse` nascondeva solo i bottoni in UI: un voto "fuori tempo" +-- restava tecnicamente possibile. +-- +-- Le policy "Ognuno gestisce i propri voti ..." di M11 vengono estese con un controllo sui +-- convocati dell'evento: `convocati` vuoto significa "tutta la rosa" (stessa convenzione di +-- `convocatiEvento()` in eventi.ts), quindi il controllo si applica solo se la lista non è +-- vuota. Le policy admin restano invariate: un amministratore può correggere un voto anche +-- per un giocatore che non risultava convocato o dopo la chiusura delle pagelle. + +CREATE OR REPLACE FUNCTION public.evento_permette_voto( + p_match_id text, p_votante_id text, p_votato_id text, p_richiede_pagelle_aperte boolean +) RETURNS boolean +LANGUAGE sql STABLE AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.eventi_app e + WHERE e.id = p_match_id + AND (cardinality(e.convocati) = 0 OR p_votante_id = ANY(e.convocati)) + AND (cardinality(e.convocati) = 0 OR p_votato_id = ANY(e.convocati)) + AND (NOT p_richiede_pagelle_aperte OR NOT e.pagelle_chiuse) + ); +$$; + +-- --- pagelle: anche convocazione e chiusura -------------------------------------------- +DROP POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti; + +CREATE POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti + FOR ALL TO authenticated + USING ( + 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, true) + ) + 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, true) + ); + +-- --- MVP: solo convocazione, la votazione non ha un flag di chiusura ------------------- +DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti; + +CREATE POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti + FOR ALL TO authenticated + USING ( + 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) + ) + 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) + ); + +-- --- badge social: solo convocazione, stesso motivo ------------------------------------- +DROP POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti; + +CREATE POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti + FOR ALL TO authenticated + USING ( + 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) + ) + 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) + ); diff --git a/test/integration/pagella-badge.test.ts b/test/integration/pagella-badge.test.ts new file mode 100644 index 0000000..b66f34e --- /dev/null +++ b/test/integration/pagella-badge.test.ts @@ -0,0 +1,146 @@ +/** + * Badge Pagellone end-to-end contro il database locale: `bun test/integration/pagella-badge.test.ts`. + * + * I test unitari (`test/unit/pagelle.test.ts`, `test/unit/badges.test.ts`) verificano + * `mediePagelle()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui invece + * si scrivono voti veri su `pagelle_voti`, si rileggono via REST con la stessa selezione di + * `usePagelle()`, e si passa il risultato attraverso `mediePagelle()` fino a `statoBadge()` sul + * badge `pagella`: se una colonna cambia nome o la mappatura si rompe, qui il grado del badge + * torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano mai il + * database. + * + * Copre in particolare la soglia minima di voti (`VOTI_MINIMI_PAGELLA`, aggiunta per il gap + * "un solo voto sblocca/toglie il badge" segnalato in `docs/modules/badge.md`): con voti reali + * letti dal database, non solo con numeri scelti a mano. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-pagella-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeDefs, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; +import { mediePagelle, type VotoPagella } from "@/lib/pagelle"; +import { giocatori, type Giocatore } from "@/lib/crapp-data"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("badge Pagellone sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("pagella-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Pagellone su ${URL_BASE}`); + + const PREFISSO = "test-pagella-badge"; + const pagellaDef = badgeDefs.find((b) => b.id === "pagella")!; + + const rest = (percorso: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + async function upsert(riga: VotoPagella) { + const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify(riga), + }); + if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`); + } + + /** Rilegge esattamente come `usePagelle()`. */ + async function leggiVoti(): Promise { + const res = await rest( + `pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`, + ); + return (await res.json()) as VotoPagella[]; + } + + /** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */ + function giocatoreAzzerato(mediaVoto: number, votiPagella: number): Giocatore { + return { ...giocatori[0]!, mediaVoto, votiPagella, mvp: 0, palloni: 0, presenze: 0 }; + } + + try { + await prova( + `sotto ${VOTI_MINIMI_PAGELLA} voti il badge resta bloccato anche con media alta`, + async () => { + // "pg1" riceve 4 voti da 9-10 (media altissima) ma sotto la soglia minima di voti: + // il badge non deve sbloccarsi nonostante la media sarebbe oro. + for (const [i, votante] of ["va", "vb", "vc", "vd"].entries()) { + await upsert({ + match_id: `${PREFISSO}-m${i + 1}`, + votante_id: votante, + votato_id: "pg1", + voto: 9, + }); + } + + const voti = await leggiVoti(); + const medie = mediePagelle(voti); + assert.equal(medie["pg1"]?.voti, 4, "4 voti scritti, 4 riletti"); + assert.equal(medie["pg1"]?.media, 9, "media alta"); + + const badgePg1 = statoBadge( + pagellaDef, + giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti), + ); + assert.equal(badgePg1.grado, null, "4 voti < 5: il badge resta bloccato"); + }, + ); + + await prova( + `al ${VOTI_MINIMI_PAGELLA}° voto il badge Pagellone si sblocca con il grado giusto`, + async () => { + // Un quinto voto a "pg1" (stesso giocatore del test precedente) fa scattare la + // soglia minima: la media (9) sblocca subito l'oro (soglia 8.5). + await upsert({ + match_id: `${PREFISSO}-m5`, + votante_id: "ve", + votato_id: "pg1", + voto: 9, + }); + + const medie = mediePagelle(await leggiVoti()); + assert.equal(medie["pg1"]?.voti, 5); + const badgePg1 = statoBadge( + pagellaDef, + giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti), + ); + assert.equal(badgePg1.grado, "oro", "5 voti raggiunti: la media conta, è oro"); + }, + ); + + await prova("le soglie di grado normali si applicano sopra il minimo di voti", async () => { + // "pg2" riceve 5 voti che fanno una media di bronzo (6.5), non oro: verifica che sopra + // la soglia minima il grado dipenda ancora dalla media, non solo dal numero di voti. + for (const [i, votante] of ["va", "vb", "vc", "vd", "ve"].entries()) { + await upsert({ + match_id: `${PREFISSO}-n${i + 1}`, + votante_id: votante, + votato_id: "pg2", + voto: i < 4 ? 6 : 9, // (6*4+9)/5 = 6.6 -> arrotondato 6.6, sopra 6.5 + }); + } + const medie = mediePagelle(await leggiVoti()); + assert.equal(medie["pg2"]?.voti, 5); + assert.equal(medie["pg2"]?.media, 6.6); + const badgePg2 = statoBadge( + pagellaDef, + giocatoreAzzerato(medie["pg2"]!.media, medie["pg2"]!.voti), + ); + assert.equal(badgePg2.grado, "bronzo", "media 6.6: bronzo, non oro"); + }); + } finally { + await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" }); + } + + riepilogo("pagella-badge"); +} diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index ce047e8..4a46dbd 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -406,6 +406,142 @@ if (!locale) { assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro"); }); + // M13: le tabelle di voto controllano anche a database chi può votare chi, non solo + // chi firma il voto. Prima di M13 un convocato poteva votare/essere votato in un + // evento a cui non aveva partecipato, e un voto pagella restava possibile anche a + // `pagelle_chiuse` — entrambi filtri solo applicativi (segnalati in `badge.md`). + // Nota: `tokenAdmin` non va usato per queste prove, la policy admin di M11 non ha il + // controllo sui convocati (l'admin corregge anche dati fuori convocazione di + // proposito) e farebbe passare tutto a prescindere, senza provare niente sulla nuova + // policy. Si usa solo `tokenGiocatore` (g1), un votante non-admin vero. + const EVENTO_SENZA_G1 = `${PREFISSO}-evento-senza-g1`; + const EVENTO_CON_G1 = `${PREFISSO}-evento-con-g1`; + const EVENTO_CHIUSO = `${PREFISSO}-evento-chiuso`; + + await prova("un votante non convocato non può votare", async () => { + const creato = await rest("eventi_app", tokenAdmin, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + id: EVENTO_SENZA_G1, + tipo: "partita", + titolo: "Partita senza g1 tra i convocati", + data: "2026-01-02", + ora: "20:00", + luogo: "Palestra", + convocati: ["g2", "g5"], + }), + }); + assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea"); + + const votanteEscluso = await rest("pagelle_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO_SENZA_G1, + votante_id: "g1", + votato_id: "g2", + voto: 7, + }), + }); + assert.ok(!votanteEscluso.ok, `g1 non era convocato, non vota (${votanteEscluso.status})`); + }); + + await prova("un votante convocato non può votare chi non lo era", async () => { + const creato = await rest("eventi_app", tokenAdmin, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + id: EVENTO_CON_G1, + tipo: "partita", + titolo: "Partita con g1 convocato, g2 no", + data: "2026-01-02", + ora: "20:00", + luogo: "Palestra", + convocati: ["g1", "g5"], + }), + }); + assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea"); + + const votatoEscluso = await rest("badge_social_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO_CON_G1, + categoria: "cuore", + votante_id: "g1", + votato_id: "g2", + votato_nome: "Due", + }), + }); + assert.ok( + !votatoEscluso.ok, + `g2 non era convocato, non è votabile (${votatoEscluso.status})`, + ); + + // Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui + // convocato — senza questo, il test sopra potrebbe fallire per un altro motivo + // (es. un evento inesistente) e sembrare comunque corretto. + const votoValido = await rest("mvp_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO_CON_G1, + votante_id: "g1", + votato_id: "g5", + votato_nome: "Cinque", + }), + }); + assert.equal(await righeToccate(votoValido), 1, "votante e votato convocati: il voto passa"); + }); + + await prova("pagelle_chiuse blocca anche a database, non solo in UI", async () => { + const creato = await rest("eventi_app", tokenAdmin, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + id: EVENTO_CHIUSO, + tipo: "partita", + titolo: "Partita con pagelle chiuse", + data: "2026-01-03", + ora: "20:00", + luogo: "Palestra", + pagelle_chiuse: true, + }), + }); + assert.equal(await righeToccate(creato), 1, "l'evento con pagelle chiuse si crea"); + + const pagellaFuoriTempo = await rest("pagelle_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO_CHIUSO, + votante_id: "g1", + votato_id: "g5", + voto: 7, + }), + }); + assert.ok( + !pagellaFuoriTempo.ok, + `pagelle chiuse: voto rifiutato (${pagellaFuoriTempo.status})`, + ); + + // 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 + // votabili sullo stesso evento. + const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO_CHIUSO, + votante_id: "g1", + votato_id: "g5", + votato_nome: "Cinque", + }), + }); + assert.equal(await righeToccate(mvpAncoraAperto), 1, "l'MVP non ha un flag di chiusura"); + }); + // Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché // nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo // Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine. diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts index 37b4ed9..3cab238 100644 --- a/test/unit/badges.test.ts +++ b/test/unit/badges.test.ts @@ -25,6 +25,7 @@ function g(valori: Partial = {}): Giocatore { presenze: 0, mvp: 0, mediaVoto: 0, + votiPagella: 0, palloni: 0, cacche: 0, cacchePartita: 0, @@ -66,6 +67,29 @@ const pagella = badgeDefs.find((b) => b.id === "pagella")!; assert.equal(gradoRaggiunto(pagella, 6.4), null); assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo"); +// Sotto i voti minimi il badge resta bloccato anche con una media altissima: un voto solo +// non deve poter sbloccare/sfilare Pagellone. +assert.equal( + statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 1 })).grado, + null, + "1 voto su 10: non basta, sotto la soglia minima", +); +assert.equal( + statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 4 })).grado, + null, + "4 voti: ancora sotto la soglia minima (5)", +); +assert.equal( + statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 5 })).grado, + "oro", + "5 voti: la soglia minima è raggiunta, la media conta", +); +assert.equal( + statoBadge(pagella, g({ mediaVoto: 6.5, votiPagella: 5 })).grado, + "bronzo", + "sopra la soglia minima, valgono le normali soglie di grado", +); + // --- badgeGiocatore ---------------------------------------------------------- assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.ok( @@ -116,7 +140,9 @@ assert.equal(vuota.sbloccati.length, 0); assert.equal(vuota.inProgresso.length, badgeDefs.length); assert.equal(vuota.nascosti, badgeSegreti.length); -const piena = collezioneBadge(g({ mvp: 5, mediaVoto: 8, presenze: 30, infortuni: 3 })); +const piena = collezioneBadge( + g({ mvp: 5, mediaVoto: 8, votiPagella: 5, presenze: 30, infortuni: 3 }), +); assert.equal( piena.ottenuti, piena.sbloccati.length + piena.segreti.length, @@ -133,6 +159,7 @@ assert.equal( g({ mvp: 5, mediaVoto: 10, + votiPagella: 5, palloni: 10, presenze: 30, serieAllenamenti: 10,