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 { cn } from "@/lib/utils";
import { urlAvatar } from "@/lib/avatar-store";
import { urlAvatarDaRiga, useAvatarMap } from "@/lib/avatar-store";
export function Avatar({
id,
@@ -19,8 +19,12 @@ export function Avatar({
const [errore, setErrore] = useState(false);
useEffect(() => setErrore(false), [id, bust]);
if (!errore) {
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id);
const { data: mappa } = useAvatarMap();
const riga = mappa?.[id];
if (riga && !errore) {
const base = urlAvatarDaRiga(riga);
const src = bust ? `${base}?v=${bust}` : base;
return (
<img
src={src}
+14 -6
View File
@@ -34,15 +34,17 @@ function Intestazione({ titolo, completa }: { titolo: string; completa: boolean
function CampoFile({
label,
path,
recordId,
sezione,
giocatoreId,
onCaricato,
}: {
label: string;
path: string | null;
recordId: string | null;
sezione: SezioneFile;
giocatoreId: string;
onCaricato: (path: string) => Promise<void>;
onCaricato: (risultato: { id: string; filename: string }) => Promise<void>;
}) {
const input = useRef<HTMLInputElement>(null);
const [inCorso, setInCorso] = useState(false);
@@ -53,7 +55,7 @@ function CampoFile({
if (!file) return;
setInCorso(true);
try {
const nuovo = await caricaFile(giocatoreId, sezione, file, path);
const nuovo = await caricaFile(giocatoreId, sezione, file);
await onCaricato(nuovo);
toast.success(`${label} caricato`);
} catch (errore) {
@@ -77,10 +79,10 @@ function CampoFile({
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{path ? (
{path && recordId ? (
<button
type="button"
onClick={() => void scaricaFile(path)}
onClick={() => void scaricaFile(recordId, path)}
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
aria-label={`Vedi ${label}`}
>
@@ -287,8 +289,10 @@ export function ProfiloAmministrativo({
}
// 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 });
const caricato =
(campo: "documentoFrontePath" | "documentoRetroPath" | "certificatoPath" | "fotoPath") =>
async (risultato: { id: string; filename: string }) =>
scrivi({ ...corrente, id: risultato.id, [campo]: risultato.filename });
const corpo = (
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
@@ -313,6 +317,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Foto fronte"
path={corrente.documentoFrontePath}
recordId={corrente.id}
sezione="documento-fronte"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoFrontePath")}
@@ -320,6 +325,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Foto retro"
path={corrente.documentoRetroPath}
recordId={corrente.id}
sezione="documento-retro"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoRetroPath")}
@@ -330,6 +336,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Certificato medico"
path={corrente.certificatoPath}
recordId={corrente.id}
sezione="certificato"
giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")}
@@ -341,6 +348,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Foto tessera"
path={corrente.fotoPath}
recordId={corrente.id}
sezione="foto"
giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")}
+48 -31
View File
@@ -1,41 +1,54 @@
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) {
return `${id}/${NOME_FILE}`;
export const AVATAR_KEY = ["avatar-giocatori"] as const;
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. */
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 <Avatar>: 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<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. */
@@ -77,17 +90,21 @@ function fileToBlob(file: File, size = 256): Promise<Blob> {
}
/** 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 { 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<RigaAvatar>(
"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<void> {
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);
}
+7 -72
View File
@@ -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;
+115 -58
View File
@@ -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<Record<string, Profilo>> {
const { data, error } = await supabaseNuoveTabelle
.from("profili_giocatore")
.select(COLONNE_PROFILO);
if (error) throw error;
const righe = await pb.collection("profili_giocatore").getFullList<RigaProfiloPocketBase>();
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;
}
/**
* 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<string> {
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<string> {
const token = await pb.files.getToken();
return pb.files.getURL({ id: recordId, collectionName: "profili_giocatore" }, filename, {
token,
});
}
export async function scaricaFile(path: string): Promise<void> {
const url = await urlFirmato(path);
export async function scaricaFile(recordId: string, filename: string): Promise<void> {
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<RigaProfiloPocketBase>(
"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<SezioneFile, keyof RigaProfiloPocketBase> = {
"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<string> {
): 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<void> {
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
const campo = CAMPO_SEZIONE[sezione];
const riga = await upsertByFilter<RigaProfiloPocketBase>(
"profili_giocatore",
"giocatore = {:giocatore}",
{ giocatore: giocatoreId },
{ giocatore: giocatoreId, [campo]: file },
);
return { id: riga.id, filename: riga[campo] };
}
+9 -3
View File
@@ -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({
<button
type="button"
onClick={scarica}
disabled={!path || inCorso}
disabled={!path || !recordId || inCorso}
className={cn(
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
statoClasse[stato],
@@ -431,24 +433,28 @@ function SchedaGiocatore({
label="Doc fronte"
stato={fronte}
path={profilo?.documentoFrontePath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
recordId={profilo?.id ?? null}
/>
<span
className={cn(
+3 -3
View File
@@ -119,9 +119,9 @@ function Profilo() {
e.target.value = "";
if (!file || !g) return;
try {
await caricaAvatar(g.id, file);
const riga = await caricaAvatar(g.id, file);
setBust(Date.now());
impostaAvatarEsiste(g.id, true);
impostaAvatarEsiste(g.id, riga);
toast.success("Immagine profilo aggiornata");
} catch {
toast.error("Non sono riuscito a caricare l'immagine");
@@ -169,7 +169,7 @@ function Profilo() {
try {
await rimuoviAvatar(g.id);
setBust(Date.now());
impostaAvatarEsiste(g.id, false);
impostaAvatarEsiste(g.id, null);
toast.success("Immagine rimossa");
} catch {
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.
*/
import assert from "node:assert/strict";
import { COLONNE_PROFILO } from "@/lib/profili-core";
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";
const env = { ...envDaFile(), ...process.env };
+19 -8
View File
@@ -1,16 +1,27 @@
/**
* Check dell'avatar giocatore: `bun test/unit/avatar-store.test.ts`.
* `urlAvatar` è puro (il bucket è pubblico, nessuna richiesta di rete): qui
* verifichiamo solo la forma dell'URL, non serve un database.
* `urlAvatarDaRiga` è puro (il campo non è protetto, nessuna richiesta di rete): qui
* verifichiamo solo la forma dell'URL a partire da una riga finta, non serve un database.
*/
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");
assert.ok(url.includes("g1/avatar.jpg"), "il percorso è <id>/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");
+1 -13
View File
@@ -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");