From da51517ffc4b9036eccf65b31582a6b010e68b43 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 30 Aug 2026 17:57:59 +0200 Subject: [PATCH] Dashboard amministratore e profilo per il tesseramento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa la voce "Dashboard amministratore" della roadmap v1.1, come descritta in docs/modules/profilo-giocatore.md, insieme alla parte di profilo che la alimenta. - /admin: stato dei profili della squadra, download di documento, certificato e foto tessera, export CSV con i 12 campi del tesseramento CSI. Un certificato scaduto non conta come valido. - Profilo giocatore: dati personali, documento (fronte e retro), certificato e foto tessera, con widget di completamento in Home che sparisce al 100%. - I permessi di amministrazione arrivano da user_roles (DD-011) e non più dalla lista di nomi in crapp-data.ts, che resta come ponte finché VITE_AUTH_OBBLIGATORIA non viene acceso in produzione. - Login Google via Supabase Auth: al primo accesso l'account si collega a uno slot libero di giocatori_squadra, e il vincolo lo fa rispettare il trigger di M1 (DD-016 regola 2). Migration additive: M2 crea profili_giocatore, M3 il bucket privato profili-giocatore. Nessuna tabella v1.0 viene toccata, quindi si possono applicare senza cambiare il comportamento attuale dell'app. supabase/config.toml e seed.sql configurano lo stack locale: serve perché il progetto Supabase è uno solo, condiviso tra sviluppo e produzione. Co-Authored-By: Claude Opus 5 --- .../crapp/ProfiloAmministrativo.tsx | 370 ++++++++++++++++++ src/components/crapp/RosaPresenze.tsx | 6 +- src/components/crapp/ScoutEntry.tsx | 5 +- .../supabase/client-nuove-tabelle.ts | 12 + src/lib/auth.ts | 61 +++ src/lib/giocatori-squadra.ts | 118 ++++++ src/lib/profili-core.ts | 205 ++++++++++ src/lib/profili.ts | 117 ++++++ src/lib/ruoli.ts | 44 +++ src/lib/scout-export.ts | 19 +- src/routeTree.gen.ts | 21 + src/routes/admin.tsx | 280 +++++++++++++ src/routes/benvenuto.tsx | 157 ++++++-- src/routes/calendario.tsx | 5 +- src/routes/eventi.tsx | 6 +- src/routes/index.tsx | 36 +- src/routes/partita.$id.tsx | 6 +- src/routes/profilo.tsx | 39 +- src/routes/scout.tsx | 6 +- supabase/config.toml | 23 +- .../20260830120000_m2_profili_giocatore.sql | 89 +++++ .../20260830120100_m3_bucket_profili.sql | 36 ++ supabase/seed.sql | 37 ++ 23 files changed, 1635 insertions(+), 63 deletions(-) create mode 100644 src/components/crapp/ProfiloAmministrativo.tsx create mode 100644 src/integrations/supabase/client-nuove-tabelle.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/giocatori-squadra.ts create mode 100644 src/lib/profili-core.ts create mode 100644 src/lib/profili.ts create mode 100644 src/lib/ruoli.ts create mode 100644 src/routes/admin.tsx create mode 100644 supabase/migrations/20260830120000_m2_profili_giocatore.sql create mode 100644 supabase/migrations/20260830120100_m3_bucket_profili.sql create mode 100644 supabase/seed.sql diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx new file mode 100644 index 0000000..1c8ad91 --- /dev/null +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -0,0 +1,370 @@ +import { useRef, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Check, Eye, Loader2, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Section } from "@/components/crapp/ui-bits"; +import { Reveal } from "@/components/motion/Reveal"; +import { + caricaFile, + scaricaFile, + useProfili, + useSalvaProfilo, + type SezioneFile, +} from "@/lib/profili"; +import { completamento, profiloVuoto, sezioniComplete, type Profilo } from "@/lib/profili-core"; + +const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"]; + +function Campo({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; + +function CampoFile({ + label, + path, + sezione, + giocatoreId, + onCaricato, +}: { + label: string; + path: string | null; + sezione: SezioneFile; + giocatoreId: string; + onCaricato: (path: string) => Promise; +}) { + const input = useRef(null); + const [inCorso, setInCorso] = useState(false); + + async function scegli(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setInCorso(true); + try { + const nuovo = await caricaFile(giocatoreId, sezione, file, path); + await onCaricato(nuovo); + toast.success(`${label} caricato`); + } catch (errore) { + toast.error(errore instanceof Error ? errore.message : "Caricamento non riuscito"); + } finally { + setInCorso(false); + } + } + + return ( +
+ + + {path ? : } + + {label} + + + {path ? ( + + ) : null} + + + +
+ ); +} + +/** + * Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge. + * Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente. + */ +export function ProfiloAmministrativo({ + giocatoreId, + indice = 0, +}: { + giocatoreId: string; + indice?: number; +}) { + const { profili } = useProfili(); + const salva = useSalvaProfilo(); + const [bozza, setBozza] = useState(null); + + const salvato = profili[giocatoreId]; + const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId); + const sporco = bozza !== null; + const perc = completamento(corrente); + const sezioni = sezioniComplete(corrente); + + function aggiorna(patch: Partial) { + setBozza({ ...corrente, ...patch }); + } + + async function scrivi(profilo: Profilo) { + await salva.mutateAsync(profilo); + setBozza(null); + } + + async function salvaBozza() { + try { + await scrivi(corrente); + toast.success("Profilo aggiornato"); + } catch (errore) { + toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito"); + } + } + + // Un file caricato va persistito subito, insieme a quello che si stava scrivendo. + const caricato = (campo: keyof Profilo) => async (path: string) => + scrivi({ ...corrente, [campo]: path }); + + return ( +
{perc}%} + > +
+
+
+
+ +

+ Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. +

+ + +
+ + aggiorna({ dataNascita: e.target.value })} + className={classiInput} + /> + + + aggiorna({ luogoNascita: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ indirizzo: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ telefono: e.target.value })} + className={classiInput} + /> + + + aggiorna({ email: e.target.value })} + className={classiInput} + /> + +
+ + +
+ + + + + aggiorna({ documentoNumero: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ documentoRilasciatoDa: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ documentoEmissione: e.target.value })} + className={classiInput} + /> + + + aggiorna({ documentoScadenza: e.target.value })} + className={classiInput} + /> + +
+
+ + +
+ + + + aggiorna({ certificatoScadenza: e.target.value })} + className={classiInput} + /> + + + + + + + +
+
+ ); +} + +/** + * Widget di Home: sparisce da solo quando il profilo è completo + * (docs/modules/profilo-giocatore.md § Home). + */ +export function CompletaProfilo({ + giocatoreId, + indice = 0, +}: { + giocatoreId: string; + indice?: number; +}) { + const { profili, isPending } = useProfili(); + const perc = completamento(profili[giocatoreId]); + if (isPending || perc === 100) return null; + + return ( + + +
+ + Completa il tuo profilo + + {perc}% +
+
+
+
+

+ Documento, certificato medico e foto tessera servono per il tesseramento CSI. +

+ + + ); +} + +function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { + return ( +
+

{titolo}

+ {completa ? : null} +
+ ); +} diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index 8836039..e0df50a 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -4,9 +4,10 @@ import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/crapp/Avatar"; import { Barra } from "@/components/motion/Barra"; -import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data"; +import { giocatori, statoMeta, type Stato } from "@/lib/crapp-data"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"]; @@ -14,6 +15,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) { const { risposte, isPending } = usePresenzeEvento(eventoId); const salva = useSalvaPresenza(); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const [sollecito, setSollecito] = useState(false); const mancanti = giocatori.filter((g) => !risposte[g.id]); @@ -105,7 +107,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
) : null} - {io && isAdmin(io.id) ? ( + {admin ? ( + ); +} + +function SchedaGiocatore({ + nome, + ruolo, + numero, + profilo, + oggi, + indice, +}: { + nome: string; + ruolo: string; + numero: number; + profilo: Profilo | undefined; + oggi: string; + indice: number; +}) { + const [aperta, setAperta] = useState(false); + const perc = completamento(profilo); + const sezioni = sezioniComplete(profilo); + const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi); + const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi); + const retro = statoScadenza(profilo?.documentoScadenza, profilo?.documentoRetroPath, oggi); + + return ( + + + +
+
+
+ +
+ } + label="Doc fronte" + stato={fronte} + path={profilo?.documentoFrontePath ?? null} + /> + } + label="Doc retro" + stato={retro} + path={profilo?.documentoRetroPath ?? null} + /> + } + label="Certificato" + stato={certificato} + path={profilo?.certificatoPath ?? null} + /> + } + label="Foto" + stato={sezioni.foto ? "presente" : "assente"} + path={profilo?.fotoPath ?? null} + /> +
+ + {aperta ? ( +
+ + + + + + + + + +
+ ) : null} + + ); +} + +function Dashboard() { + const admin = useIsAdmin(); + const { righe: squadra } = useGiocatoriSquadra(); + const { profili, isPending } = useProfili(); + const oggi = oggiISO(); + + if (!admin) { + return ( + <> + +
+

+ + Riservata agli amministratori della squadra. +

+
+ + ); + } + + const attivi = squadra.filter((g) => g.attivo); + const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length; + const certificatiOk = attivi.filter( + (g) => + statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) === + "valido", + ).length; + + return ( + <> + + +
+
+ + + +
+ +
+ +
+ {isPending ? ( +

+ Caricamento… +

+ ) : ( +
+ {attivi.map((g, i) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index 865e66f..d4ab6cd 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -1,7 +1,18 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { LogIn } from "lucide-react"; +import { toast } from "sonner"; import { TeamLogo } from "@/components/crapp/ui-bits"; import { giocatori } from "@/lib/crapp-data"; +import { accediConGoogle, authObbligatoria, useSessione } from "@/lib/auth"; +import { + nomeCompleto, + slotDi, + slotLiberi, + useCollegaGiocatore, + useGiocatoriSquadra, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; export const Route = createFileRoute("/benvenuto")({ @@ -10,27 +21,89 @@ export const Route = createFileRoute("/benvenuto")({ { title: "Benvenuto — CrAPP DEVELOP" }, { name: "description", - content: "Seleziona il tuo profilo giocatore per iniziare.", + content: "Accedi e collega il tuo profilo giocatore per iniziare.", }, { property: "og:title", content: "Benvenuto — CrAPP DEVELOP" }, { property: "og:description", - content: "Seleziona il tuo profilo giocatore per iniziare.", + content: "Accedi e collega il tuo profilo giocatore per iniziare.", }, ], }), component: Benvenuto, }); +function Scheda({ + titolo, + sottotitolo, + onClick, + iniziali, +}: { + titolo: string; + sottotitolo: string; + onClick: () => void; + iniziali: string; +}) { + return ( + + ); +} + function Benvenuto() { const navigate = useNavigate(); const giocatore = useGiocatoreCorrente(); + const { pronta, utenteId } = useSessione(); + const { righe } = useGiocatoriSquadra(); + const collega = useCollegaGiocatore(); + const [inCorso, setInCorso] = useState(false); + + const mioSlot = slotDi(righe, utenteId); + // Con l'auth obbligatoria si entra solo da loggati; finché non lo è, la selezione + // diretta resta come ponte per chi non ha ancora collegato l'account (DD-011). + const puoEntrare = !!giocatore && (!!utenteId || !authObbligatoria()); useEffect(() => { - if (giocatore) { - navigate({ to: "/" }); + if (puoEntrare) navigate({ to: "/" }); + }, [puoEntrare, navigate]); + + // L'account è già collegato a uno slot: nessuna scelta da fare. + useEffect(() => { + if (mioSlot) impostaGiocatore(mioSlot.id); + }, [mioSlot]); + + async function accedi() { + setInCorso(true); + try { + await accediConGoogle(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Accesso non riuscito"); + setInCorso(false); } - }, [giocatore, navigate]); + } + + async function reclama(g: GiocatoreSquadra) { + if (!utenteId) return; + try { + await collega.mutateAsync({ giocatoreId: g.id, utenteId }); + impostaGiocatore(g.id); + } catch { + toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore."); + } + } + + const liberi = slotLiberi(righe); return (
@@ -38,29 +111,63 @@ function Benvenuto() {

Benvenuto in CrAPP DEVELOP

-

- Seleziona chi sei per personalizzare l'app. -

-
- {giocatori.map((g) => ( + + {!pronta ? null : !utenteId ? ( + <> +

+ Accedi con il tuo account Google per collegare il profilo giocatore. +

- ))} -
+ + {authObbligatoria() ? null : ( +
+

+ Oppure entra scegliendo il tuo nome, come prima. +

+
+ {giocatori.map((g) => ( + impostaGiocatore(g.id)} + /> + ))} +
+
+ )} + + ) : ( + <> +

+ Sei entrato. Scegli il tuo nome: resterà collegato a questo account. +

+
+ {liberi.map((g) => ( + void reclama(g)} + /> + ))} + {liberi.length === 0 ? ( +

+ Nessun profilo libero: chiedi a un amministratore di collegarti. +

+ ) : null} +
+ + )}
); } diff --git a/src/routes/calendario.tsx b/src/routes/calendario.tsx index e25c52f..3a42a84 100644 --- a/src/routes/calendario.tsx +++ b/src/routes/calendario.tsx @@ -5,9 +5,9 @@ import { Link } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; -import { isAdmin } from "@/lib/crapp-data"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; import { Drawer, DrawerClose, @@ -97,6 +97,7 @@ function Calendario() { const [giornoSelezionato, setGiornoSelezionato] = useState(null); const [drawerAperto, setDrawerAperto] = useState(false); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const { eventi } = useEventi(); const { anno, mese, precedente, successivo } = useMeseNav(); const { giorni, offsetLunedi } = giorniDelMese(anno, mese); @@ -245,7 +246,7 @@ function Calendario() { ) : null} - {io && isAdmin(io.id) ? ( + {admin ? (
({ @@ -47,12 +48,13 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [ function GestioneEventi() { const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const { eventi, isPending } = useEventi(); const salva = useSalvaEvento(); const elimina = useEliminaEvento(); const [bozza, setBozza] = useState(null); - if (!io || !isAdmin(io.id)) { + if (!io || !admin) { return ( <> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ec8a27c..544b6cd 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,6 +4,7 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni"; import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; +import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { Reveal } from "@/components/motion/Reveal"; import { Barra } from "@/components/motion/Barra"; import { Numero } from "@/components/motion/Numero"; @@ -24,7 +25,8 @@ export const Route = createFileRoute("/")({ { property: "og:title", content: "CrAPP — L'app del CRAP Volley" }, { property: "og:description", - content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.", + content: + "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.", }, ], }), @@ -105,6 +107,8 @@ function Index() {
+ +
{prossimi.slice(1).map((e) => { @@ -155,25 +159,29 @@ function Index() { titolo="Obiettivo di squadra" indice={4} azione={ - + Tutti } > {obiettivo ? ( -
-
- {obiettivo.emoji} {obiettivo.titolo} +
+
+ {obiettivo.emoji} {obiettivo.titolo} +
+ +

+ Siamo al —{" "} + {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. +

+

+ {microcopyObiettivo(obiettivo)} +

+

{obiettivo.impatto}

- -

- Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target}{" "} - {obiettivo.unita}. -

-

{microcopyObiettivo(obiettivo)}

-

{obiettivo.impatto}

-
) : null}
diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 57b258d..2dc6574 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -2,13 +2,14 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; -import { storicoMatch, formatData, giocatori, isAdmin } from "@/lib/crapp-data"; +import { storicoMatch, formatData, giocatori } from "@/lib/crapp-data"; import { convocatiEvento, useEvento } from "@/lib/eventi"; import { Pagelle } from "@/components/crapp/Pagelle"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotoSocial } from "@/components/crapp/VotoSocial"; import { TurnoPalloni } from "@/components/crapp/TurnoPalloni"; @@ -36,6 +37,7 @@ function PartitaDetail() { const { id } = Route.useParams(); const { evento } = useEvento(id); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const scoutMatches = useScoutMatches(); const { risposte } = usePresenzeEvento(id); const presentiVeri = giocatori.filter( @@ -224,7 +226,7 @@ function PartitaDetail() { ); })}
- {io && isAdmin(io.id) ? ( + {admin ? ( + {sessione ? ( + + ) : null} diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 6aaeda0..f34ac4e 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -3,9 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { giocatori, formatData, isAdmin } from "@/lib/crapp-data"; +import { giocatori, formatData } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; import { usePresenzeEvento } from "@/lib/presenze"; import { statoIniziale, @@ -70,6 +71,7 @@ function Blocco({ icona, titolo, testo, children }: { icona: React.ReactNode; ti function Scout() { const { pronto, partita } = usePartitaDiOggi(); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const sessione = useSessioneScout(partita?.id ?? null); const statoSalvato = useStatoScout(partita?.id ?? null); const apri = useApriSessioneScout(); @@ -88,7 +90,7 @@ function Scout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [controllo, partita?.id, io?.id]); - if (io && !isAdmin(io.id)) { + if (io && !admin) { return ( } diff --git a/supabase/config.toml b/supabase/config.toml index e0800f3..30be195 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1,22 @@ -project_id = "kfkcldwncxqaixetsjes" \ No newline at end of file +project_id = "kfkcldwncxqaixetsjes" + +# Configurazione dell'istanza locale (`supabase start`). Non tocca il progetto cloud: +# lì le stesse impostazioni si mettono dalla dashboard Supabase. + +[auth] +site_url = "http://localhost:8080" +additional_redirect_urls = ["http://localhost:8080"] + +# Accesso via email con Mailpit (http://127.0.0.1:54324) per le prove locali: +# nessuna mail esce dalla macchina e non serve confermare l'indirizzo. +[auth.email] +enable_signup = true +enable_confirmations = false + +# Google in locale: metti client id e secret in `.env` e porta `enabled` a true. +# Il redirect da registrare in Google Cloud è http://127.0.0.1:54321/auth/v1/callback, +# che convive con quello di produzione sulla stessa credenziale. +[auth.external.google] +enabled = false +client_id = "env(SUPABASE_AUTH_GOOGLE_CLIENT_ID)" +secret = "env(SUPABASE_AUTH_GOOGLE_SECRET)" diff --git a/supabase/migrations/20260830120000_m2_profili_giocatore.sql b/supabase/migrations/20260830120000_m2_profili_giocatore.sql new file mode 100644 index 0000000..97061bb --- /dev/null +++ b/supabase/migrations/20260830120000_m2_profili_giocatore.sql @@ -0,0 +1,89 @@ +-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016) +-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti. +-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato +-- `profili-giocatore` creato dalla migration M3. + +CREATE TABLE public.profili_giocatore ( + giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE, + + -- Dati personali richiesti dal tesseramento CSI + data_nascita date, + luogo_nascita text, + indirizzo text, + telefono text, + email text, + + -- Documento di identità + documento_tipo text, + documento_numero text, + documento_rilasciato_da text, + documento_emissione date, + documento_scadenza date, + -- Il documento si carica fronte e retro: il CSI li vuole entrambi. + documento_fronte_path text, + documento_retro_path text, + + -- Certificato medico (storico non conservato in v1: DD-010) + certificato_scadenza date, + certificato_path text, + + -- Foto tessera + foto_path text, + + creato_il timestamptz NOT NULL DEFAULT now(), + aggiornato_il timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.profili_giocatore IS + 'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.'; + +CREATE TRIGGER update_profili_giocatore_aggiornato_il + BEFORE UPDATE ON public.profili_giocatore + FOR EACH ROW + EXECUTE FUNCTION public.update_aggiornato_il(); + +ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY; + +-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa +-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1). +CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore + FOR INSERT TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore + FOR UPDATE TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento. +CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated; +GRANT ALL ON public.profili_giocatore TO service_role; diff --git a/supabase/migrations/20260830120100_m3_bucket_profili.sql b/supabase/migrations/20260830120100_m3_bucket_profili.sql new file mode 100644 index 0000000..4a26afd --- /dev/null +++ b/supabase/migrations/20260830120100_m3_bucket_profili.sql @@ -0,0 +1,36 @@ +-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4) +-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e +-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene +-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin. + +INSERT INTO storage.buckets (id, name, public) +VALUES ('profili-giocatore', 'profili-giocatore', false) +ON CONFLICT (id) DO NOTHING; + +-- Convenzione dei path: `/.` (es. `g4/certificato.pdf`). +-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file. +CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects + FOR ALL TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + bucket_id = 'profili-giocatore' + AND EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid() + ) + ); + +-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano +-- in mano al giocatore che li ha caricati. +CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects + FOR SELECT TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND public.has_role(auth.uid(), 'admin'::public.app_role) + ); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..0048ecd --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,37 @@ +-- Seed di sviluppo: gira solo in locale (`supabase start` e `supabase db reset`), +-- mai in produzione. Serve a vedere la dashboard amministratore con dati realistici +-- senza inserire righe finte nel database vero. +-- +-- 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). + +INSERT INTO public.profili_giocatore + (giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, + documento_tipo, documento_numero, documento_rilasciato_da, + documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, + certificato_scadenza, certificato_path, foto_path) +VALUES + -- Profilo completo al 100%. + ('g1', '1997-08-30', 'Bologna', 'Via Roma 1', '3330000001', 'g1@example.test', + 'Carta d''identità', 'CA1000001', 'Comune di Bologna', + '2021-03-01', '2031-03-01', 'g1/documento-fronte.jpg', 'g1/documento-retro.jpg', + '2027-06-30', 'g1/certificato.pdf', 'g1/foto.jpg'), + + -- Certificato scaduto: in dashboard deve comparire rosso. + ('g4', '1995-05-01', 'Bologna', 'Via Verdi 2', '3330000004', 'g4@example.test', + 'Carta d''identità', 'CA1000004', 'Comune di Bologna', + '2019-05-01', '2029-05-01', 'g4/documento-fronte.jpg', 'g4/documento-retro.jpg', + '2025-01-01', 'g4/certificato.pdf', 'g4/foto.jpg'), + + -- Profilo a metà: dati personali sì, documento no, certificato sì, foto no. + ('g2', '1996-12-07', 'Modena', 'Via Bianchi 3', '3330000002', 'g2@example.test', + NULL, NULL, NULL, NULL, NULL, NULL, NULL, + '2027-09-15', 'g2/certificato.pdf', NULL) +ON CONFLICT (giocatore_id) DO NOTHING; + +-- 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: +-- +-- INSERT INTO public.user_roles (user_id, role) +-- SELECT id, 'admin' FROM auth.users WHERE email = '';