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:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
Reference in New Issue
Block a user