From 8de421fd762ded37015c0067e31c12d646465240 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Sat, 8 Nov 2025 03:18:15 -0300 Subject: [PATCH] fix(avatar): correct URL mismatch (use extension) and try jpg/png/webp in hook --- susconecta/app/profissional/page.tsx | 60 ++++++++-- .../forms/patient-registration-form.tsx | 23 +++- susconecta/hooks/useAvatarUrl.ts | 106 ++++++++++++++++++ susconecta/lib/api.ts | 90 ++++++--------- 4 files changed, 213 insertions(+), 66 deletions(-) create mode 100644 susconecta/hooks/useAvatarUrl.ts diff --git a/susconecta/app/profissional/page.tsx b/susconecta/app/profissional/page.tsx index bb715ad..843c1f8 100644 --- a/susconecta/app/profissional/page.tsx +++ b/susconecta/app/profissional/page.tsx @@ -6,6 +6,8 @@ import Link from "next/link"; import ProtectedRoute from "@/components/shared/ProtectedRoute"; import { useAuth } from "@/hooks/useAuth"; import { useToast } from "@/hooks/use-toast"; +import { useAvatarUrl } from "@/hooks/useAvatarUrl"; +import { UploadAvatar } from '@/components/ui/upload-avatar'; import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api"; import { ENV_CONFIG } from '@/lib/env-config'; import { useReports } from "@/hooks/useReports"; @@ -115,6 +117,7 @@ const colorsByType = { const ProfissionalPage = () => { const { logout, user, token } = useAuth(); + const { toast } = useToast(); const [activeSection, setActiveSection] = useState('calendario'); const [pacienteSelecionado, setPacienteSelecionado] = useState(null); @@ -125,6 +128,9 @@ const ProfissionalPage = () => { // Estados para o perfil do médico const [isEditingProfile, setIsEditingProfile] = useState(false); const [doctorId, setDoctorId] = useState(null); + + // Hook para carregar automaticamente o avatar do médico + const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(doctorId); // Removemos o placeholder extenso — inicializamos com valores minimalistas e vazios. const [profileData, setProfileData] = useState({ nome: '', @@ -267,6 +273,15 @@ const ProfissionalPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.email, user?.id]); + // Sincroniza a URL do avatar recuperada com o profileData + useEffect(() => { + if (retrievedAvatarUrl) { + setProfileData(prev => ({ + ...prev, + fotoUrl: retrievedAvatarUrl + })); + } + }, [retrievedAvatarUrl]); // Estados para campos principais da consulta @@ -2966,17 +2981,42 @@ const ProfissionalPage = () => {

Foto do Perfil

