Rende pubblica a tutta la squadra la data di nascita inserita dal Profilo

Prima veniva mostrata solo per i 17 giocatori del seed storico: chi la
inseriva da sé restava con "Invalid Date" in Squadra, perché il dato
finiva solo in profili_giocatore (privato). Un trigger ora lo sincronizza
in giocatori_squadra.nascita, colonna pubblica a tutta la rosa.

Include il backfill dei profili già compilati prima di M18 (M19),
mancante nel push iniziale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-15 12:18:01 +02:00
1 parent 4d036f532d
commit 3f2ca30775
14 files changed
+471 -35

No files matched your search

+17 -7
View File
@@ -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
+1 -1
View File
@@ -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). |
+84
View File
@@ -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.
+5 -1
View File
@@ -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
+6 -7
View File
@@ -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
-8
View File
@@ -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<string, string> = Object.fromEntries(
giocatori.map((g) => [g.id, g.nascita]),
);
export type RigaClassifica = {
pos: number;
squadra: string;
+14 -2
View File
@@ -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")`).
+3 -3
View File
@@ -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),
+8 -6
View File
@@ -169,12 +169,14 @@ function Squadra() {
{isOpen ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
{g.nascita ? (
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
) : null}
<div className="mt-3 grid grid-cols-3 gap-2">
{[
@@ -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();
@@ -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;
+8
View File
@@ -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:
+173
View File
@@ -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<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: 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: 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<string | null> {
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");
}
}
+6
View File
@@ -23,6 +23,7 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): 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");