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 <noreply@anthropic.com>
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
|
import {
|
|
aRigaProfilo,
|
|
COLONNE_PROFILO,
|
|
daRigaProfilo,
|
|
type Profilo,
|
|
type RigaProfilo,
|
|
} from "./profili-core";
|
|
|
|
export const PROFILI_KEY = ["profili-giocatore"] as const;
|
|
export const BUCKET = "profili-giocatore";
|
|
|
|
async function fetchProfili(): Promise<Record<string, Profilo>> {
|
|
const { data, error } = await supabaseNuoveTabelle
|
|
.from("profili_giocatore")
|
|
.select(COLONNE_PROFILO);
|
|
if (error) throw error;
|
|
const mappa: Record<string, Profilo> = {};
|
|
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r);
|
|
return mappa;
|
|
}
|
|
|
|
/**
|
|
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono — il proprio
|
|
* per un giocatore, tutti per un admin. Una lettura per sessione.
|
|
*/
|
|
export function useProfili() {
|
|
const query = useQuery({ queryKey: PROFILI_KEY, queryFn: fetchProfili, staleTime: 30 * 60_000 });
|
|
return { ...query, profili: query.data ?? {} };
|
|
}
|
|
|
|
/**
|
|
* I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4):
|
|
* ogni download passa da una signed URL che scade in un minuto.
|
|
*/
|
|
export async function urlFirmato(path: string): Promise<string> {
|
|
const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60);
|
|
if (error) throw error;
|
|
return data.signedUrl;
|
|
}
|
|
|
|
export async function scaricaFile(path: string): Promise<void> {
|
|
const url = await urlFirmato(path);
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
/**
|
|
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga:
|
|
* il vincolo vive nel database, qui non serve ricontrollarlo.
|
|
*/
|
|
export function useSalvaProfilo() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (profilo: Profilo) => {
|
|
const { error } = await supabaseNuoveTabelle
|
|
.from("profili_giocatore")
|
|
.upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" });
|
|
if (error) throw error;
|
|
return profilo;
|
|
},
|
|
// Scrittura unica e cache aggiornata a mano, senza rilettura.
|
|
onSuccess: (profilo) => {
|
|
queryClient.setQueryData<Record<string, Profilo>>(PROFILI_KEY, (prec) => ({
|
|
...(prec ?? {}),
|
|
[profilo.giocatoreId]: profilo,
|
|
}));
|
|
},
|
|
});
|
|
}
|
|
|
|
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
|
|
|
|
const MAX_BYTE = 8 * 1024 * 1024;
|
|
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
|
|
|
function estensione(nome: string): string {
|
|
const punto = nome.lastIndexOf(".");
|
|
const est = punto > 0 ? nome.slice(punto + 1).toLowerCase() : "";
|
|
return /^[a-z0-9]{1,5}$/.test(est) ? est : "bin";
|
|
}
|
|
|
|
/**
|
|
* Carica un file nella cartella del giocatore e restituisce il path da salvare sul profilo.
|
|
* Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
|
|
* dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla
|
|
* propria cartella.
|
|
*/
|
|
export async function caricaFile(
|
|
giocatoreId: string,
|
|
sezione: SezioneFile,
|
|
file: File,
|
|
pathPrecedente?: string | null,
|
|
): Promise<string> {
|
|
if (!TIPI_AMMESSI.includes(file.type)) {
|
|
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF.");
|
|
}
|
|
if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
|
|
|
|
const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`;
|
|
const { error } = await supabase.storage
|
|
.from(BUCKET)
|
|
.upload(path, file, { upsert: true, contentType: file.type });
|
|
if (error) throw error;
|
|
|
|
// Cambiando estensione il vecchio file resterebbe orfano nel bucket.
|
|
if (pathPrecedente && pathPrecedente !== path) {
|
|
await supabase.storage.from(BUCKET).remove([pathPrecedente]);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
export async function rimuoviFile(path: string): Promise<void> {
|
|
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
|
if (error) throw error;
|
|
}
|