diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7ad7ac3..61b7d79 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 09/09/2026 +Ultimo aggiornamento: 15/09/2026 ## Stato generale @@ -23,9 +23,10 @@ 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` -- 28 migration in `supabase/migrations/`, fino a `m17_notifiche_utente` (15/09/2026) — - quest'ultima testata solo in locale (`npx supabase db reset`), non ancora applicata in - produzione (`supabase db push`) +- 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 - Sviluppo locale verificato con il nuovo Supabase --- @@ -44,9 +45,18 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). (`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con `npx supabase migration list`) - Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente` - per il centro notifiche in-app, testata sullo stack locale — **non ancora applicata in - produzione**, va fatta con `supabase db push` quando si decide di rilasciare la - funzionalità (è additiva: non tocca schema o comportamento esistenti, vedi DD-030) + per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca + schema o comportamento esistenti, vedi DD-030). Il codice che la usa (icona notifiche, + route aggiornate) non è ancora deployato: la tabella si riempie già in background +- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna + `giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge + l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del + 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) - `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 8c6f783..dba9d64 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -29,7 +29,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa | 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, e come sorgente della data di nascita (non ancora una colonna di questa tabella). 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. | +| `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). | diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 0577be2..139bfc5 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -1225,3 +1225,87 @@ modo per un giocatore di toglierle di torno per sempre. **Riesame** Se la tabella crescesse in modo sensibile (squadre molto più numerose, o giocatori che non fanno mai swipe), va aggiunta la pulizia automatica scartata sopra. + +### DD-031 — Data di nascita pubblica: una colonna sincronizzata, non una seconda RLS aperta + +**Data:** 15 settembre 2026 +**Stato:** Accettata + +**Contesto** +In "Squadra" la data di nascita mostrava **"Invalid Date"** per chi l'aveva inserita da +solo dal proprio Profilo. Causa: due fonti mai collegate. I 17 giocatori del seed storico +hanno la nascita in `nascitaPerId`, una mappa statica pubblica bundlata nel client +(`src/lib/crapp-data.ts`) — il commento sulla riga la descriveva esplicitamente come +stopgap: *"`giocatori_squadra` non ha ancora questa colonna (DD-015 follow-up)"*. Un +giocatore aggiunto dopo la migrazione DD-015 inserisce invece la nascita dal form Profilo, +che scrive su `profili_giocatore.data_nascita` — tabella con RLS "solo il proprio profilo o +admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()` (`src/lib/rosa.ts`), e comunque +non visibile ai compagni per via della RLS. Per quel giocatore `nascitaPerId[g.id]` era +`undefined` → `""` → `formatData("")` in `src/routes/squadra.tsx` produceva `Invalid Date`. + +**Decisione** +Migration `m18_nascita_pubblica_giocatori_squadra`: nuova colonna `giocatori_squadra.nascita` +(quella prevista da DD-015), pubblica a tutta la rosa attiva con la stessa policy di lettura +già in vigore per le altre colonne. Un trigger `SECURITY DEFINER` su `profili_giocatore` +(`sincronizza_nascita_pubblica`, AFTER INSERT/UPDATE/DELETE) la tiene allineata a +`data_nascita`: un solo input nel form Profilo (`CampiProfilo`), due destinazioni — quella +amministrativa esistente (RLS invariata: sé stesso o admin) e questo specchio pubblico. +`profili_giocatore` resta l'unica tabella che il client scrive per la nascita; il client non +tocca mai `giocatori_squadra.nascita` direttamente. + +Essendo `SECURITY DEFINER` di proprietà dell'owner della tabella, la scrittura del trigger +bypassa la RLS di `giocatori_squadra` automaticamente — nessuna nuova policy lì. Serve però +un nuovo ramo in `enforce_giocatori_squadra_update()` (il trigger `BEFORE UPDATE` che oggi +rifiuta qualunque modifica a uno slot già collegato da parte di chi non è admin): accetta un +update dove cambia **solo** `nascita` e il chiamante è il proprietario dello slot +(`OLD.auth_user_id = auth.uid()`). Senza quel ramo l'update annidato del trigger di sync +fallirebbe con l'eccezione già in vigore. + +Backfill una tantum dei 17 storici con gli stessi valori di `nascitaPerId`, poi rimozione di +quell'export (`crapp-data.ts`): era descritto fin dall'inizio come temporaneo, in attesa +esattamente di questa colonna. + +**Alternative scartate** + +- Allentare la RLS di `profili_giocatore` per rendere `data_nascita` leggibile a tutta la + squadra → scartata: quella tabella porta anche indirizzo, telefono, documento d'identità + nello stesso record — aprirne la lettura avrebbe esposto molto più della sola nascita. +- Un secondo campo "data di nascita pubblica" nel form Profilo, distinto da quello + amministrativo → scartata: due campi per lo stesso dato reale avrebbero richiesto + spiegare all'utente perché ne esistono due, con rischio di disallineamento se ne compila + uno solo. +- Far scrivere `giocatori_squadra.nascita` direttamente dal client (nuova RLS policy per il + proprietario dello slot) invece di sincronizzarla da `profili_giocatore` → scartata: la + RLS di update per un non-admin resta `USING (auth_user_id IS NULL)` (solo reclamare uno + slot libero); aprirla anche a righe già collegate avrebbe ampliato la superficie scrivibile + dal client su una tabella con un trigger di sicurezza già delicato (DD-016/DD-023), per un + guadagno che il trigger di sync ottiene comunque senza toccare la RLS. + +**Conseguenze** + +- Chiunque compili la propria data di nascita dal Profilo la vede comparire in Squadra (e + nei compleanni del Calendario) esattamente come i 17 storici — non più un dato "di serie + B". +- Cancellare il profilo (da sé o da un admin) azzera anche la nascita pubblica: non resta un + valore pubblico orfano di un profilo che non esiste più. +- Un giocatore senza nascita nota (profilo mai compilato) non genera più "Invalid Date": + `squadra.tsx` mostra la riga della data di nascita solo se `g.nascita` è valorizzata, + stesso criterio già usato da `compleanniEventi()` per escludere chi non ha ancora un dato. +- `test/integration/nascita-pubblica.test.ts` è la definizione eseguibile del comportamento: + sync alla scrittura, visibilità a un giocatore terzo (non solo l'interessato o l'admin), + azzeramento alla cancellazione, blocco della scrittura diretta dal client. + +**Riesame** +Se in futuro serve differenziare "chi può vedere la nascita" (es. solo squadra, non +pubblico) va rivista insieme alla policy di lettura generale di `giocatori_squadra`, non solo +per questa colonna. + +**Aggiornamento (15 settembre 2026, stesso giorno)** — dopo aver applicato M18 in produzione +(`supabase db push`), la nascita in Squadra compariva solo per i 17 giocatori storici, non +per chi aveva già compilato il proprio profilo prima della migration: il trigger di sync +copre solo le scritture *future* su `profili_giocatore`, non retroattivamente, e M18 +backfillava a mano solo il seed storico. Migration `m19_backfill_nascita_da_profili_esistenti` +allinea una tantum `giocatori_squadra.nascita` a `profili_giocatore.data_nascita` per tutti i +profili già esistenti, con la stessa disattivazione temporanea del trigger di sicurezza usata +in M18. Da qui in poi il gap non si può ripresentare: ogni scrittura successiva passa dal +trigger di sync, questo backfill serve solo per lo storico antecedente a M18. diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 346524a..ddfd6fe 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -91,7 +91,11 @@ Il profilo viene suddiviso in sette aree. **Dati personali** — modificabili dal giocatore. -- Data di nascita +- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in + `giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione, + visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta + Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera + anche quel valore pubblico. - Luogo di nascita - Indirizzo di residenza - Telefono diff --git a/docs/modules/squadra.md b/docs/modules/squadra.md index 517482b..12eae72 100644 --- a/docs/modules/squadra.md +++ b/docs/modules/squadra.md @@ -22,7 +22,11 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app. - **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le - liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. + liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita + viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, + DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il + giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null` + finché non è stata inserita da nessuno. - **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti, partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni, @@ -77,12 +81,7 @@ presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squ ## Limiti noti -1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i - giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts` - (`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita - nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel - Calendario per quei giocatori. -2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database +1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non diff --git a/src/lib/crapp-data.ts b/src/lib/crapp-data.ts index 22591ef..b821c2e 100644 --- a/src/lib/crapp-data.ts +++ b/src/lib/crapp-data.ts @@ -122,14 +122,6 @@ export const giocatori: Giocatore[] = rosaCSI })) .sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it")); -/** - * Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna - * (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota. - */ -export const nascitaPerId: Record = Object.fromEntries( - giocatori.map((g) => [g.id, g.nascita]), -); - export type RigaClassifica = { pos: number; squadra: string; diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts index 469b348..a07bcb7 100644 --- a/src/lib/giocatori-squadra.ts +++ b/src/lib/giocatori-squadra.ts @@ -18,6 +18,8 @@ export type GiocatoreSquadra = { email: string | null; numeroTessera: string | null; dataTessera: string | null; + /** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */ + nascita: string | null; }; export type RigaGiocatoreSquadra = { @@ -31,6 +33,7 @@ export type RigaGiocatoreSquadra = { email: string | null; numero_tessera: string | null; data_tessera: string | null; + nascita: string | null; }; /** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */ @@ -50,6 +53,7 @@ export function rosaFallback(): GiocatoreSquadra[] { email: null, numeroTessera: null, dataTessera: null, + nascita: g.nascita, })); } @@ -77,7 +81,7 @@ export function slotPerEmail( } export const COLONNE_SQUADRA = - "id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera"; + "id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita"; /** Conversione riga database -> modello applicativo (riusabile anche lato server). */ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { @@ -92,6 +96,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { email: r.email, numeroTessera: r.numero_tessera, dataTessera: r.data_tessera, + nascita: r.nascita, }; } @@ -231,7 +236,14 @@ export function useAggiungiGiocatore() { const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga); if (error) throw error; // Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL. - return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null }; + return { + ...riga, + authUserId: null, + attivo: true, + numeroTessera: null, + dataTessera: null, + nascita: null, + }; }, // Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query // (`.order("cognome").order("nome")`). diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 6f184e4..0c825d4 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { nascitaPerId, type Giocatore } from "./crapp-data"; +import type { Giocatore } from "./crapp-data"; import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti"; import { mediePagelle, usePagelle } from "./pagelle"; @@ -44,7 +44,7 @@ export function useAnagraficaRosa(): Array< nome: nomeCompleto(g), ruolo: g.ruolo, numero: g.numero, - nascita: nascitaPerId[g.id] ?? "", + nascita: g.nascita ?? "", })), [squadra], ); @@ -85,7 +85,7 @@ export function useRosa(): Giocatore[] { nome: nomeCompleto(g), numero: g.numero, ruolo: g.ruolo, - nascita: nascitaPerId[g.id] ?? "", + nascita: g.nascita ?? "", iniziali: iniziali(g.nome, g.cognome), presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze), totaliEventi: totaliEventiGiocatore(g.id, eventi), diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index 7b9442d..01bc926 100644 --- a/src/routes/squadra.tsx +++ b/src/routes/squadra.tsx @@ -169,12 +169,14 @@ function Squadra() { {isOpen ? (
-
-

