Aggiunge il ruolo allenatore al database (M20, M21)
L'allenatore è uno slot di giocatori_squadra con tipo = 'allenatore' (DD-034): - M20 aggiunge il valore allenatore ad app_role, da solo perché Postgres non lascia usare un valore di enum nella transazione che lo crea; - M21 aggiunge la colonna tipo, rende numero nullabile solo per gli allenatori, blocca tipo nel trigger sugli slot e lascia all'allenatore nome e cognome del proprio slot; un trigger tiene allineato il ruolo in user_roles al collegamento; - eventi_app scrivibile da admin e allenatori; presenze, cacche e voti chiusi all'allenatore; i promemoria evento lo includono sempre. Compatibile con la 0.9.2: senza allenatori registrati non cambia nulla. Già applicata in produzione con supabase db push; g18 «Beta Tester» è allenatore. Il test permessi-allenatore verifica le regole contro il database locale. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda4aff746
commit
15a010855e
6 files changed
+601
-42
No files matched your search
+14
-8
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 15/09/2026
|
Ultimo aggiornamento: 24/09/2026
|
||||||
|
|
||||||
## Stato generale
|
## Stato generale
|
||||||
|
|
||||||
@@ -23,10 +23,8 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
|||||||
- Cursor e Claude Code come ambienti di sviluppo
|
- Cursor e Claude Code come ambienti di sviluppo
|
||||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||||
- 30 migration in `supabase/migrations/`, fino a `m19_backfill_nascita_da_profili_esistenti`
|
- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte
|
||||||
(15/09/2026). `m17_notifiche_utente` e `m18_nascita_pubblica_giocatori_squadra` sono state
|
applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026)
|
||||||
applicate in produzione lo stesso giorno (`supabase db push`); `m19` (fix del backfill di
|
|
||||||
M18, vedi sotto) è ancora solo in locale
|
|
||||||
- Sviluppo locale verificato con il nuovo Supabase
|
- Sviluppo locale verificato con il nuovo Supabase
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -54,9 +52,17 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
|||||||
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
|
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
|
||||||
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
|
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
|
||||||
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
|
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
|
||||||
backfill di M18 per tutti i profili già esistenti — **non ancora applicata in produzione**,
|
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
|
||||||
va fatta con `supabase db push` appena possibile (M18 da sola in produzione lascia la
|
in `npx supabase migration list` del 24/09/2026)
|
||||||
nascita vuota per chi non è nel seed storico e non risalva il profilo)
|
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
|
||||||
|
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
|
||||||
|
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
|
||||||
|
produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori
|
||||||
|
registrati non cambiano nulla. Il codice che le usa non è ancora deployato
|
||||||
|
- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna
|
||||||
|
`tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore»
|
||||||
|
lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare
|
||||||
|
da `/admin` dopo il deploy del codice nuovo
|
||||||
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
||||||
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
||||||
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
||||||
|
|||||||
+33
-32
@@ -7,32 +7,33 @@ non in questo file.
|
|||||||
|
|
||||||
## Permessi di scrittura
|
## Permessi di scrittura
|
||||||
|
|
||||||
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura**
|
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
|
||||||
|
allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
|
||||||
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
|
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
|
||||||
nessuna di esse da M4 (DD-011).
|
nessuna di esse da M4 (DD-011).
|
||||||
|
|
||||||
| Tabella | Chi può scrivere |
|
| Tabella | Chi può scrivere |
|
||||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
|
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
|
| `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`), 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`); 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 |
|
||||||
| `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 |
|
||||||
| `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) |
|
| `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 |
|
| `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.
|
||||||
|
|
||||||
## Anagrafica e utenti
|
## Anagrafica e utenti
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. |
|
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. |
|
||||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). |
|
||||||
|
|
||||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||||
|
|
||||||
@@ -45,12 +46,12 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
|||||||
|
|
||||||
## Eventi e presenze
|
## Eventi e presenze
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
||||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
||||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||||
|
|
||||||
## Scout
|
## Scout
|
||||||
|
|
||||||
@@ -62,20 +63,20 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
|||||||
|
|
||||||
## Votazioni
|
## Votazioni
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| 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`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
| `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 originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `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 originari della tabella; 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`). |
|
| `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
|
## Turni e notifiche
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
||||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||||
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
||||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin. Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
|
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
|
||||||
|
|
||||||
## Funzioni speciali
|
## Funzioni speciali
|
||||||
|
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
Enums: {
|
Enums: {
|
||||||
app_role: "admin" | "user";
|
app_role: "admin" | "user" | "allenatore";
|
||||||
};
|
};
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
@@ -762,7 +762,7 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
app_role: ["admin", "user"],
|
app_role: ["admin", "user", "allenatore"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- M20 — Nuovo valore `allenatore` per `app_role` (DD-034).
|
||||||
|
--
|
||||||
|
-- Sta da solo perché Postgres non permette di usare un valore di enum nella stessa
|
||||||
|
-- transazione che lo aggiunge: le policy e le funzioni che lo citano stanno in M21.
|
||||||
|
|
||||||
|
ALTER TYPE public.app_role ADD VALUE IF NOT EXISTS 'allenatore';
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
-- M21 — Ruolo allenatore (DD-034, docs/modules/allenatore.md).
|
||||||
|
--
|
||||||
|
-- L'allenatore è una riga di giocatori_squadra con `tipo = 'allenatore'`: si collega per
|
||||||
|
-- email come i giocatori (DD-018) e riusa profilo, avatar e compleanno. Non gioca, quindi
|
||||||
|
-- non ha numero di maglia né ruolo in campo, non risponde alle presenze, non dichiara
|
||||||
|
-- cacche, non vota e non viene votato. Gestisce gli eventi come un admin.
|
||||||
|
--
|
||||||
|
-- Il permesso vive in user_roles (DD-011) come valore `allenatore` di app_role (M20). Non
|
||||||
|
-- lo scrive nessuno a mano: lo tiene allineato un trigger su giocatori_squadra, quindi è
|
||||||
|
-- l'admin a concederlo registrando lo slot, e l'allenatore non può auto-promuoversi perché
|
||||||
|
-- `tipo` lo cambia solo un admin.
|
||||||
|
|
||||||
|
-- --- Colonna tipo --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE public.giocatori_squadra
|
||||||
|
ADD COLUMN tipo text NOT NULL DEFAULT 'giocatore'
|
||||||
|
CONSTRAINT giocatori_squadra_tipo_valido CHECK (tipo IN ('giocatore', 'allenatore'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.giocatori_squadra.tipo IS
|
||||||
|
'giocatore (in rosa, convocabile, votabile) oppure allenatore (gestisce gli eventi, fuori '
|
||||||
|
'dalla rosa di gioco). Scrivibile solo da un admin (DD-034).';
|
||||||
|
|
||||||
|
-- L'allenatore non ha numero di maglia: resta obbligatorio solo per i giocatori.
|
||||||
|
ALTER TABLE public.giocatori_squadra ALTER COLUMN numero DROP NOT NULL;
|
||||||
|
ALTER TABLE public.giocatori_squadra
|
||||||
|
ADD CONSTRAINT giocatori_squadra_numero_giocatori
|
||||||
|
CHECK (tipo = 'allenatore' OR numero IS NOT NULL);
|
||||||
|
|
||||||
|
-- --- Trigger sugli slot: tipo solo admin, nome e cognome anche all'allenatore ---------------
|
||||||
|
--
|
||||||
|
-- Stessi due rami di M18 (reclamo dello slot libero, sync della nascita sul proprio slot),
|
||||||
|
-- con `tipo` tra i campi bloccati. Il secondo ramo lascia cambiare nome e cognome solo se
|
||||||
|
-- lo slot è di un allenatore: per un giocatore restano dati squadra gestiti dall'admin.
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF OLD.auth_user_id IS NULL
|
||||||
|
AND NEW.auth_user_id = auth.uid()
|
||||||
|
AND OLD.email IS NOT NULL
|
||||||
|
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
||||||
|
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||||
|
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
|
||||||
|
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
|
||||||
|
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||||
|
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||||
|
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||||
|
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||||
|
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
|
||||||
|
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
|
||||||
|
AND NEW.tipo IS NOT DISTINCT FROM OLD.tipo
|
||||||
|
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Slot già collegato al chiamante: cambia `nascita` (sync di M18) e, se è un allenatore,
|
||||||
|
-- nome e cognome.
|
||||||
|
IF OLD.auth_user_id IS NOT NULL
|
||||||
|
AND OLD.auth_user_id = auth.uid()
|
||||||
|
AND NEW.auth_user_id IS NOT DISTINCT FROM OLD.auth_user_id
|
||||||
|
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||||
|
AND (OLD.tipo = 'allenatore' OR NEW.nome IS NOT DISTINCT FROM OLD.nome)
|
||||||
|
AND (OLD.tipo = 'allenatore' OR NEW.cognome IS NOT DISTINCT FROM OLD.cognome)
|
||||||
|
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||||
|
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||||
|
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||||
|
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||||
|
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
|
||||||
|
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
|
||||||
|
AND NEW.tipo IS NOT DISTINCT FROM OLD.tipo
|
||||||
|
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Fino a qui un giocatore non poteva aggiornare una riga già collegata (solo il reclamo di
|
||||||
|
-- uno slot libero, M1): l'allenatore ora può farlo sulla propria, per nome e cognome. Il
|
||||||
|
-- trigger sopra decide quali campi.
|
||||||
|
CREATE POLICY "L'allenatore aggiorna il proprio slot" ON public.giocatori_squadra
|
||||||
|
FOR UPDATE TO authenticated
|
||||||
|
USING (auth_user_id = auth.uid() AND tipo = 'allenatore')
|
||||||
|
WITH CHECK (auth_user_id = auth.uid() AND tipo = 'allenatore');
|
||||||
|
|
||||||
|
-- --- Ruolo in user_roles allineato allo slot ---------------------------------------------
|
||||||
|
--
|
||||||
|
-- Un account ha il ruolo `allenatore` finché è collegato a uno slot attivo di tipo
|
||||||
|
-- allenatore. Scollegare, disattivare o cambiare tipo lo toglie.
|
||||||
|
CREATE OR REPLACE FUNCTION public.sincronizza_ruolo_allenatore()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'UPDATE' AND OLD.auth_user_id IS NOT NULL
|
||||||
|
AND (NEW.auth_user_id IS DISTINCT FROM OLD.auth_user_id
|
||||||
|
OR NEW.tipo <> 'allenatore' OR NOT NEW.attivo) THEN
|
||||||
|
DELETE FROM public.user_roles
|
||||||
|
WHERE user_id = OLD.auth_user_id AND role = 'allenatore'::public.app_role;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.auth_user_id IS NOT NULL AND NEW.tipo = 'allenatore' AND NEW.attivo THEN
|
||||||
|
INSERT INTO public.user_roles (user_id, role)
|
||||||
|
VALUES (NEW.auth_user_id, 'allenatore'::public.app_role)
|
||||||
|
ON CONFLICT (user_id, role) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER sincronizza_ruolo_allenatore
|
||||||
|
AFTER INSERT OR UPDATE OF auth_user_id, tipo, attivo ON public.giocatori_squadra
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.sincronizza_ruolo_allenatore();
|
||||||
|
|
||||||
|
-- --- Eventi: admin o allenatore -----------------------------------------------------------
|
||||||
|
DROP POLICY "Gli admin gestiscono gli eventi" ON public.eventi_app;
|
||||||
|
|
||||||
|
CREATE POLICY "Admin e allenatori gestiscono gli eventi" ON public.eventi_app
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||||
|
OR public.has_role(auth.uid(), 'allenatore'::public.app_role)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||||
|
OR public.has_role(auth.uid(), 'allenatore'::public.app_role)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --- Presenze e cacche: solo i giocatori rispondono per sé --------------------------------
|
||||||
|
DROP POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze;
|
||||||
|
|
||||||
|
CREATE POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita;
|
||||||
|
|
||||||
|
CREATE POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --- Voti: l'allenatore non vota e non viene votato ---------------------------------------
|
||||||
|
--
|
||||||
|
-- Stessa firma di M13, quindi le policy di pagelle, MVP e badge social la usano già.
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id IN (p_votante_id, p_votato_id) AND g.tipo = 'allenatore'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- --- Promemoria evento: anche agli allenatori, convocati o no -----------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text)
|
||||||
|
RETURNS SETOF text
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT g.id
|
||||||
|
FROM public.eventi_app e
|
||||||
|
JOIN public.giocatori_squadra g
|
||||||
|
ON g.attivo = true
|
||||||
|
AND (g.tipo = 'allenatore' OR cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
|
||||||
|
WHERE e.id = p_evento_id;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Permessi del ruolo allenatore (DD-034, migration M21): `bun test/integration/permessi-allenatore.test.ts`.
|
||||||
|
*
|
||||||
|
* Stesso impianto di `permessi.test.ts`: utenti veri, database interrogato *come loro*,
|
||||||
|
* solo contro lo stack locale. L'allenatore è uno slot di `giocatori_squadra` con
|
||||||
|
* `tipo = 'allenatore'`, creato dall'admin e reclamato per email al primo accesso.
|
||||||
|
*
|
||||||
|
* 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`.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("permessi allenatore", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("permessi-allenatore");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`permessi allenatore su ${URL_BASE}`);
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const righeToccate = async (res: Response): Promise<number> => {
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const corpo = (await res.json()) as unknown[];
|
||||||
|
return Array.isArray(corpo) ? corpo.length : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function creaUtente(email: string, password: string): Promise<string> {
|
||||||
|
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
apikey: SERVIZIO,
|
||||||
|
Authorization: `Bearer ${SERVIZIO}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password, email_confirm: true }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { id?: string };
|
||||||
|
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||||
|
return corpo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accedi(email: string, password: string): Promise<string> {
|
||||||
|
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { apikey: ANON, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { access_token?: string };
|
||||||
|
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
|
||||||
|
return corpo.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eliminaUtente = (id: string) =>
|
||||||
|
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ruoliDi = async (idUtente: string): Promise<string[]> => {
|
||||||
|
const res = await rest(`user_roles?user_id=eq.${idUtente}&select=role`, SERVIZIO);
|
||||||
|
return ((await res.json()) as Array<{ role: string }>).map((r) => r.role);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PASSWORD = "prova-allenatore-123";
|
||||||
|
const PREFISSO = "test-allenatore";
|
||||||
|
const SLOT = "g9001";
|
||||||
|
const GIOCATORE = "g3";
|
||||||
|
const EVENTO = `${PREFISSO}-evento`;
|
||||||
|
const idUtenti: string[] = [];
|
||||||
|
let tokenAdmin = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oggi = Date.now();
|
||||||
|
const emailAllenatore = `test-allenatore-${oggi}@example.test`;
|
||||||
|
const emailAdmin = `test-admin-all-${oggi}@example.test`;
|
||||||
|
const emailGiocatore = `test-giocatore-all-${oggi}@example.test`;
|
||||||
|
const idAllenatore = await creaUtente(emailAllenatore, PASSWORD);
|
||||||
|
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
|
||||||
|
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
|
||||||
|
idUtenti.push(idAllenatore, idAdmin, idGiocatore);
|
||||||
|
|
||||||
|
await rest("user_roles", SERVIZIO, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||||
|
});
|
||||||
|
tokenAdmin = await accedi(emailAdmin, PASSWORD);
|
||||||
|
const tokenAllenatore = await accedi(emailAllenatore, PASSWORD);
|
||||||
|
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
|
||||||
|
|
||||||
|
const collegaGiocatore = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ auth_user_id: idGiocatore }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(collegaGiocatore), 1, `${GIOCATORE} collegato al test`);
|
||||||
|
|
||||||
|
await prova("l'admin registra un allenatore senza numero né ruolo", async () => {
|
||||||
|
const res = await rest("giocatori_squadra", tokenAdmin, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: SLOT,
|
||||||
|
nome: "Carla",
|
||||||
|
cognome: "Neri",
|
||||||
|
numero: null,
|
||||||
|
ruolo: "",
|
||||||
|
email: emailAllenatore,
|
||||||
|
tipo: "allenatore",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 1, `slot allenatore creato (${res.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un giocatore senza numero resta rifiutato", async () => {
|
||||||
|
const res = await rest("giocatori_squadra", tokenAdmin, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id: "g9002", nome: "X", cognome: "Y", numero: null, ruolo: "" }),
|
||||||
|
});
|
||||||
|
assert.ok(!res.ok, `il numero è obbligatorio per chi gioca (${res.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il collegamento per email dà il ruolo allenatore", async () => {
|
||||||
|
assert.deepEqual(await ruoliDi(idAllenatore), [], "prima del collegamento nessun ruolo");
|
||||||
|
const res = await rest(
|
||||||
|
`giocatori_squadra?id=eq.${SLOT}&auth_user_id=is.null`,
|
||||||
|
tokenAllenatore,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ auth_user_id: idAllenatore }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(await righeToccate(res), 1, `slot reclamato (${res.status})`);
|
||||||
|
assert.deepEqual(await ruoliDi(idAllenatore), ["allenatore"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("l'allenatore crea, modifica ed elimina gli eventi", async () => {
|
||||||
|
const crea = await rest("eventi_app", tokenAllenatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: EVENTO,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: "Partita di prova",
|
||||||
|
data: "2026-01-01",
|
||||||
|
ora: "20:00",
|
||||||
|
luogo: "Palestra",
|
||||||
|
convocati: [GIOCATORE],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(crea), 1, `evento creato (${crea.status})`);
|
||||||
|
|
||||||
|
const modifica = await rest(`eventi_app?id=eq.${EVENTO}`, tokenAllenatore, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ luogo: "Altra palestra" }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(modifica), 1, "evento modificato");
|
||||||
|
|
||||||
|
const altro = await rest("eventi_app", tokenAllenatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: `${PREFISSO}-da-cancellare`,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: "Da cancellare",
|
||||||
|
data: "2026-01-02",
|
||||||
|
ora: "20:00",
|
||||||
|
luogo: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(altro), 1);
|
||||||
|
const cancella = await rest(`eventi_app?id=eq.${PREFISSO}-da-cancellare`, tokenAllenatore, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(cancella), 1, "evento eliminato");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il giocatore continua a non gestire gli eventi", async () => {
|
||||||
|
const res = await rest(`eventi_app?id=eq.${EVENTO}`, tokenGiocatore, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ luogo: "Abusivo" }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("l'allenatore cambia il proprio nome e cognome", async () => {
|
||||||
|
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAllenatore, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ nome: "Carlotta", cognome: "Neri Bianchi" }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 1, `nome aggiornato (${res.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("l'allenatore non cambia il proprio tipo né gli altri campi admin", async () => {
|
||||||
|
for (const campi of [{ tipo: "giocatore" }, { numero: 5 }, { email: "altra@example.test" }]) {
|
||||||
|
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAllenatore, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify(campi),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 0, `rifiutato: ${JSON.stringify(campi)}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(await ruoliDi(idAllenatore), ["allenatore"], "il ruolo resta");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("l'allenatore non scrive lo slot di un giocatore", async () => {
|
||||||
|
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAllenatore, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ nome: "Abusivo" }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("il giocatore non cambia il proprio nome", async () => {
|
||||||
|
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenGiocatore, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ nome: "Abusivo" }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 0, "per chi gioca nome e cognome restano all'admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("l'allenatore non risponde alle presenze né dichiara cacche", async () => {
|
||||||
|
const presenza = await rest("risposte_presenze", tokenAllenatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: SLOT, stato: "presente" }),
|
||||||
|
});
|
||||||
|
assert.ok(!presenza.ok, `presenza rifiutata (${presenza.status})`);
|
||||||
|
|
||||||
|
const cacche = await rest("cacche_partita", tokenAllenatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: SLOT, quantita: 1 }),
|
||||||
|
});
|
||||||
|
assert.ok(!cacche.ok, `cacche rifiutate (${cacche.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("l'allenatore non vota e non viene votato", async () => {
|
||||||
|
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
|
||||||
|
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ convocati: [] }),
|
||||||
|
});
|
||||||
|
const vota = await rest("mvp_voti", tokenAllenatore, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO,
|
||||||
|
votante_id: SLOT,
|
||||||
|
votato_id: GIOCATORE,
|
||||||
|
votato_nome: "Tre",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(!vota.ok, `voto dell'allenatore rifiutato (${vota.status})`);
|
||||||
|
|
||||||
|
const votato = await rest("mvp_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO,
|
||||||
|
votante_id: GIOCATORE,
|
||||||
|
votato_id: SLOT,
|
||||||
|
votato_nome: "Allenatore",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(!votato.ok, `l'allenatore non si vota (${votato.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("i promemoria evento arrivano anche all'allenatore non convocato", async () => {
|
||||||
|
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ convocati: [GIOCATORE] }),
|
||||||
|
});
|
||||||
|
const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ p_evento_id: EVENTO }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const destinatari = ((await res.json()) as Array<string | Record<string, string>>).map((d) =>
|
||||||
|
typeof d === "string" ? d : Object.values(d)[0],
|
||||||
|
);
|
||||||
|
assert.deepEqual([...destinatari].sort(), [GIOCATORE, SLOT].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("scollegare l'account toglie il ruolo", async () => {
|
||||||
|
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAdmin, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ auth_user_id: null }),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(res), 1);
|
||||||
|
assert.deepEqual(await ruoliDi(idAllenatore), []);
|
||||||
|
const crea = await rest("eventi_app", tokenAllenatore, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: `${PREFISSO}-dopo`,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: "Non deve esistere",
|
||||||
|
data: "2026-01-03",
|
||||||
|
ora: "20:00",
|
||||||
|
luogo: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(!crea.ok, `senza ruolo niente eventi (${crea.status})`);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`mvp_voti?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await rest(`giocatori_squadra?id=in.(${SLOT},g9002)`, SERVIZIO, { method: "DELETE" });
|
||||||
|
if (tokenAdmin) {
|
||||||
|
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ auth_user_id: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const id of idUtenti) {
|
||||||
|
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||||
|
await eliminaUtente(id);
|
||||||
|
}
|
||||||
|
riepilogo("permessi-allenatore");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user