Sposta avatar e documenti profilo su PocketBase (file field)

Sostituisce i due bucket Supabase Storage con campi file PocketBase:

- avatar-giocatori: nuova collection avatar_giocatori separata da
  giocatori_squadra, perché la policy originale ("chiunque autenticato
  carica/sostituisce/elimina qualsiasi avatar, nessun controllo
  proprietario") è più permissiva delle regole di giocatori_squadra —
  mescolarle avrebbe indebolito le une o bloccato l'altra. File pubblico,
  come il bucket originale;
- profili-giocatore: campi file su profili_giocatore stesso, con
  protected: true (vedi sotto).

Trovato e corretto un problema di sicurezza reale: PocketBase rende i
file pubblici di default (si affida solo alla casualità del nome file),
a meno di impostare esplicitamente protected: true sul campo — i
documenti d'identità e i certificati medici sarebbero stati raggiungibili
senza autenticazione, in violazione di DD-016 regola 4. Verificato che
ora servono un token valido (404 senza, 200 con).

La scrittura dei campi testuali del profilo e il caricamento dei file
sono due operazioni separate (PocketBase rifiuta una stringa dove si
aspetta un file): verificato che caricare un documento non cancella i
dati testuali già salvati.

Rimossi da profili-core.ts RigaProfilo/daRigaProfilo/aRigaProfilo
(shape Postgres non più usata da nessun modulo di produzione) e
rimuoviFile (mai chiamato da nessun componente). Avatar.tsx non usa più
un URL deterministico per giocatore: PocketBase genera nomi file
casuali, quindi legge la mappa avatar tramite una query React Query
condivisa tra tutte le istanze del componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:58:54 +02:00
co-authored by Claude Sonnet 5
parent d125bdfb6a
commit c1744d4087
10 changed files with 229 additions and 198 deletions
+7 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { urlAvatar } from "@/lib/avatar-store"; import { urlAvatarDaRiga, useAvatarMap } from "@/lib/avatar-store";
export function Avatar({ export function Avatar({
id, id,
@@ -19,8 +19,12 @@ export function Avatar({
const [errore, setErrore] = useState(false); const [errore, setErrore] = useState(false);
useEffect(() => setErrore(false), [id, bust]); useEffect(() => setErrore(false), [id, bust]);
if (!errore) { const { data: mappa } = useAvatarMap();
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id); const riga = mappa?.[id];
if (riga && !errore) {
const base = urlAvatarDaRiga(riga);
const src = bust ? `${base}?v=${bust}` : base;
return ( return (
<img <img
src={src} src={src}
+14 -6
View File
@@ -34,15 +34,17 @@ function Intestazione({ titolo, completa }: { titolo: string; completa: boolean
function CampoFile({ function CampoFile({
label, label,
path, path,
recordId,
sezione, sezione,
giocatoreId, giocatoreId,
onCaricato, onCaricato,
}: { }: {
label: string; label: string;
path: string | null; path: string | null;
recordId: string | null;
sezione: SezioneFile; sezione: SezioneFile;
giocatoreId: string; giocatoreId: string;
onCaricato: (path: string) => Promise<void>; onCaricato: (risultato: { id: string; filename: string }) => Promise<void>;
}) { }) {
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const [inCorso, setInCorso] = useState(false); const [inCorso, setInCorso] = useState(false);
@@ -53,7 +55,7 @@ function CampoFile({
if (!file) return; if (!file) return;
setInCorso(true); setInCorso(true);
try { try {
const nuovo = await caricaFile(giocatoreId, sezione, file, path); const nuovo = await caricaFile(giocatoreId, sezione, file);
await onCaricato(nuovo); await onCaricato(nuovo);
toast.success(`${label} caricato`); toast.success(`${label} caricato`);
} catch (errore) { } catch (errore) {
@@ -77,10 +79,10 @@ function CampoFile({
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </span>
<span className="flex shrink-0 items-center gap-2"> <span className="flex shrink-0 items-center gap-2">
{path ? ( {path && recordId ? (
<button <button
type="button" type="button"
onClick={() => void scaricaFile(path)} onClick={() => void scaricaFile(recordId, path)}
className="premi rounded-xl bg-secondary p-2 text-muted-foreground" className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
aria-label={`Vedi ${label}`} aria-label={`Vedi ${label}`}
> >
@@ -287,8 +289,10 @@ export function ProfiloAmministrativo({
} }
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo. // Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
const caricato = (campo: keyof Profilo) => async (path: string) => const caricato =
scrivi({ ...corrente, [campo]: path }); (campo: "documentoFrontePath" | "documentoRetroPath" | "certificatoPath" | "fotoPath") =>
async (risultato: { id: string; filename: string }) =>
scrivi({ ...corrente, id: risultato.id, [campo]: risultato.filename });
const corpo = ( const corpo = (
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card"> <div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
@@ -313,6 +317,7 @@ export function ProfiloAmministrativo({
<CampoFile <CampoFile
label="Foto fronte" label="Foto fronte"
path={corrente.documentoFrontePath} path={corrente.documentoFrontePath}
recordId={corrente.id}
sezione="documento-fronte" sezione="documento-fronte"
giocatoreId={giocatoreId} giocatoreId={giocatoreId}
onCaricato={caricato("documentoFrontePath")} onCaricato={caricato("documentoFrontePath")}
@@ -320,6 +325,7 @@ export function ProfiloAmministrativo({
<CampoFile <CampoFile
label="Foto retro" label="Foto retro"
path={corrente.documentoRetroPath} path={corrente.documentoRetroPath}
recordId={corrente.id}
sezione="documento-retro" sezione="documento-retro"
giocatoreId={giocatoreId} giocatoreId={giocatoreId}
onCaricato={caricato("documentoRetroPath")} onCaricato={caricato("documentoRetroPath")}
@@ -330,6 +336,7 @@ export function ProfiloAmministrativo({
<CampoFile <CampoFile
label="Certificato medico" label="Certificato medico"
path={corrente.certificatoPath} path={corrente.certificatoPath}
recordId={corrente.id}
sezione="certificato" sezione="certificato"
giocatoreId={giocatoreId} giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")} onCaricato={caricato("certificatoPath")}
@@ -341,6 +348,7 @@ export function ProfiloAmministrativo({
<CampoFile <CampoFile
label="Foto tessera" label="Foto tessera"
path={corrente.fotoPath} path={corrente.fotoPath}
recordId={corrente.id}
sezione="foto" sezione="foto"
giocatoreId={giocatoreId} giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")} onCaricato={caricato("fotoPath")}
+48 -31
View File
@@ -1,41 +1,54 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
const BUCKET = "avatar-giocatori"; /**
const NOME_FILE = "avatar.jpg"; * Foto profilo pubbliche (M6 avatar-giocatori): collection separata da giocatori_squadra,
* chiunque autenticato può caricare/sostituire/eliminare l'avatar di chiunque (nessun
* controllo per-proprietario, come il vecchio bucket pubblico Supabase).
*/
type RigaAvatar = { id: string; giocatore: string; foto: string };
function percorso(id: string) { export const AVATAR_KEY = ["avatar-giocatori"] as const;
return `${id}/${NOME_FILE}`;
async function fetchAvatarMap(): Promise<Record<string, RigaAvatar>> {
const righe = await pb.collection("avatar_giocatori").getFullList<RigaAvatar>();
const mappa: Record<string, RigaAvatar> = {};
for (const r of righe) if (r.foto) mappa[r.giocatore] = r;
return mappa;
} }
/** URL pubblico e stabile: il bucket è pubblico, nessuna richiesta di rete. */ /** Una lettura per sessione, condivisa da tutte le istanze di <Avatar>: react-query la
export function urlAvatar(id: string): string { * deduplica anche se il componente compare decine di volte nella stessa pagina (rosa). */
return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl; export function useAvatarMap() {
return useQuery({ queryKey: AVATAR_KEY, queryFn: fetchAvatarMap, staleTime: 30 * 60_000 });
} }
const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const; /** URL pubblico e stabile: il campo non è protetto, nessuna richiesta di rete aggiuntiva. */
export function urlAvatarDaRiga(riga: RigaAvatar): string {
return pb.files.getURL({ id: riga.id, collectionName: "avatar_giocatori" }, riga.foto);
}
/** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */ /** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */
export function useAvatarEsiste(id: string | undefined) { export function useAvatarEsiste(id: string | undefined) {
return useQuery({ const query = useAvatarMap();
queryKey: chiaveEsiste(id ?? ""), return { ...query, data: id ? !!query.data?.[id] : false };
enabled: !!id,
staleTime: 60_000,
queryFn: async () => {
const { data, error } = await supabase.storage.from(BUCKET).list(id!, { search: NOME_FILE });
if (error) throw error;
return (data ?? []).some((f) => f.name === NOME_FILE);
},
});
} }
/** /**
* Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di * Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di
* rileggere l'elenco del bucket (una richiesta in meno per ogni cambio foto). * rileggere l'elenco (una richiesta in meno per ogni cambio foto).
*/ */
export function useImpostaAvatarEsiste() { export function useImpostaAvatarEsiste() {
const qc = useQueryClient(); const qc = useQueryClient();
return (id: string, esiste: boolean) => qc.setQueryData(chiaveEsiste(id), esiste); return (id: string, riga: RigaAvatar | null) => {
qc.setQueryData<Record<string, RigaAvatar>>(AVATAR_KEY, (prec) => {
const nuova = { ...(prec ?? {}) };
if (riga) nuova[id] = riga;
else delete nuova[id];
return nuova;
});
};
} }
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */ /** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
@@ -77,17 +90,21 @@ function fileToBlob(file: File, size = 256): Promise<Blob> {
} }
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */ /** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
export async function caricaAvatar(id: string, file: File) { export async function caricaAvatar(id: string, file: File): Promise<RigaAvatar> {
const blob = await fileToBlob(file); const blob = await fileToBlob(file);
const { error } = await supabase.storage.from(BUCKET).upload(percorso(id), blob, { const foto = new File([blob], "avatar.jpg", { type: "image/jpeg" });
contentType: "image/jpeg", return upsertByFilter<RigaAvatar>(
upsert: true, "avatar_giocatori",
cacheControl: "0", "giocatore = {:giocatore}",
}); { giocatore: id },
if (error) throw error; { giocatore: id, foto },
);
} }
export async function rimuoviAvatar(id: string) { export async function rimuoviAvatar(id: string): Promise<void> {
const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]); const esistente = await pb
if (error) throw error; .collection("avatar_giocatori")
.getFirstListItem(pb.filter("giocatore = {:giocatore}", { giocatore: id }))
.catch(() => null);
if (esistente) await pb.collection("avatar_giocatori").delete(esistente.id);
} }
+7 -72
View File
@@ -2,10 +2,14 @@ import { rigaCsv } from "./scout-export";
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra"; import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
/** /**
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato * Profilo amministrativo di un giocatore (DD-016). I file veri sono campi file su
* `profili-giocatore`: qui viaggiano solo i path. * PocketBase (`profili_giocatore`, protetti — mai URL pubblici): qui viaggia il nome del
* file caricato (usato anche come semplice marcatore "presente/assente"). `id` è l'id del
* record PocketBase (non quello del giocatore): serve a costruire l'URL dei file, `null`
* finché il profilo non è mai stato salvato.
*/ */
export type Profilo = { export type Profilo = {
id: string | null;
giocatoreId: string; giocatoreId: string;
dataNascita: string | null; dataNascita: string | null;
luogoNascita: string | null; luogoNascita: string | null;
@@ -24,30 +28,9 @@ export type Profilo = {
fotoPath: string | null; fotoPath: string | null;
}; };
export type RigaProfilo = {
giocatore_id: string;
data_nascita: string | null;
luogo_nascita: string | null;
indirizzo: string | null;
telefono: string | null;
email: string | null;
documento_tipo: string | null;
documento_numero: string | null;
documento_rilasciato_da: string | null;
documento_emissione: string | null;
documento_scadenza: string | null;
documento_fronte_path: string | null;
documento_retro_path: string | null;
certificato_scadenza: string | null;
certificato_path: string | null;
foto_path: string | null;
};
export const COLONNE_PROFILO =
"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";
export function profiloVuoto(giocatoreId: string): Profilo { export function profiloVuoto(giocatoreId: string): Profilo {
return { return {
id: null,
giocatoreId, giocatoreId,
dataNascita: null, dataNascita: null,
luogoNascita: null, luogoNascita: null,
@@ -67,54 +50,6 @@ export function profiloVuoto(giocatoreId: string): Profilo {
}; };
} }
export function daRigaProfilo(r: RigaProfilo): Profilo {
return {
giocatoreId: r.giocatore_id,
dataNascita: r.data_nascita,
luogoNascita: r.luogo_nascita,
indirizzo: r.indirizzo,
telefono: r.telefono,
email: r.email,
documentoTipo: r.documento_tipo,
documentoNumero: r.documento_numero,
documentoRilasciatoDa: r.documento_rilasciato_da,
documentoEmissione: r.documento_emissione,
documentoScadenza: r.documento_scadenza,
documentoFrontePath: r.documento_fronte_path,
documentoRetroPath: r.documento_retro_path,
certificatoScadenza: r.certificato_scadenza,
certificatoPath: r.certificato_path,
fotoPath: r.foto_path,
};
}
/** I campi vuoti tornano al database come NULL, non come stringa vuota. */
function oNull(valore: string | null): string | null {
const pulito = valore?.trim();
return pulito ? pulito : null;
}
export function aRigaProfilo(p: Profilo): RigaProfilo {
return {
giocatore_id: p.giocatoreId,
data_nascita: oNull(p.dataNascita),
luogo_nascita: oNull(p.luogoNascita),
indirizzo: oNull(p.indirizzo),
telefono: oNull(p.telefono),
email: oNull(p.email),
documento_tipo: oNull(p.documentoTipo),
documento_numero: oNull(p.documentoNumero),
documento_rilasciato_da: oNull(p.documentoRilasciatoDa),
documento_emissione: oNull(p.documentoEmissione),
documento_scadenza: oNull(p.documentoScadenza),
documento_fronte_path: oNull(p.documentoFrontePath),
documento_retro_path: oNull(p.documentoRetroPath),
certificato_scadenza: oNull(p.certificatoScadenza),
certificato_path: oNull(p.certificatoPath),
foto_path: oNull(p.fotoPath),
};
}
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */ /** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const; export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
+115 -58
View File
@@ -1,29 +1,71 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { pb } from "@/integrations/pocketbase/client";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import { import type { Profilo } from "./profili-core";
aRigaProfilo,
COLONNE_PROFILO,
daRigaProfilo,
type Profilo,
type RigaProfilo,
} from "./profili-core";
export const PROFILI_KEY = ["profili-giocatore"] as const; export const PROFILI_KEY = ["profili-giocatore"] as const;
export const BUCKET = "profili-giocatore";
type RigaProfiloPocketBase = {
id: string;
giocatore: string;
data_nascita: string;
luogo_nascita: string;
indirizzo: string;
telefono: string;
email: string;
documento_tipo: string;
documento_numero: string;
documento_rilasciato_da: string;
documento_emissione: string;
documento_scadenza: string;
documento_fronte: string;
documento_retro: string;
certificato_scadenza: string;
certificato: string;
foto: string;
};
/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */
function vuotoANull(v: string): string | null {
return v ? v : null;
}
/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */
function soloData(v: string): string | null {
return v ? v.slice(0, 10) : null;
}
function daRiga(r: RigaProfiloPocketBase): Profilo {
return {
id: r.id,
giocatoreId: r.giocatore,
dataNascita: soloData(r.data_nascita),
luogoNascita: vuotoANull(r.luogo_nascita),
indirizzo: vuotoANull(r.indirizzo),
telefono: vuotoANull(r.telefono),
email: vuotoANull(r.email),
documentoTipo: vuotoANull(r.documento_tipo),
documentoNumero: vuotoANull(r.documento_numero),
documentoRilasciatoDa: vuotoANull(r.documento_rilasciato_da),
documentoEmissione: soloData(r.documento_emissione),
documentoScadenza: soloData(r.documento_scadenza),
documentoFrontePath: vuotoANull(r.documento_fronte),
documentoRetroPath: vuotoANull(r.documento_retro),
certificatoScadenza: soloData(r.certificato_scadenza),
certificatoPath: vuotoANull(r.certificato),
fotoPath: vuotoANull(r.foto),
};
}
async function fetchProfili(): Promise<Record<string, Profilo>> { async function fetchProfili(): Promise<Record<string, Profilo>> {
const { data, error } = await supabaseNuoveTabelle const righe = await pb.collection("profili_giocatore").getFullList<RigaProfiloPocketBase>();
.from("profili_giocatore")
.select(COLONNE_PROFILO);
if (error) throw error;
const mappa: Record<string, Profilo> = {}; const mappa: Record<string, Profilo> = {};
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r); for (const r of righe) mappa[r.giocatore] = daRiga(r);
return mappa; return mappa;
} }
/** /**
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono — il proprio * Profili visibili all'utente corrente: le API rule decidono quanti sono — il proprio
* per un giocatore, tutti per un admin. Una lettura per sessione. * per un giocatore, tutti per un admin. Una lettura per sessione.
*/ */
export function useProfili() { export function useProfili() {
@@ -32,33 +74,55 @@ export function useProfili() {
} }
/** /**
* I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4): * I documenti sono un campo file protetto e non hanno URL permanenti (DD-016 regola 4):
* ogni download passa da una signed URL che scade in un minuto. * ogni download passa da un token che scade a breve, l'equivalente PocketBase della
* signed URL Supabase.
*/ */
export async function urlFirmato(path: string): Promise<string> { export async function urlFirmato(recordId: string, filename: string): Promise<string> {
const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60); const token = await pb.files.getToken();
if (error) throw error; return pb.files.getURL({ id: recordId, collectionName: "profili_giocatore" }, filename, {
return data.signedUrl; token,
});
} }
export async function scaricaFile(path: string): Promise<void> { export async function scaricaFile(recordId: string, filename: string): Promise<void> {
const url = await urlFirmato(path); const url = await urlFirmato(recordId, filename);
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
} }
/** /**
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga: * Salva i campi testuali del profilo. Le API rule lasciano scrivere solo la propria riga:
* il vincolo vive nel database, qui non serve ricontrollarlo. * il vincolo vive nel database, qui non serve ricontrollarlo.
*
* I campi file NON passano da qui: PocketBase si aspetta un file in quei campi, non una
* stringa, quindi mandarli in questo upsert testuale fallirebbe la validazione. Li scrive
* `caricaFile` con una richiesta multipart dedicata — ometterli qui li lascia semplicemente
* invariati, esattamente il comportamento voluto.
*/ */
export function useSalvaProfilo() { export function useSalvaProfilo() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (profilo: Profilo) => { mutationFn: async (profilo: Profilo) => {
const { error } = await supabaseNuoveTabelle const riga = await upsertByFilter<RigaProfiloPocketBase>(
.from("profili_giocatore") "profili_giocatore",
.upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" }); "giocatore = {:giocatore}",
if (error) throw error; { giocatore: profilo.giocatoreId },
return profilo; {
giocatore: profilo.giocatoreId,
data_nascita: profilo.dataNascita?.trim() || "",
luogo_nascita: profilo.luogoNascita?.trim() || "",
indirizzo: profilo.indirizzo?.trim() || "",
telefono: profilo.telefono?.trim() || "",
email: profilo.email?.trim() || "",
documento_tipo: profilo.documentoTipo?.trim() || "",
documento_numero: profilo.documentoNumero?.trim() || "",
documento_rilasciato_da: profilo.documentoRilasciatoDa?.trim() || "",
documento_emissione: profilo.documentoEmissione || "",
documento_scadenza: profilo.documentoScadenza || "",
certificato_scadenza: profilo.certificatoScadenza || "",
},
);
return daRiga(riga);
}, },
// Scrittura unica e cache aggiornata a mano, senza rilettura. // Scrittura unica e cache aggiornata a mano, senza rilettura.
onSuccess: (profilo) => { onSuccess: (profilo) => {
@@ -72,46 +136,39 @@ export function useSalvaProfilo() {
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto"; export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
const CAMPO_SEZIONE: Record<SezioneFile, keyof RigaProfiloPocketBase> = {
"documento-fronte": "documento_fronte",
"documento-retro": "documento_retro",
certificato: "certificato",
foto: "foto",
};
const MAX_BYTE = 8 * 1024 * 1024; const MAX_BYTE = 8 * 1024 * 1024;
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"]; 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. * Carica un file nel campo giusto del profilo del giocatore (crea il profilo se non
* Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto * esiste ancora) e restituisce id del record e nome del file, da salvare nella bozza
* dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla * locale. Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
* propria cartella. * dall'utente; le API rule della collection impediscono comunque di scrivere sul profilo
* di qualcun altro.
*/ */
export async function caricaFile( export async function caricaFile(
giocatoreId: string, giocatoreId: string,
sezione: SezioneFile, sezione: SezioneFile,
file: File, file: File,
pathPrecedente?: string | null, ): Promise<{ id: string; filename: string }> {
): Promise<string> {
if (!TIPI_AMMESSI.includes(file.type)) { if (!TIPI_AMMESSI.includes(file.type)) {
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF."); 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."); if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`; const campo = CAMPO_SEZIONE[sezione];
const { error } = await supabase.storage const riga = await upsertByFilter<RigaProfiloPocketBase>(
.from(BUCKET) "profili_giocatore",
.upload(path, file, { upsert: true, contentType: file.type }); "giocatore = {:giocatore}",
if (error) throw error; { giocatore: giocatoreId },
{ giocatore: giocatoreId, [campo]: file },
// Cambiando estensione il vecchio file resterebbe orfano nel bucket. );
if (pathPrecedente && pathPrecedente !== path) { return { id: riga.id, filename: riga[campo] };
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;
} }
+9 -3
View File
@@ -338,19 +338,21 @@ function Documento({
label, label,
stato, stato,
path, path,
recordId,
}: { }: {
icona: React.ReactNode; icona: React.ReactNode;
label: string; label: string;
stato: StatoScadenza | "presente" | "assente"; stato: StatoScadenza | "presente" | "assente";
path: string | null; path: string | null;
recordId: string | null;
}) { }) {
const [inCorso, setInCorso] = useState(false); const [inCorso, setInCorso] = useState(false);
async function scarica() { async function scarica() {
if (!path || inCorso) return; if (!path || !recordId || inCorso) return;
setInCorso(true); setInCorso(true);
try { try {
await scaricaFile(path); await scaricaFile(recordId, path);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Download non riuscito"); toast.error(error instanceof Error ? error.message : "Download non riuscito");
} finally { } finally {
@@ -362,7 +364,7 @@ function Documento({
<button <button
type="button" type="button"
onClick={scarica} onClick={scarica}
disabled={!path || inCorso} disabled={!path || !recordId || inCorso}
className={cn( className={cn(
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60", "premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
statoClasse[stato], statoClasse[stato],
@@ -431,24 +433,28 @@ function SchedaGiocatore({
label="Doc fronte" label="Doc fronte"
stato={fronte} stato={fronte}
path={profilo?.documentoFrontePath ?? null} path={profilo?.documentoFrontePath ?? null}
recordId={profilo?.id ?? null}
/> />
<Documento <Documento
icona={<IdCard className="h-3.5 w-3.5" />} icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro" label="Doc retro"
stato={retro} stato={retro}
path={profilo?.documentoRetroPath ?? null} path={profilo?.documentoRetroPath ?? null}
recordId={profilo?.id ?? null}
/> />
<Documento <Documento
icona={<FileText className="h-3.5 w-3.5" />} icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato" label="Certificato"
stato={certificato} stato={certificato}
path={profilo?.certificatoPath ?? null} path={profilo?.certificatoPath ?? null}
recordId={profilo?.id ?? null}
/> />
<Documento <Documento
icona={<Image className="h-3.5 w-3.5" />} icona={<Image className="h-3.5 w-3.5" />}
label="Foto" label="Foto"
stato={sezioni.foto ? "presente" : "assente"} stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null} path={profilo?.fotoPath ?? null}
recordId={profilo?.id ?? null}
/> />
<span <span
className={cn( className={cn(
+3 -3
View File
@@ -119,9 +119,9 @@ function Profilo() {
e.target.value = ""; e.target.value = "";
if (!file || !g) return; if (!file || !g) return;
try { try {
await caricaAvatar(g.id, file); const riga = await caricaAvatar(g.id, file);
setBust(Date.now()); setBust(Date.now());
impostaAvatarEsiste(g.id, true); impostaAvatarEsiste(g.id, riga);
toast.success("Immagine profilo aggiornata"); toast.success("Immagine profilo aggiornata");
} catch { } catch {
toast.error("Non sono riuscito a caricare l'immagine"); toast.error("Non sono riuscito a caricare l'immagine");
@@ -169,7 +169,7 @@ function Profilo() {
try { try {
await rimuoviAvatar(g.id); await rimuoviAvatar(g.id);
setBust(Date.now()); setBust(Date.now());
impostaAvatarEsiste(g.id, false); impostaAvatarEsiste(g.id, null);
toast.success("Immagine rimossa"); toast.success("Immagine rimossa");
} catch { } catch {
toast.error("Non sono riuscito a rimuovere l'immagine"); toast.error("Non sono riuscito a rimuovere l'immagine");
+6 -1
View File
@@ -9,8 +9,13 @@
* M2 non è ancora applicata a quel database: sono stati dell'ambiente, non difetti. * M2 non è ancora applicata a quel database: sono stati dell'ambiente, non difetti.
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { COLONNE_PROFILO } from "@/lib/profili-core";
import { envDaFile } from "../helpers/server"; import { envDaFile } from "../helpers/server";
// Elenco storico delle colonne Supabase di profili_giocatore (M2/DD-016). Non più importato
// da src/lib/profili-core.ts da quando il livello dati è passato a PocketBase (Fase 4 della
// migrazione): questo test valida ancora lo schema Supabase, quindi lo tiene qui in locale.
const COLONNE_PROFILO =
"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";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
const env = { ...envDaFile(), ...process.env }; const env = { ...envDaFile(), ...process.env };
+19 -8
View File
@@ -1,16 +1,27 @@
/** /**
* Check dell'avatar giocatore: `bun test/unit/avatar-store.test.ts`. * Check dell'avatar giocatore: `bun test/unit/avatar-store.test.ts`.
* `urlAvatar` è puro (il bucket è pubblico, nessuna richiesta di rete): qui * `urlAvatarDaRiga` è puro (il campo non è protetto, nessuna richiesta di rete): qui
* verifichiamo solo la forma dell'URL, non serve un database. * verifichiamo solo la forma dell'URL a partire da una riga finta, non serve un database.
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { urlAvatar } from "@/lib/avatar-store"; import { urlAvatarDaRiga } from "@/lib/avatar-store";
const url = urlAvatar("g1"); const riga = { id: "rec1", giocatore: "g1", foto: "avatar_abc123.jpg" };
assert.ok(url.includes("avatar-giocatori"), "punta al bucket degli avatar"); const url = urlAvatarDaRiga(riga);
assert.ok(url.includes("g1/avatar.jpg"), "il percorso è <id>/avatar.jpg");
assert.equal(urlAvatar("g1"), url, "deterministico: nessuna chiamata di rete coinvolta"); assert.ok(url.includes("avatar_giocatori"), "punta alla collection degli avatar");
assert.notEqual(urlAvatar("g2"), url, "id diversi -> percorsi diversi"); assert.ok(url.includes("rec1"), "contiene l'id del record");
assert.ok(url.includes("avatar_abc123.jpg"), "contiene il nome del file");
assert.equal(
urlAvatarDaRiga(riga),
url,
"deterministico a parità di riga: nessuna chiamata di rete",
);
assert.notEqual(
urlAvatarDaRiga({ ...riga, foto: "altro.jpg" }),
url,
"file diverso -> URL diverso",
);
console.log("avatar-store: ok"); console.log("avatar-store: ok");
+1 -13
View File
@@ -1,10 +1,8 @@
/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */ /** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
aRigaProfilo,
completamento, completamento,
csvTesseramento, csvTesseramento,
daRigaProfilo,
sezioniComplete, sezioniComplete,
statoScadenza, statoScadenza,
type Profilo, type Profilo,
@@ -19,6 +17,7 @@ import {
import { dividiNome } from "@/lib/crapp-data"; import { dividiNome } from "@/lib/crapp-data";
const vuoto: Profilo = { const vuoto: Profilo = {
id: null,
giocatoreId: "g1", giocatoreId: "g1",
dataNascita: null, dataNascita: null,
luogoNascita: null, luogoNascita: null,
@@ -77,17 +76,6 @@ assert.equal(
); );
assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false); assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false);
// --- aRigaProfilo ------------------------------------------------------------
const riga = aRigaProfilo({ ...completo, luogoNascita: " ", telefono: " 333 " });
assert.equal(riga.luogo_nascita, null, "i campi solo-spazi tornano NULL, non stringa vuota");
assert.equal(riga.telefono, "333", "il resto viene ripulito ai bordi");
assert.equal(riga.documento_fronte_path, "g1/documento-fronte.jpg");
assert.deepEqual(
daRigaProfilo(aRigaProfilo(completo)),
completo,
"modello -> riga -> modello non perde niente",
);
// --- statoScadenza ----------------------------------------------------------- // --- statoScadenza -----------------------------------------------------------
const oggi = "2026-08-30"; const oggi = "2026-08-30";
assert.equal(statoScadenza(null, null, oggi), "mancante"); assert.equal(statoScadenza(null, null, oggi), "mancante");