@@ -313,6 +317,7 @@ export function ProfiloAmministrativo({
> {
+ const righe = await pb.collection("avatar_giocatori").getFullList();
+ const mappa: Record = {};
+ 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. */
-export function urlAvatar(id: string): string {
- return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl;
+/** Una lettura per sessione, condivisa da tutte le istanze di : react-query la
+ * deduplica anche se il componente compare decine di volte nella stessa pagina (rosa). */
+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". */
export function useAvatarEsiste(id: string | undefined) {
- return useQuery({
- queryKey: chiaveEsiste(id ?? ""),
- 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);
- },
- });
+ const query = useAvatarMap();
+ return { ...query, data: id ? !!query.data?.[id] : false };
}
/**
* 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() {
const qc = useQueryClient();
- return (id: string, esiste: boolean) => qc.setQueryData(chiaveEsiste(id), esiste);
+ return (id: string, riga: RigaAvatar | null) => {
+ qc.setQueryData>(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. */
@@ -77,17 +90,21 @@ function fileToBlob(file: File, size = 256): Promise {
}
/** 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 {
const blob = await fileToBlob(file);
- const { error } = await supabase.storage.from(BUCKET).upload(percorso(id), blob, {
- contentType: "image/jpeg",
- upsert: true,
- cacheControl: "0",
- });
- if (error) throw error;
+ const foto = new File([blob], "avatar.jpg", { type: "image/jpeg" });
+ return upsertByFilter(
+ "avatar_giocatori",
+ "giocatore = {:giocatore}",
+ { giocatore: id },
+ { giocatore: id, foto },
+ );
}
-export async function rimuoviAvatar(id: string) {
- const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]);
- if (error) throw error;
+export async function rimuoviAvatar(id: string): Promise {
+ const esistente = await pb
+ .collection("avatar_giocatori")
+ .getFirstListItem(pb.filter("giocatore = {:giocatore}", { giocatore: id }))
+ .catch(() => null);
+ if (esistente) await pb.collection("avatar_giocatori").delete(esistente.id);
}
diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts
index ae40c07..c22747b 100644
--- a/src/lib/profili-core.ts
+++ b/src/lib/profili-core.ts
@@ -2,10 +2,14 @@ import { rigaCsv } from "./scout-export";
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
/**
- * Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
- * `profili-giocatore`: qui viaggiano solo i path.
+ * Profilo amministrativo di un giocatore (DD-016). I file veri sono campi file su
+ * 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 = {
+ id: string | null;
giocatoreId: string;
dataNascita: string | null;
luogoNascita: string | null;
@@ -24,30 +28,9 @@ export type Profilo = {
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 {
return {
+ id: null,
giocatoreId,
dataNascita: 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). */
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
diff --git a/src/lib/profili.ts b/src/lib/profili.ts
index e73d7ef..d8b3270 100644
--- a/src/lib/profili.ts
+++ b/src/lib/profili.ts
@@ -1,29 +1,71 @@
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";
+import { pb } from "@/integrations/pocketbase/client";
+import { upsertByFilter } from "@/integrations/pocketbase/upsert";
+import type { Profilo } from "./profili-core";
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> {
- const { data, error } = await supabaseNuoveTabelle
- .from("profili_giocatore")
- .select(COLONNE_PROFILO);
- if (error) throw error;
+ const righe = await pb.collection("profili_giocatore").getFullList();
const mappa: Record = {};
- 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;
}
/**
- * 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.
*/
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):
- * ogni download passa da una signed URL che scade in un minuto.
+ * I documenti sono un campo file protetto e non hanno URL permanenti (DD-016 regola 4):
+ * ogni download passa da un token che scade a breve, l'equivalente PocketBase della
+ * signed URL Supabase.
*/
-export async function urlFirmato(path: string): Promise {
- const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60);
- if (error) throw error;
- return data.signedUrl;
+export async function urlFirmato(recordId: string, filename: string): Promise {
+ const token = await pb.files.getToken();
+ return pb.files.getURL({ id: recordId, collectionName: "profili_giocatore" }, filename, {
+ token,
+ });
}
-export async function scaricaFile(path: string): Promise {
- const url = await urlFirmato(path);
+export async function scaricaFile(recordId: string, filename: string): Promise {
+ const url = await urlFirmato(recordId, filename);
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.
+ *
+ * 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() {
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;
+ const riga = await upsertByFilter(
+ "profili_giocatore",
+ "giocatore = {:giocatore}",
+ { giocatore: profilo.giocatoreId },
+ {
+ 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.
onSuccess: (profilo) => {
@@ -72,46 +136,39 @@ export function useSalvaProfilo() {
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
+const CAMPO_SEZIONE: Record = {
+ "documento-fronte": "documento_fronte",
+ "documento-retro": "documento_retro",
+ certificato: "certificato",
+ foto: "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.
+ * Carica un file nel campo giusto del profilo del giocatore (crea il profilo se non
+ * esiste ancora) e restituisce id del record e nome del file, da salvare nella bozza
+ * locale. Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
+ * dall'utente; le API rule della collection impediscono comunque di scrivere sul profilo
+ * di qualcun altro.
*/
export async function caricaFile(
giocatoreId: string,
sezione: SezioneFile,
file: File,
- pathPrecedente?: string | null,
-): Promise {
+): Promise<{ id: string; filename: 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 {
- const { error } = await supabase.storage.from(BUCKET).remove([path]);
- if (error) throw error;
+ const campo = CAMPO_SEZIONE[sezione];
+ const riga = await upsertByFilter(
+ "profili_giocatore",
+ "giocatore = {:giocatore}",
+ { giocatore: giocatoreId },
+ { giocatore: giocatoreId, [campo]: file },
+ );
+ return { id: riga.id, filename: riga[campo] };
}
diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx
index f14ff1f..6039f99 100644
--- a/src/routes/admin.tsx
+++ b/src/routes/admin.tsx
@@ -338,19 +338,21 @@ function Documento({
label,
stato,
path,
+ recordId,
}: {
icona: React.ReactNode;
label: string;
stato: StatoScadenza | "presente" | "assente";
path: string | null;
+ recordId: string | null;
}) {
const [inCorso, setInCorso] = useState(false);
async function scarica() {
- if (!path || inCorso) return;
+ if (!path || !recordId || inCorso) return;
setInCorso(true);
try {
- await scaricaFile(path);
+ await scaricaFile(recordId, path);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Download non riuscito");
} finally {
@@ -362,7 +364,7 @@ function Documento({
}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
+ recordId={profilo?.id ?? null}
/>
}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
+ recordId={profilo?.id ?? null}
/>
}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
+ recordId={profilo?.id ?? null}
/>
/avatar.jpg");
-assert.equal(urlAvatar("g1"), url, "deterministico: nessuna chiamata di rete coinvolta");
-assert.notEqual(urlAvatar("g2"), url, "id diversi -> percorsi diversi");
+const url = urlAvatarDaRiga(riga);
+
+assert.ok(url.includes("avatar_giocatori"), "punta alla collection degli avatar");
+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");
diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts
index e19b741..13ce713 100644
--- a/test/unit/profili-core.test.ts
+++ b/test/unit/profili-core.test.ts
@@ -1,10 +1,8 @@
/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */
import assert from "node:assert/strict";
import {
- aRigaProfilo,
completamento,
csvTesseramento,
- daRigaProfilo,
sezioniComplete,
statoScadenza,
type Profilo,
@@ -19,6 +17,7 @@ import {
import { dividiNome } from "@/lib/crapp-data";
const vuoto: Profilo = {
+ id: null,
giocatoreId: "g1",
dataNascita: null,
luogoNascita: null,
@@ -77,17 +76,6 @@ assert.equal(
);
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 -----------------------------------------------------------
const oggi = "2026-08-30";
assert.equal(statoScadenza(null, null, oggi), "mancante");