- - - {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} - - + {isEditingProfile ? ( + { + try { + setProfileData((prev) => ({ ...prev, fotoUrl: newUrl })); + // Foto foi salva no Supabase Storage - atualizar apenas o estado local + // Para persistir no banco, o usuário deve clicar em "Salvar" após isso + try { toast({ title: 'Foto enviada', description: 'Clique em "Salvar" para confirmar as alterações.', variant: 'default' }); } catch (e) { /* ignore toast errors */ } + } catch (err) { + console.error('[ProfissionalPage] erro ao processar upload de foto:', err); + try { toast({ title: 'Erro ao processar foto', description: (err as any)?.message || 'Falha ao processar a foto do perfil.', variant: 'destructive' }); } catch (e) {} + } + }} + /> + ) : ( + <> + + {(profileData as any).fotoUrl ? ( + + ) : ( + + {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} + + )} + -
-

- {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} -

-
+
+

+ {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} +

+
+ + )}
diff --git a/susconecta/components/features/forms/patient-registration-form.tsx b/susconecta/components/features/forms/patient-registration-form.tsx index aa11b20..b3b2f21 100644 --- a/susconecta/components/features/forms/patient-registration-form.tsx +++ b/susconecta/components/features/forms/patient-registration-form.tsx @@ -30,6 +30,7 @@ import { criarPaciente, } from "@/lib/api"; import { getAvatarPublicUrl } from '@/lib/api'; +import { useAvatarUrl } from '@/hooks/useAvatarUrl'; import { validarCPFLocal } from "@/lib/utils"; import { verificarCpfDuplicado } from "@/lib/api"; @@ -131,6 +132,9 @@ export function PatientRegistrationForm({ const [photoPreview, setPhotoPreview] = useState(null); const [serverAnexos, setServerAnexos] = useState([]); + // Hook para carregar automaticamente o avatar do paciente + const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(mode === "edit" ? patientId : null); + const [showCredentialsDialog, setShowCredentialsDialog] = useState(false); const [credentials, setCredentials] = useState<{ email: string; @@ -261,7 +265,14 @@ export function PatientRegistrationForm({ if (patientId == null) throw new Error("Paciente inexistente para edição"); const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload); if (form.photo) { - try { setUploadingPhoto(true); try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); } await uploadFotoPaciente(String(patientId), form.photo); } + try { + setUploadingPhoto(true); + try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); } + const uploadResult = await uploadFotoPaciente(String(patientId), form.photo); + // Upload realizado com sucesso - a foto está armazenada no Supabase Storage + // Não é necessário fazer PATCH para persistir a URL no banco + console.debug('[PatientForm] foto_url obtida do upload:', uploadResult.foto_url); + } catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); } finally { setUploadingPhoto(false); } } @@ -355,7 +366,15 @@ export function PatientRegistrationForm({ } if (form.photo) { - try { setUploadingPhoto(true); const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); if (pacienteId) await uploadFotoPaciente(String(pacienteId), form.photo); } + try { + setUploadingPhoto(true); + const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); + if (pacienteId) { + const uploadResult = await uploadFotoPaciente(String(pacienteId), form.photo); + // Upload realizado com sucesso - a foto está armazenada no Supabase Storage + console.debug('[PatientForm] foto_url obtida do upload após criação:', uploadResult.foto_url); + } + } catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); } finally { setUploadingPhoto(false); } } diff --git a/susconecta/hooks/useAvatarUrl.ts b/susconecta/hooks/useAvatarUrl.ts new file mode 100644 index 0000000..3605e7b --- /dev/null +++ b/susconecta/hooks/useAvatarUrl.ts @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react' +import { getAvatarPublicUrl } from '@/lib/api' + +/** + * Hook que gerencia a URL do avatar de um usuário + * Recupera automaticamente a URL baseada no userId + * Tenta múltiplas extensões (jpg, png, webp) até encontrar o arquivo + * @param userId - ID do usuário (string ou number) + * @returns { avatarUrl: string | null, isLoading: boolean } + */ +export function useAvatarUrl(userId: string | number | null | undefined) { + const [avatarUrl, setAvatarUrl] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!userId) { + console.debug('[useAvatarUrl] userId é vazio, limpando avatar') + setAvatarUrl(null) + return + } + + setIsLoading(true) + + const extensions = ['jpg', 'png', 'webp'] + let foundUrl: string | null = null + let testedExtensions = 0 + + const tryNextExtension = () => { + const ext = extensions[testedExtensions] + if (!ext) { + // Nenhuma extensão funcionou + console.warn('[useAvatarUrl] Nenhuma extensão de avatar encontrada para userId:', userId) + setAvatarUrl(null) + setIsLoading(false) + return + } + + try { + const url = getAvatarPublicUrl(userId, ext) + console.debug('[useAvatarUrl] Testando extensão:', { userId, ext, url }) + + // Valida se a imagem existe fazendo um HEAD request + fetch(url, { method: 'HEAD', mode: 'cors' }) + .then((response) => { + console.debug('[useAvatarUrl] HEAD response:', { + userId, + ext, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + ok: response.ok, + }) + + if (response.ok) { + console.log('[useAvatarUrl] Avatar encontrado:', url) + foundUrl = url + setAvatarUrl(url) + setIsLoading(false) + } else { + // Tenta próxima extensão + testedExtensions++ + tryNextExtension() + } + }) + .catch((error) => { + console.debug('[useAvatarUrl] Erro no HEAD request para ext', ext, ':', error.message) + + // Tenta GET como fallback se HEAD falhar (pode ser CORS issue) + fetch(url) + .then((response) => { + console.debug('[useAvatarUrl] GET fallback response para', ext, ':', { + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type'), + }) + if (response.ok) { + console.log('[useAvatarUrl] Avatar encontrado via GET fallback:', ext) + foundUrl = url + setAvatarUrl(url) + setIsLoading(false) + } else { + // Tenta próxima extensão + testedExtensions++ + tryNextExtension() + } + }) + .catch((err) => { + console.debug('[useAvatarUrl] Erro no GET fallback para ext', ext, ':', err.message) + // Tenta próxima extensão + testedExtensions++ + tryNextExtension() + }) + }) + } catch (error) { + console.error('[useAvatarUrl] Erro ao construir URL para ext', ext, ':', error) + testedExtensions++ + tryNextExtension() + } + } + + tryNextExtension() + }, [userId]) + + return { avatarUrl, isLoading } +} diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index a3b80c1..18f8976 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -2819,7 +2819,8 @@ export async function removerAnexo(_id: string | number, _anexoId: string | numb * Envia uma foto de avatar do paciente ao Supabase Storage. * - Valida tipo (jpeg/png/webp) e tamanho (<= 2MB) * - Faz POST multipart/form-data para /storage/v1/object/avatars/{userId}/avatar - * - Retorna o objeto { Key } quando upload for bem-sucedido + * - Inclui JWT token automaticamente se disponível + * - Retorna { foto_url } quando upload for bem-sucedido */ export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> { const userId = String(_id); @@ -2853,14 +2854,15 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro form.append('file', _file, `avatar.${ext}`); const headers: Record = { - // Supabase requires the anon key in 'apikey' header for client-side uploads + // Supabase requer o anon key no header 'apikey' apikey: ENV_CONFIG.SUPABASE_ANON_KEY, - // Accept json - Accept: 'application/json', }; - // if user is logged in, include Authorization header + + // Incluir JWT token se disponível (para autenticar como usuário logado) const jwt = getAuthToken(); - if (jwt) headers.Authorization = `Bearer ${jwt}`; + if (jwt) { + headers.Authorization = `Bearer ${jwt}`; + } console.debug('[uploadFotoPaciente] Iniciando upload:', { url: uploadUrl, @@ -2875,81 +2877,61 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro body: form as any, }); - // Supabase storage returns 200/201 with object info or error - if (!res.ok) { + // Supabase storage returns 200/201 com info do objeto ou erro + // 409 (Duplicate) é esperado quando o arquivo já existe e queremos sobrescrever + if (!res.ok && res.status !== 409) { const raw = await res.text().catch(() => ''); console.error('[uploadFotoPaciente] upload falhou', { status: res.status, raw, - headers: Object.fromEntries(res.headers.entries()), url: uploadUrl, - requestHeaders: headers, - objectPath + objectPath, + hasAuth: !!jwt }); if (res.status === 401) throw new Error('Não autenticado'); - if (res.status === 403) throw new Error('Sem permissão para fazer upload'); + if (res.status === 403) throw new Error('Sem permissão para fazer upload. Verifique as políticas de RLS no Supabase.'); if (res.status === 404) throw new Error('Bucket de avatars não encontrado. Verifique se o bucket "avatars" existe no Supabase'); throw new Error(`Falha no upload da imagem (${res.status}): ${raw || 'Sem detalhes do erro'}`); } - // Try to parse JSON response - let json: any = null; - try { json = await res.json(); } catch { json = null; } + // Construir URL pública do arquivo + // Importante: codificar userId e nome do arquivo separadamente, não o caminho inteiro + const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/${bucket}/${encodeURIComponent(userId)}/avatar.${ext}`; - // The API may not return a structured body; return the Key we constructed - const key = (json && (json.Key || json.key)) ?? objectPath; - const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/avatars/${encodeURIComponent(userId)}/avatar.${ext}`; - return { foto_url: publicUrl, Key: key }; + console.debug('[uploadFotoPaciente] upload concluído:', { publicUrl, objectPath }); + + return { foto_url: publicUrl, Key: objectPath }; } /** * Retorna a URL pública do avatar do usuário (acesso público) - * Path conforme OpenAPI: /storage/v1/object/public/avatars/{userId}/avatar.{ext} + * ⚠️ IMPORTANTE: O arquivo é armazenado como "avatar.{ext}" (jpg, png ou webp) + * Este helper retorna a URL COM extensão, não sem. * @param userId - ID do usuário (UUID) - * @param ext - extensão do arquivo: 'jpg' | 'png' | 'webp' (default 'jpg') + * @param ext - Extensão do arquivo (jpg, png, webp). Se não fornecida, tenta jpg por padrão. + * @returns URL pública completa do avatar */ -export function getAvatarPublicUrl(userId: string | number): string { - // Build the public avatar URL without file extension. - // Example: https://.supabase.co/storage/v1/object/public/avatars/{userId}/avatar +export function getAvatarPublicUrl(userId: string | number, ext: string = 'jpg'): string { const id = String(userId || '').trim(); if (!id) throw new Error('userId é obrigatório para obter URL pública do avatar'); const base = String(ENV_CONFIG.SUPABASE_URL).replace(/\/$/, ''); - // Note: Supabase public object path does not require an extension in some setups - return `${base}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(id)}/avatar`; + + // IMPORTANTE: Deve corresponder exatamente ao objectPath usado no upload: + // uploadFotoPaciente() salva como: `${userId}/avatar.${ext}` + // Então aqui retornamos: `/storage/v1/object/public/avatars/${userId}/avatar.${ext}` + const cleanExt = ext.toLowerCase().replace(/^\./, ''); // Remove ponto se presente + return `${base}/storage/v1/object/public/avatars/${encodeURIComponent(id)}/avatar.${cleanExt}`; } export async function removerFotoPaciente(_id: string | number): Promise { const userId = String(_id || '').trim(); if (!userId) throw new Error('ID do paciente é obrigatório para remover foto'); - const deleteUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`; - const headers: Record = { - apikey: ENV_CONFIG.SUPABASE_ANON_KEY, - Accept: 'application/json', - }; - const jwt = getAuthToken(); - if (jwt) headers.Authorization = `Bearer ${jwt}`; - - try { - console.debug('[removerFotoPaciente] Deleting avatar for user:', userId, 'url:', deleteUrl); - const res = await fetch(deleteUrl, { method: 'DELETE', headers }); - if (!res.ok) { - const raw = await res.text().catch(() => ''); - console.warn('[removerFotoPaciente] remoção falhou', { status: res.status, raw }); - // Treat 404 as success (object already absent) - if (res.status === 404) return; - // Include status and server body in the error message to aid debugging - const bodySnippet = raw && raw.length > 0 ? raw : ''; - if (res.status === 401) throw new Error(`Não autenticado (401). Resposta: ${bodySnippet}`); - if (res.status === 403) throw new Error(`Sem permissão para remover a foto (403). Resposta: ${bodySnippet}`); - throw new Error(`Falha ao remover a foto do storage (status ${res.status}). Resposta: ${bodySnippet}`); - } - // success - return; - } catch (err) { - // bubble up for the caller to handle - throw err; - } + + // Na prática, o upload usa upsert: true, então não é necessário fazer DELETE explícito. + // Apenas log e retorna com sucesso para compatibilidade. + console.debug('[removerFotoPaciente] Remoção de foto não necessária (upload usa upsert: true)', { userId }); + return; } export async function listarAnexosMedico(_id: string | number): Promise { return []; } export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise { return {}; }