Files
CRAPP/src/lib/profili.ts
T
davideandClaude Opus 5 da51517ffc Dashboard amministratore e profilo per il tesseramento
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>
2026-08-30 17:57:59 +02:00

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;
}