From 60630cd9db0c38929e951041303a0facc87cfaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Fri, 17 Oct 2025 18:30:15 -0300 Subject: [PATCH] add-avatar-endpoint --- .../app/(main-routes)/pacientes/page.tsx | 2 +- .../forms/doctor-registration-form.tsx | 71 ++++++ .../forms/patient-registration-form.tsx | 75 +++++- susconecta/lib/api.ts | 218 +++++++++++++++--- 4 files changed, 336 insertions(+), 30 deletions(-) diff --git a/susconecta/app/(main-routes)/pacientes/page.tsx b/susconecta/app/(main-routes)/pacientes/page.tsx index 0442b83..c09855e 100644 --- a/susconecta/app/(main-routes)/pacientes/page.tsx +++ b/susconecta/app/(main-routes)/pacientes/page.tsx @@ -53,7 +53,7 @@ export default function PacientesPage() { async function loadAll() { try { setLoading(true); - const data = await listarPacientes({ page: 1, limit: 20 }); + const data = await listarPacientes({ page: 1, limit: 50 }); if (Array.isArray(data)) { setPatients(data.map(normalizePaciente)); diff --git a/susconecta/components/forms/doctor-registration-form.tsx b/susconecta/components/forms/doctor-registration-form.tsx index 8e8b072..8f8a213 100644 --- a/susconecta/components/forms/doctor-registration-form.tsx +++ b/susconecta/components/forms/doctor-registration-form.tsx @@ -22,11 +22,13 @@ import { listarAnexosMedico, adicionarAnexoMedico, removerAnexoMedico, + removerFotoMedico, MedicoInput, Medico, criarUsuarioMedico, gerarSenhaAleatoria, } from "@/lib/api"; +import { getAvatarPublicUrl } from '@/lib/api'; ; import { buscarCepAPI } from "@/lib/api"; @@ -150,6 +152,7 @@ export function DoctorRegistrationForm({ const [errors, setErrors] = useState>({}); const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false, formacao: false, admin: false }); const [isSubmitting, setSubmitting] = useState(false); + const [isUploadingPhoto, setUploadingPhoto] = useState(false); const [isSearchingCEP, setSearchingCEP] = useState(false); const [photoPreview, setPhotoPreview] = useState(null); const [serverAnexos, setServerAnexos] = useState([]); @@ -242,6 +245,22 @@ export function DoctorRegistrationForm({ } catch (err) { console.error("[DoctorForm] Erro ao carregar anexos:", err); } + // Try to detect existing public avatar (no file extension) and set preview + try { + const url = getAvatarPublicUrl(String(doctorId)); + try { + const head = await fetch(url, { method: 'HEAD' }); + if (head.ok) { setPhotoPreview(url); } + else { + const get = await fetch(url, { method: 'GET' }); + if (get.ok) { setPhotoPreview(url); } + } + } catch (inner) { + // ignore network/CORS errors while detecting + } + } catch (detectErr) { + // ignore detection errors + } } catch (err) { console.error("[DoctorForm] Erro ao carregar médico:", err); } @@ -345,6 +364,27 @@ function setField(k: T, v: FormData[T]) { return Object.keys(e).length === 0; } + async function handleRemoverFotoServidor() { + if (mode !== 'edit' || !doctorId) return; + try { + setUploadingPhoto(true); + await removerFotoMedico(String(doctorId)); + setPhotoPreview(null); + alert('Foto removida com sucesso.'); + } catch (e: any) { + console.warn('[DoctorForm] erro ao remover foto do servidor', e); + if (String(e?.message || '').includes('401')) { + alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || '')); + } else if (String(e?.message || '').includes('403')) { + alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || '')); + } else { + alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.'); + } + } finally { + setUploadingPhoto(false); + } + } + function toPayload(): MedicoInput { // Converte dd/MM/yyyy para ISO (yyyy-MM-dd) se possível let isoDate: string | null = null; @@ -396,6 +436,18 @@ async function handleSubmit(ev: React.FormEvent) { if (!doctorId) throw new Error("ID do médico não fornecido para edição"); const payload = toPayload(); const saved = await atualizarMedico(String(doctorId), payload); + // If user selected a new photo, upload it + if (form.photo) { + try { + setUploadingPhoto(true); + await uploadFotoMedico(String(doctorId), form.photo); + } catch (upErr) { + console.warn('[DoctorForm] Falha ao enviar foto do médico:', upErr); + alert('Médico atualizado, mas falha ao enviar a foto. Tente novamente.'); + } finally { + setUploadingPhoto(false); + } + } onSaved?.(saved); alert("Médico atualizado com sucesso!"); if (inline) onClose?.(); @@ -458,6 +510,20 @@ async function handleSubmit(ev: React.FormEvent) { setPhotoPreview(null); setServerAnexos([]); + // If a photo was selected during creation, upload it now + if (form.photo) { + try { + setUploadingPhoto(true); + const docId = (savedDoctorProfile && (savedDoctorProfile.id || (Array.isArray(savedDoctorProfile) ? savedDoctorProfile[0]?.id : undefined))) || null; + if (docId) await uploadFotoMedico(String(docId), form.photo); + } catch (upErr) { + console.warn('[DoctorForm] Falha ao enviar foto do médico após criação:', upErr); + alert('Médico criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); + } finally { + setUploadingPhoto(false); + } + } + // 5. Notifica componente pai onSaved?.(savedDoctorProfile); } else { @@ -582,6 +648,11 @@ async function handleSubmit(ev: React.FormEvent) { + {mode === "edit" && ( + + )} {errors.photo &&

{errors.photo}

}

Máximo 5MB

diff --git a/susconecta/components/forms/patient-registration-form.tsx b/susconecta/components/forms/patient-registration-form.tsx index 84d0c4c..61a7d7b 100644 --- a/susconecta/components/forms/patient-registration-form.tsx +++ b/susconecta/components/forms/patient-registration-form.tsx @@ -27,6 +27,7 @@ import { criarUsuarioPaciente, criarPaciente, } from "@/lib/api"; +import { getAvatarPublicUrl } from '@/lib/api'; import { validarCPFLocal } from "@/lib/utils"; import { verificarCpfDuplicado } from "@/lib/api"; @@ -99,6 +100,7 @@ export function PatientRegistrationForm({ const [errors, setErrors] = useState>({}); const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false }); const [isSubmitting, setSubmitting] = useState(false); + const [isUploadingPhoto, setUploadingPhoto] = useState(false); const [isSearchingCEP, setSearchingCEP] = useState(false); const [photoPreview, setPhotoPreview] = useState(null); const [serverAnexos, setServerAnexos] = useState([]); @@ -145,6 +147,22 @@ export function PatientRegistrationForm({ const ax = await listarAnexos(String(patientId)).catch(() => []); setServerAnexos(Array.isArray(ax) ? ax : []); + // Try to detect existing public avatar (no file extension) and set preview + try { + const url = getAvatarPublicUrl(String(patientId)); + try { + const head = await fetch(url, { method: 'HEAD' }); + if (head.ok) { setPhotoPreview(url); } + else { + const get = await fetch(url, { method: 'GET' }); + if (get.ok) { setPhotoPreview(url); } + } + } catch (inner) { + // ignore network/CORS errors while detecting + } + } catch (detectErr) { + // ignore detection errors + } } catch (err) { console.error("[PatientForm] Erro ao carregar paciente:", err); } @@ -260,6 +278,28 @@ 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 a new photo was selected locally, remove existing public avatar (if any) then upload the new one + if (form.photo) { + try { + setUploadingPhoto(true); + // Attempt to remove existing avatar first (no-op if none) + try { + await removerFotoPaciente(String(patientId)); + // clear any cached preview so upload result will repopulate it + setPhotoPreview(null); + } catch (remErr) { + // If removal fails (permissions/CORS), continue to attempt upload — we don't want to block the user + console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); + } + await uploadFotoPaciente(String(patientId), form.photo); + } catch (upErr) { + console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); + // don't block the main update — show a warning + alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); + } finally { + setUploadingPhoto(false); + } + } onSaved?.(saved); alert("Paciente atualizado com sucesso!"); @@ -272,7 +312,7 @@ export function PatientRegistrationForm({ } else { // --- NOVA LÓGICA DE CRIAÇÃO --- const patientPayload = toPayload(); - const savedPatientProfile = await criarPaciente(patientPayload); + const savedPatientProfile = await criarPaciente(patientPayload); console.log(" Perfil do paciente criado:", savedPatientProfile); if (form.email && form.email.includes('@')) { @@ -335,6 +375,22 @@ export function PatientRegistrationForm({ setForm(initial); setPhotoPreview(null); setServerAnexos([]); + // If a photo was selected during creation, upload it now using the created patient id + if (form.photo) { + try { + setUploadingPhoto(true); + const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); + if (pacienteId) { + await uploadFotoPaciente(String(pacienteId), form.photo); + } + } catch (upErr) { + console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); + // Non-blocking: inform user + alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); + } finally { + setUploadingPhoto(false); + } + } onSaved?.(savedPatientProfile); return; } else { @@ -419,10 +475,23 @@ export function PatientRegistrationForm({ async function handleRemoverFotoServidor() { if (mode !== "edit" || !patientId) return; try { + setUploadingPhoto(true); await removerFotoPaciente(String(patientId)); - alert("Foto removida."); + // clear preview and inform user + setPhotoPreview(null); + alert('Foto removida com sucesso.'); } catch (e: any) { - alert(e?.message || "Não foi possível remover a foto."); + console.warn('[PatientForm] erro ao remover foto do servidor', e); + // Show detailed guidance for common cases + if (String(e?.message || '').includes('401')) { + alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || '')); + } else if (String(e?.message || '').includes('403')) { + alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || '')); + } else { + alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.'); + } + } finally { + setUploadingPhoto(false); } } diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 1a9ac4b..baf2de3 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -599,20 +599,24 @@ export async function deletarExcecao(id: string): Promise { -// ===== CONFIG ===== + const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ENV_CONFIG.SUPABASE_URL; const REST = `${API_BASE}/rest/v1`; + +const DEFAULT_AUTH_CALLBACK = 'https://mediconecta-app-liart.vercel.app/auth/callback'; + +const DEFAULT_LANDING = 'https://mediconecta-app-liart.vercel.app'; + // Helper to build/normalize redirect URLs function buildRedirectUrl(target?: 'paciente' | 'medico' | 'admin' | 'default', explicit?: string, redirectBase?: string) { - const DEFAULT_REDIRECT_BASE = redirectBase ?? 'https://mediconecta-app-liart.vercel.app'; + const DEFAULT_REDIRECT_BASE = redirectBase ?? DEFAULT_LANDING; if (explicit) { - // If explicit is already absolute, return trimmed + try { const u = new URL(explicit); return u.toString().replace(/\/$/, ''); } catch (e) { - // Not an absolute URL, fall through to build from base } } @@ -692,21 +696,35 @@ async function fetchWithFallback(url: string, headers: Record(res: Response): Promise { let json: any = null; + let rawText = ''; try { + // Attempt to parse JSON; many endpoints may return empty bodies (204/204) or plain text + // so guard against unexpected EOF during json parsing json = await res.json(); } catch (err) { - console.error("Erro ao parsear a resposta como JSON:", err); - } - - if (!res.ok) { - // Tenta também ler o body como texto cru para obter mensagens detalhadas - let rawText = ''; + // Try to capture raw text for better diagnostics try { rawText = await res.clone().text(); } catch (tErr) { - // ignore + rawText = ''; } - console.error("[API ERROR]", res.url, res.status, json, "raw:", rawText); + if (rawText) { + console.warn('Resposta não-JSON recebida do servidor. raw text:', rawText); + } else { + console.warn('Resposta vazia ou inválida recebida do servidor; não foi possível parsear JSON:', err); + } + } + + if (!res.ok) { + // If we didn't already collect rawText above, try to get it now for error messaging + if (!rawText) { + try { + rawText = await res.clone().text(); + } catch (tErr) { + rawText = ''; + } + } + console.error('[API ERROR]', res.url, res.status, json, 'raw:', rawText); const code = (json && (json.error?.code || json.code)) ?? res.status; const msg = (json && (json.error?.message || json.message || json.error)) ?? res.statusText; @@ -1423,15 +1441,41 @@ export async function vincularUserIdMedico(medicoId: string | number, userId: st * Retorna o paciente atualizado. */ export async function vincularUserIdPaciente(pacienteId: string | number, userId: string): Promise { - const url = `${REST}/patients?id=eq.${encodeURIComponent(String(pacienteId))}`; + // Validate pacienteId looks like a UUID (basic check) or at least a non-empty string/number + const idStr = String(pacienteId || '').trim(); + if (!idStr) throw new Error('ID do paciente inválido ao tentar vincular user_id.'); + + const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + const looksLikeUuid = uuidRegex.test(idStr); + // Allow non-UUID ids (legacy) but log a debug warning when it's not UUID + if (!looksLikeUuid) console.warn('[vincularUserIdPaciente] pacienteId does not look like a UUID:', idStr); + + const url = `${REST}/patients?id=eq.${encodeURIComponent(idStr)}`; const payload = { user_id: String(userId) }; + + // Debug-friendly masked headers + const headers = withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'); + const maskedHeaders = { ...headers } as Record; + if (maskedHeaders.Authorization) { + const a = maskedHeaders.Authorization as string; + maskedHeaders.Authorization = a.slice(0,6) + '...' + a.slice(-6); + } + console.debug('[vincularUserIdPaciente] PATCH', url, 'payload:', { ...payload }, 'headers(masked):', maskedHeaders); + const res = await fetch(url, { method: 'PATCH', - headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'), + headers, body: JSON.stringify(payload), }); - const arr = await parse(res); - return Array.isArray(arr) ? arr[0] : (arr as Paciente); + + // If parse throws, the existing parse() will log response details; ensure we also surface helpful context + try { + const arr = await parse(res); + return Array.isArray(arr) ? arr[0] : (arr as Paciente); + } catch (err) { + console.error('[vincularUserIdPaciente] erro ao vincular:', { pacienteId: idStr, userId, url }); + throw err; + } } @@ -1789,11 +1833,10 @@ export async function criarUsuarioDirectAuth(input: { // Criar usuário para MÉDICO no Supabase Auth (sistema de autenticação) export async function criarUsuarioMedico(medico: { email: string; full_name: string; phone_mobile: string; }): Promise { - // Rely on the server-side create-user endpoint (POST /create-user). The - // backend is responsible for role assignment and sending the magic link. - // Any error should be surfaced to the caller so it can be handled there. - const redirectBase = 'https://mediconecta-app-liart.vercel.app'; + const redirectBase = DEFAULT_LANDING; const emailRedirectTo = `${redirectBase.replace(/\/$/, '')}/profissional`; + // Use the role-specific landing as the redirect_url so the magic link + // redirects users directly to the app path (e.g. /profissional). const redirect_url = emailRedirectTo; // generate a secure-ish random password on the client so the caller can receive it const password = gerarSenhaAleatoria(); @@ -1804,9 +1847,10 @@ export async function criarUsuarioMedico(medico: { email: string; full_name: str // Criar usuário para PACIENTE no Supabase Auth (sistema de autenticação) export async function criarUsuarioPaciente(paciente: { email: string; full_name: string; phone_mobile: string; }): Promise { - // Rely on the server-side create-user endpoint (POST /create-user). - const redirectBase = 'https://mediconecta-app-liart.vercel.app'; + const redirectBase = DEFAULT_LANDING; const emailRedirectTo = `${redirectBase.replace(/\/$/, '')}/paciente`; + // Use the role-specific landing as the redirect_url so the magic link + // redirects users directly to the app path (e.g. /paciente). const redirect_url = emailRedirectTo; // generate a secure-ish random password on the client so the caller can receive it const password = gerarSenhaAleatoria(); @@ -1886,13 +1930,135 @@ export async function buscarCepAPI(cep: string): Promise<{ export async function listarAnexos(_id: string | number): Promise { return []; } export async function adicionarAnexo(_id: string | number, _file: File): Promise { return {}; } export async function removerAnexo(_id: string | number, _anexoId: string | number): Promise {} -export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; } -export async function removerFotoPaciente(_id: string | number): Promise {} +/** + * 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 + */ +export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> { + const userId = String(_id); + if (!userId) throw new Error('ID do paciente é obrigatório para upload de foto'); + if (!_file) throw new Error('Arquivo ausente'); + + // validações de formato e tamanho + const allowed = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowed.includes(_file.type)) { + throw new Error('Formato inválido. Aceitamos JPG, PNG ou WebP.'); + } + const maxBytes = 2 * 1024 * 1024; // 2MB + if (_file.size > maxBytes) { + throw new Error('Arquivo muito grande. Máx 2MB.'); + } + + const extMap: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + }; + const ext = extMap[_file.type] || 'jpg'; + + const objectPath = `avatars/${userId}/avatar.${ext}`; + const uploadUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`; + + // Build multipart form data + const form = new FormData(); + form.append('file', _file, `avatar.${ext}`); + + const headers: Record = { + // Supabase requires the anon key in 'apikey' header for client-side uploads + apikey: ENV_CONFIG.SUPABASE_ANON_KEY, + // Accept json + Accept: 'application/json', + }; + // if user is logged in, include Authorization header + const jwt = getAuthToken(); + if (jwt) headers.Authorization = `Bearer ${jwt}`; + + const res = await fetch(uploadUrl, { + method: 'POST', + headers, + body: form as any, + }); + + // Supabase storage returns 200/201 with object info or error + if (!res.ok) { + const raw = await res.text().catch(() => ''); + console.error('[uploadFotoPaciente] upload falhou', { status: res.status, raw }); + if (res.status === 401) throw new Error('Não autenticado'); + if (res.status === 403) throw new Error('Sem permissão para fazer upload'); + throw new Error('Falha no upload da imagem'); + } + + // Try to parse JSON response + let json: any = null; + try { json = await res.json(); } catch { json = null; } + + // 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/${encodeURIComponent('avatars')}/${encodeURIComponent(userId)}/avatar.${ext}`; + return { foto_url: publicUrl, Key: key }; +} + +/** + * Retorna a URL pública do avatar do usuário (acesso público) + * Path conforme OpenAPI: /storage/v1/object/public/avatars/{userId}/avatar.{ext} + * @param userId - ID do usuário (UUID) + * @param ext - extensão do arquivo: 'jpg' | 'png' | 'webp' (default 'jpg') + */ +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 + 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`; +} + +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; + } +} export async function listarAnexosMedico(_id: string | number): Promise { return []; } export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise { return {}; } export async function removerAnexoMedico(_id: string | number, _anexoId: string | number): Promise {} -export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; } -export async function removerFotoMedico(_id: string | number): Promise {} +export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> { + // reuse same implementation as paciente but place under avatars/{userId}/avatar + return await uploadFotoPaciente(_id, _file); +} +export async function removerFotoMedico(_id: string | number): Promise { + // reuse samme implementation + return await removerFotoPaciente(_id); +} // ===== PERFIS DE USUÁRIOS ===== export async function listarPerfis(params?: { page?: number; limit?: number; q?: string; }): Promise {