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:
1 parent
4d036f532d
commit
3f2ca30775
14 files changed
+471
-35
No files matched your search
+17
-7
@@ -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
@@ -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). |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user