- {formatData(g.nascita)}{" "} - {g.nascita.slice(0, 4)} -

-
+ {g.nascita ? ( +
+

+ {formatData(g.nascita)}{" "} + {g.nascita.slice(0, 4)} +

+
+ ) : null}
{[ diff --git a/supabase/migrations/20260915120000_m18_nascita_pubblica_giocatori_squadra.sql b/supabase/migrations/20260915120000_m18_nascita_pubblica_giocatori_squadra.sql new file mode 100644 index 0000000..007c1d4 --- /dev/null +++ b/supabase/migrations/20260915120000_m18_nascita_pubblica_giocatori_squadra.sql @@ -0,0 +1,124 @@ +-- M18 — Data di nascita pubblica a tutta la squadra (DD-031) +-- +-- In "Squadra" la data di nascita mostrava "Invalid Date" per chi l'aveva inserita da solo +-- dal proprio Profilo: quel valore finisce in `profili_giocatore.data_nascita`, tabella con +-- RLS "solo il proprio profilo o admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()` +-- (src/lib/rosa.ts). Quelle funzioni leggevano solo la mappa statica `nascitaPerId` del seed +-- storico dei 17 giocatori originali (src/lib/crapp-data.ts) — pubblica perché bundlata nel +-- client, ma senza voce per chi si è aggiunto dopo. +-- +-- Questa migration aggiunge la colonna pubblica prevista fin da DD-015 ("giocatori_squadra +-- non ha ancora questa colonna, DD-015 follow-up") e la tiene sincronizzata con +-- `profili_giocatore.data_nascita`: un solo input nel form Profilo, due destinazioni — quella +-- amministrativa esistente (RLS invariata) e questo specchio pubblico, leggibile da tutta la +-- rosa attiva con la stessa policy di sempre. + +ALTER TABLE public.giocatori_squadra ADD COLUMN nascita date; + +-- Backfill una tantum dei 17 giocatori storici (stessi valori di `rosaCSI` in +-- src/lib/crapp-data.ts, stesso ordine g1..g17 del seed di M1). Il trigger +-- `enforce_giocatori_squadra_update` (attivo dalla migration M1) rifiuterebbe questi UPDATE: +-- gira in un contesto senza `auth.uid()`, quindi non passa né il ramo admin né quello di +-- reclamazione. Va disabilitato solo per il backfill e riattivato subito dopo. +ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update; + +UPDATE public.giocatori_squadra SET nascita = '1997-08-30' WHERE id = 'g1'; +UPDATE public.giocatori_squadra SET nascita = '1996-12-07' WHERE id = 'g2'; +UPDATE public.giocatori_squadra SET nascita = '2000-04-28' WHERE id = 'g3'; +UPDATE public.giocatori_squadra SET nascita = '1995-05-01' WHERE id = 'g4'; +UPDATE public.giocatori_squadra SET nascita = '1995-08-13' WHERE id = 'g5'; +UPDATE public.giocatori_squadra SET nascita = '1996-02-01' WHERE id = 'g6'; +UPDATE public.giocatori_squadra SET nascita = '1997-07-04' WHERE id = 'g7'; +UPDATE public.giocatori_squadra SET nascita = '1994-05-28' WHERE id = 'g8'; +UPDATE public.giocatori_squadra SET nascita = '2003-04-04' WHERE id = 'g9'; +UPDATE public.giocatori_squadra SET nascita = '1998-11-30' WHERE id = 'g10'; +UPDATE public.giocatori_squadra SET nascita = '2000-02-02' WHERE id = 'g11'; +UPDATE public.giocatori_squadra SET nascita = '1999-10-03' WHERE id = 'g12'; +UPDATE public.giocatori_squadra SET nascita = '2000-09-16' WHERE id = 'g13'; +UPDATE public.giocatori_squadra SET nascita = '1996-12-13' WHERE id = 'g14'; +UPDATE public.giocatori_squadra SET nascita = '1993-03-24' WHERE id = 'g15'; +UPDATE public.giocatori_squadra SET nascita = '2001-04-18' WHERE id = 'g16'; +UPDATE public.giocatori_squadra SET nascita = '1994-05-20' WHERE id = 'g17'; + +ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update; + +-- Il trigger di sync (sotto) è SECURITY DEFINER e bypassa la RLS in scrittura su +-- giocatori_squadra (di proprietà dell'owner della tabella), ma `enforce_giocatori_squadra_update` +-- resta un trigger BEFORE UPDATE che scatta comunque: senza questo ramo, l'update annidato +-- fallirebbe con l'eccezione già in vigore per chi non è admin. +-- +-- Non apre una scrittura diretta dal client: la policy di update per un non-admin resta +-- "Users can claim unassigned roster slot" (USING auth_user_id IS NULL) — una riga già +-- collegata non la supera nemmeno, quindi l'unica strada che raggiunge questo ramo è la +-- funzione di sync qui sotto, che gira con privilegi propri e ignora la RLS. +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.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN + RETURN NEW; + END IF; + + -- Slot già collegato al chiamante: può cambiare solo `nascita` (M18). È la via percorsa + -- dal trigger di sync qui sotto, mai da un client (vedi commento sopra). + 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 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.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra'; +END; +$$; + +CREATE OR REPLACE FUNCTION public.sincronizza_nascita_pubblica() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + UPDATE public.giocatori_squadra SET nascita = NULL WHERE id = OLD.giocatore_id; + RETURN OLD; + END IF; + + UPDATE public.giocatori_squadra SET nascita = NEW.data_nascita WHERE id = NEW.giocatore_id; + RETURN NEW; +END; +$$; + +CREATE TRIGGER sincronizza_nascita_pubblica + AFTER INSERT OR UPDATE OR DELETE ON public.profili_giocatore + FOR EACH ROW + EXECUTE FUNCTION public.sincronizza_nascita_pubblica(); diff --git a/supabase/migrations/20260915130000_m19_backfill_nascita_da_profili_esistenti.sql b/supabase/migrations/20260915130000_m19_backfill_nascita_da_profili_esistenti.sql new file mode 100644 index 0000000..ebf2bc3 --- /dev/null +++ b/supabase/migrations/20260915130000_m19_backfill_nascita_da_profili_esistenti.sql @@ -0,0 +1,22 @@ +-- M19 — Backfill della nascita pubblica per i profili già esistenti (fix di M18) +-- +-- M18 ha aggiunto `giocatori_squadra.nascita` e un trigger che la sincronizza da +-- `profili_giocatore.data_nascita`, ma quel trigger scatta solo sulle scritture *future*: +-- chi aveva già compilato il proprio profilo (data_nascita) prima che M18 fosse applicata è +-- rimasto con `nascita` vuota, mentre M18 aveva backfillato a mano solo i 17 giocatori del +-- seed storico. Effetto visibile: in Squadra la data di nascita compariva solo per quei 17, +-- non per chi l'aveva inserita dal Profilo prima di oggi. +-- +-- Stesso motivo di M18 per disabilitare temporaneamente `enforce_giocatori_squadra_update`: +-- gira in un contesto senza `auth.uid()`, quindi non passerebbe nessun ramo del trigger. + +ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update; + +-- Ha la precedenza sui valori hardcoded di M18 per chi, tra i 17 storici, ha anche corretto +-- la propria data dal Profilo nel frattempo: `profili_giocatore` è la fonte più aggiornata. +UPDATE public.giocatori_squadra gs +SET nascita = pg.data_nascita +FROM public.profili_giocatore pg +WHERE pg.giocatore_id = gs.id; + +ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update; diff --git a/supabase/seed.sql b/supabase/seed.sql index 0048ecd..d74ccac 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -5,6 +5,12 @@ -- I path dei file puntano a oggetti che nel bucket non esistono: i pulsanti di download -- falliscono finché non carichi qualcosa dall'app o dallo Studio (http://127.0.0.1:54323). +-- Il trigger di sync M18 (`sincronizza_nascita_pubblica`) aggiorna `giocatori_squadra.nascita` +-- appena questo INSERT tocca `data_nascita`, ma qui gira senza un JWT reale: né il ramo admin +-- né quello del proprietario dello slot del trigger di sicurezza passerebbero. Va disabilitato +-- solo per questo seed, mai un problema in produzione dove il seed non gira. +ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update; + INSERT INTO public.profili_giocatore (giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, @@ -29,6 +35,8 @@ VALUES '2027-09-15', 'g2/certificato.pdf', NULL) ON CONFLICT (giocatore_id) DO NOTHING; +ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update; + -- Il primo amministratore non si può seminare qui: `user_roles.user_id` punta a un utente -- di `auth.users`, che su un database appena creato non esiste ancora. Dopo il primo login -- (in locale come in produzione) basta una riga: diff --git a/test/integration/nascita-pubblica.test.ts b/test/integration/nascita-pubblica.test.ts new file mode 100644 index 0000000..0edf2ef --- /dev/null +++ b/test/integration/nascita-pubblica.test.ts @@ -0,0 +1,173 @@ +/** + * Data di nascita pubblica a tutta la squadra (M18): `bun test/integration/nascita-pubblica.test.ts`. + * + * Prima di questa migration `giocatori_squadra` non aveva una colonna `nascita`: la Squadra + * mostrava "Invalid Date" per chi l'aveva inserita da solo dal Profilo, perché quel valore + * finiva solo in `profili_giocatore.data_nascita`, tabella con RLS "solo il proprio profilo + * o admin" — mai letta dall'anagrafica pubblica. Qui si verifica che il trigger di sync + * (`sincronizza_nascita_pubblica`) propaghi il valore a `giocatori_squadra.nascita`, visibile + * a **qualsiasi** giocatore (non solo l'interessato o un admin), che lo azzeri quando il + * profilo viene cancellato, e che il client non possa scriverlo direttamente. + * + * Gira solo sullo stack locale (`npx supabase start`): usa lo slot `g6` della rosa seed, + * ripristinato alla fine. + */ +import assert from "node:assert/strict"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("nascita pubblica", "stack locale non attivo (npx supabase start)"); + riepilogo("nascita-pubblica"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`nascita pubblica su ${URL_BASE}`); + + const GIOCATORE = "g6"; + const PASSWORD = "prova-nascita-pubblica-123"; + const idUtenti: string[] = []; + let tokenAdmin = ""; + + const rest = (percorso: string, token: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { + apikey: token === SERVIZIO ? SERVIZIO : ANON, + Authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + async function creaUtente(email: 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: 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): 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: 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}` }, + }); + + async function leggiNascita(token: string): Promise { + const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}&select=nascita`, token); + const righe = (await res.json()) as Array<{ nascita: string | null }>; + return righe[0]?.nascita ?? null; + } + + try { + const emailAdmin = `test-nascita-admin-${Date.now()}@example.test`; + const emailGiocatore = `test-nascita-giocatore-${Date.now()}@example.test`; + const emailAltro = `test-nascita-altro-${Date.now()}@example.test`; + const idAdmin = await creaUtente(emailAdmin); + const idGiocatore = await creaUtente(emailGiocatore); + const idAltro = await creaUtente(emailAltro); + idUtenti.push(idAdmin, idGiocatore, idAltro); + + await rest("user_roles", SERVIZIO, { + method: "POST", + body: JSON.stringify({ user_id: idAdmin, role: "admin" }), + }); + + tokenAdmin = await accedi(emailAdmin); + const tokenGiocatore = await accedi(emailGiocatore); + const tokenAltro = await accedi(emailAltro); + + const collega = (utente: string | null) => + rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify({ auth_user_id: utente }), + }); + await collega(idGiocatore); + + await prova( + "il giocatore salva la propria nascita e diventa visibile a un altro giocatore", + async () => { + const res = await rest("profili_giocatore", tokenGiocatore, { + method: "POST", + body: JSON.stringify({ giocatore_id: GIOCATORE, data_nascita: "2002-06-15" }), + }); + assert.ok(res.ok, `salvataggio profilo: ${res.status} ${await res.text()}`); + + assert.equal(await leggiNascita(tokenAltro), "2002-06-15", "visibile a un altro giocatore"); + assert.equal(await leggiNascita(tokenAdmin), "2002-06-15", "visibile all'admin"); + }, + ); + + await prova("il client non può scrivere direttamente la nascita pubblica", async () => { + await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenGiocatore, { + method: "PATCH", + body: JSON.stringify({ nascita: "1900-01-01" }), + }); + assert.equal( + await leggiNascita(tokenAdmin), + "2002-06-15", + "il valore resta quello scritto dalla sync, non quello del tentativo diretto", + ); + }); + + await prova("cancellare il profilo azzera la nascita pubblica", async () => { + const res = await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, tokenAdmin, { + method: "DELETE", + }); + assert.ok(res.ok, `cancellazione profilo: ${res.status} ${await res.text()}`); + assert.equal(await leggiNascita(tokenAdmin), null, "torna NULL"); + }); + + await prova("un admin può impostare la nascita per conto del giocatore", async () => { + const res = await rest("profili_giocatore", tokenAdmin, { + method: "POST", + body: JSON.stringify({ giocatore_id: GIOCATORE, data_nascita: "1999-01-01" }), + }); + assert.ok(res.ok, `salvataggio profilo da admin: ${res.status} ${await res.text()}`); + assert.equal(await leggiNascita(tokenGiocatore), "1999-01-01"); + }); + } finally { + // Con la service role questa DELETE fallirebbe: fa scattare il trigger di sync, che + // tenta un UPDATE su giocatori_squadra ancora collegato, e `enforce_giocatori_squadra_update` + // rifiuta la service role quanto un utente qualsiasi (auth.uid() NULL, nessun ramo passa). + // Serve il JWT vero dell'admin, come per la cancellazione dentro il test. + if (tokenAdmin) { + await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, tokenAdmin, { + method: "DELETE", + }); + } else { + await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, 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("nascita-pubblica"); + } +} diff --git a/test/unit/giocatori-squadra.test.ts b/test/unit/giocatori-squadra.test.ts index 515d906..38b4606 100644 --- a/test/unit/giocatori-squadra.test.ts +++ b/test/unit/giocatori-squadra.test.ts @@ -23,6 +23,7 @@ const riga = (parziale: Partial = {}): GiocatoreSquadra => ({ email: null, numeroTessera: null, dataTessera: null, + nascita: null, ...parziale, }); @@ -47,6 +48,11 @@ assert.deepEqual( giocatori.map((g) => g.id).sort(), "gli id combaciano con la rosa reale", ); +assert.deepEqual( + Object.fromEntries(fallback.map((g) => [g.id, g.nascita])), + Object.fromEntries(giocatori.map((g) => [g.id, g.nascita])), + "la nascita del fallback combacia con quella del seed (M18)", +); // --- nomeCompleto ---------------------------------------------------------------- assert.equal(nomeCompleto(riga()), "Mario Rossi");