From 15a010855eeca4df3643c0fe8b9cc04ea320b688 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 24 Sep 2026 14:54:24 +0200 Subject: [PATCH] Aggiunge il ruolo allenatore al database (M20, M21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- PROJECT_STATE.md | 22 +- docs/DATABASE.md | 65 ++-- src/integrations/supabase/types.ts | 4 +- ...260924120000_m20_ruolo_allenatore_enum.sql | 6 + .../20260924120100_m21_ruolo_allenatore.sql | 206 +++++++++++ test/integration/permessi-allenatore.test.ts | 340 ++++++++++++++++++ 6 files changed, 601 insertions(+), 42 deletions(-) create mode 100644 supabase/migrations/20260924120000_m20_ruolo_allenatore_enum.sql create mode 100644 supabase/migrations/20260924120100_m21_ruolo_allenatore.sql create mode 100644 test/integration/permessi-allenatore.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 741c9ad..6786403 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 15/09/2026 +Ultimo aggiornamento: 24/09/2026 ## 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 - Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production) - Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes` -- 30 migration in `supabase/migrations/`, fino a `m19_backfill_nascita_da_profili_esistenti` - (15/09/2026). `m17_notifiche_utente` e `m18_nascita_pubblica_giocatori_squadra` sono state - applicate in produzione lo stesso giorno (`supabase db push`); `m19` (fix del backfill di - M18, vedi sotto) è ancora solo in locale +- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte + applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026) - 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 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 - backfill di M18 per tutti i profili già esistenti — **non ancora applicata in produzione**, - va fatta con `supabase db push` appena possibile (M18 da sola in produzione lascia la - nascita vuota per chi non è nel seed storico e non risalva il profilo) + backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata + in `npx supabase migration list` del 24/09/2026) +- 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`) 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 diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 1564373..67056d0 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -7,32 +7,33 @@ non in questo file. ## 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 nessuna di esse da M4 (DD-011). -| Tabella | Chi può scrivere | -| ---------------------------------------------------------------- | ------------------------------------------------------------------- | -| `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`), 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 | -| `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) | -| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) | -| `user_roles` | solo admin | +| Tabella | Chi può scrivere | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | +| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin | +| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | +| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | +| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | +| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | +| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | +| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) | 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. ## Anagrafica e utenti -| 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` | 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. | -| `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). | +| 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. 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). | +| `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). 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. @@ -45,12 +46,12 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa ## Eventi e presenze -| 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. | -| `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). | -| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | +| 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. | +| `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). | +| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | ## Scout @@ -62,20 +63,20 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa ## 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`). | -| `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`). | ## Turni e notifiche -| Tabella | Scopo | Note | -| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | -| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | -| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | -| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin. 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. | +| Tabella | Scopo | Note | +| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | +| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. | ## Funzioni speciali diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index abf4dbf..899b26b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -637,7 +637,7 @@ export type Database = { }; }; Enums: { - app_role: "admin" | "user"; + app_role: "admin" | "user" | "allenatore"; }; CompositeTypes: { [_ in never]: never; @@ -762,7 +762,7 @@ export const Constants = { }, public: { Enums: { - app_role: ["admin", "user"], + app_role: ["admin", "user", "allenatore"], }, }, } as const; diff --git a/supabase/migrations/20260924120000_m20_ruolo_allenatore_enum.sql b/supabase/migrations/20260924120000_m20_ruolo_allenatore_enum.sql new file mode 100644 index 0000000..a2258b0 --- /dev/null +++ b/supabase/migrations/20260924120000_m20_ruolo_allenatore_enum.sql @@ -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'; diff --git a/supabase/migrations/20260924120100_m21_ruolo_allenatore.sql b/supabase/migrations/20260924120100_m21_ruolo_allenatore.sql new file mode 100644 index 0000000..41ae2bb --- /dev/null +++ b/supabase/migrations/20260924120100_m21_ruolo_allenatore.sql @@ -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; +$$; diff --git a/test/integration/permessi-allenatore.test.ts b/test/integration/permessi-allenatore.test.ts new file mode 100644 index 0000000..051f791 --- /dev/null +++ b/test/integration/permessi-allenatore.test.ts @@ -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 => { + 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 { + 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 { + 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 => { + 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>).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"); + } +}