diff --git a/susconecta/.vscode/tasks.json b/susconecta/.vscode/tasks.json new file mode 100644 index 0000000..7ee26e4 --- /dev/null +++ b/susconecta/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build Next.js susconecta", + "type": "shell", + "command": "npm run build", + "problemMatcher": [ + "$tsc" + ], + "group": "build" + } + ] +} \ No newline at end of file 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/app/api/create-user/route.ts b/susconecta/app/api/create-user/route.ts deleted file mode 100644 index 35f12dc..0000000 --- a/susconecta/app/api/create-user/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { ENV_CONFIG } from '@/lib/env-config' - -export async function POST(req: NextRequest) { - try { - const body = await req.json().catch(() => ({})) - const target = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/create-user` - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY, - } - const auth = req.headers.get('authorization') - if (auth) headers.Authorization = auth - - const r = await fetch(target, { method: 'POST', headers, body: JSON.stringify(body) }) - if (r.status === 404 || r.status >= 500) { - // fallback to signup - const email = body.email - let password = body.password - const full_name = body.full_name - const phone = body.phone - const role = body.role || (Array.isArray(body.roles) ? body.roles[0] : undefined) - if (!password) password = `senha${Math.floor(Math.random()*900)+100}!` - const userType = (role && String(role).toLowerCase() === 'paciente') ? 'paciente' : 'profissional' - const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup` - const signupRes = await fetch(signupUrl, { - method: 'POST', - headers: { 'Content-Type':'application/json', 'Accept':'application/json', 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY }, - body: JSON.stringify({ email, password, data: { userType, full_name, phone } }) - }) - const text = await signupRes.text() - try { return NextResponse.json({ fallback: true, from: 'signup', result: JSON.parse(text) }, { status: signupRes.status }) } catch { return new NextResponse(text, { status: signupRes.status }) } - } - - const text = await r.text() - try { return NextResponse.json(JSON.parse(text), { status: r.status }) } catch { return new NextResponse(text, { status: r.status }) } - } catch (err:any) { - console.error('[app/api/create-user] error', err) - return NextResponse.json({ error: 'Bad gateway', details: String(err) }, { status: 502 }) - } -} diff --git a/susconecta/app/login-admin/page.tsx b/susconecta/app/login-admin/page.tsx index 958c714..f99f365 100644 --- a/susconecta/app/login-admin/page.tsx +++ b/susconecta/app/login-admin/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { useAuth } from '@/hooks/useAuth' +import { sendMagicLink } from '@/lib/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -12,6 +13,9 @@ import { AuthenticationError } from '@/lib/auth' export default function LoginAdminPage() { const [credentials, setCredentials] = useState({ email: '', password: '' }) const [error, setError] = useState('') + const [magicMessage, setMagicMessage] = useState('') + const [magicError, setMagicError] = useState('') + const [magicLoading, setMagicLoading] = useState(false) const [loading, setLoading] = useState(false) const router = useRouter() const { login } = useAuth() @@ -44,6 +48,27 @@ export default function LoginAdminPage() { } } + const handleSendMagicLink = async () => { + if (!credentials.email) { + setMagicError('Por favor, preencha o email antes de solicitar o magic link.') + return + } + + setMagicLoading(true) + setMagicError('') + setMagicMessage('') + + try { + const res = await sendMagicLink(credentials.email, { target: 'admin' }) + setMagicMessage(res?.message ?? 'Magic link enviado. Verifique seu email.') + } catch (err: any) { + console.error('[MAGIC-LINK ADMIN] erro ao enviar:', err) + setMagicError(err?.message ?? String(err)) + } finally { + setMagicLoading(false) + } + } + return (
@@ -108,6 +133,25 @@ export default function LoginAdminPage() { {loading ? 'Entrando...' : 'Entrar no Sistema Administrativo'} +
+
Ou entre usando um magic link (sem senha)
+ + {magicError && ( + + {magicError} + + )} + + {magicMessage && ( + + {magicMessage} + + )} + + +
+
+
Ou entre usando um magic link (sem senha)
+ + {magicError && ( + + {magicError} + + )} + + {magicMessage && ( + + {magicMessage} + + )} + + +
+
+
Ou entre usando um magic link (sem senha)
+ + {magicError && ( + + {magicError} + + )} + + {magicMessage && ( + + {magicMessage} + + )} + + +
+
+
+ {especialidadesHero.map(item => ( + + ))} +
+ + +
+ setTipoConsulta('teleconsulta')} + className={cn(toggleBase, tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')} + > + + Teleconsulta + + setTipoConsulta('local')} + className={cn(toggleBase, tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')} + > + + Consulta no local + + + + + + + + + +
+ +
+ {profissionais.map(medico => ( + +
+ + + + + +
+
+

{medico.nome}

+ {medico.especialidade} +
+
+ + + {medico.avaliacao.toFixed(1)} • {medico.avaliacaoQtd} avaliações + + {medico.crm} + {medico.convenios.join(', ')} +
+
+ +
+ + {tipoConsulta === 'local' && medico.atendeLocal && ( +
+ + + {medico.endereco} + +
+ {medico.cidade} + {medico.precoLocal} +
+
+ )} + + {tipoConsulta === 'teleconsulta' && medico.atendeTele && ( +
+ + + Teleconsulta + + {medico.precoTeleconsulta} +
+ )} + +
+ + + Idiomas: Português, Inglês + + + + Acolhimento em cada consulta + + + + Pagamento seguro + + + + Especialista recomendado + +
+ +
+ + + +
+ +
+
+ {medico.agenda.map(coluna => { + const horarios = agendasExpandida[medico.id] ? coluna.horarios : coluna.horarios.slice(0, 3) + return ( +
+

{coluna.label}

+

{coluna.data}

+
+ {horarios.length ? ( + horarios.map(horario => ( + + )) + ) : ( + + Sem horários + + )} + {!agendasExpandida[medico.id] && coluna.horarios.length > 3 && ( + +{coluna.horarios.length - 3} horários + )} +
+
+ ) + })} +
+
+
+ ))} + + {!profissionais.length && ( + + Nenhum profissional encontrado. Ajuste os filtros para ver outras opções. + + )} +
+ + !open && setMedicoSelecionado(null)}> + + {medicoSelecionado && ( + <> + + + {medicoSelecionado.nome} + +

+ {medicoSelecionado.especialidade} • {medicoSelecionado.crm} +

+
+ +
+
+ + + {medicoSelecionado.avaliacao.toFixed(1)} ({medicoSelecionado.avaliacaoQtd} avaliações) + + {medicoSelecionado.planosSaude.join(' • ')} +
+ + + + + Experiência + + + Planos de saúde + + + Consultórios + + + Serviços + + + Opiniões ({medicoSelecionado.opinioes.length}) + + + Agenda + + + + + {medicoSelecionado.experiencia.map((linha, index) => ( +

{linha}

+ ))} +
+ + + {medicoSelecionado.planosSaude.map(plano => ( + + {plano} + + ))} + + + + {medicoSelecionado.consultorios.length ? ( + medicoSelecionado.consultorios.map((consultorio, index) => ( +
+

{consultorio.nome}

+

{consultorio.endereco}

+

Telefone: {consultorio.telefone}

+
+ )) + ) : ( +

Atendimento exclusivamente por teleconsulta.

+ )} +
+ + + {medicoSelecionado.servicos.map(servico => ( +
+ {servico.nome} + {servico.preco} +
+ ))} +
+ + + {medicoSelecionado.opinioes.map(opiniao => ( +
+
+ {opiniao.paciente} + {opiniao.data} +
+
+ {Array.from({ length: opiniao.nota }).map((_, index) => ( + + ))} +
+

{opiniao.comentario}

+
+ ))} +
+ + +

+ Escolha o melhor horário disponível para sua consulta. +

+
+
+ {medicoSelecionado.agenda.map(coluna => ( +
+

{coluna.label}

+

{coluna.data}

+
+ {coluna.horarios.length ? ( + coluna.horarios.map(horario => ( + + )) + ) : ( + + Sem horários + + )} +
+
+ ))} +
+
+
+
+
+ + )} +
+
+
+
+ ) +} diff --git a/susconecta/app/resultados/page.tsx b/susconecta/app/resultados/page.tsx index 96de1cc..1318172 100644 --- a/susconecta/app/resultados/page.tsx +++ b/susconecta/app/resultados/page.tsx @@ -1,979 +1,10 @@ -'use client' - -import { useMemo, useState } from 'react' -import { useSearchParams, useRouter } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { Toggle } from '@/components/ui/toggle' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Badge } from '@/components/ui/badge' -import { Avatar, AvatarFallback } from '@/components/ui/avatar' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { - Building2, - Filter, - Globe, - HeartPulse, - Languages, - MapPin, - ShieldCheck, - Star, - Stethoscope, - ChevronRight, - UserRound -} from 'lucide-react' -import { cn } from '@/lib/utils' - -type TipoConsulta = 'teleconsulta' | 'local' - -type Medico = { - id: number - nome: string - especialidade: string - crm: string - categoriaHero: string - avaliacao: number - avaliacaoQtd: number - convenios: string[] - endereco?: string - bairro?: string - cidade?: string - precoLocal?: string - precoTeleconsulta?: string - atendeLocal: boolean - atendeTele: boolean - agenda: { - label: string - data: string - horarios: string[] - }[] - experiencia: string[] - planosSaude: string[] - consultorios: { nome: string; endereco: string; telefone: string }[] - servicos: { nome: string; preco: string }[] - opinioes: { id: number; paciente: string; data: string; nota: number; comentario: string }[] -} - -type MedicoBase = Omit & - Partial>; - -const especialidadesHero = ['Psicólogo', 'Médico clínico geral', 'Pediatra', 'Dentista', 'Ginecologista', 'Veja mais'] - -const medicosBase: MedicoBase[] = [ - { - id: 1, - nome: 'Paula Pontes', - especialidade: 'Psicóloga clínica', - crm: 'CRP SE 19/4244', - categoriaHero: 'Psicólogo', - avaliacao: 4.9, - avaliacaoQtd: 23, - convenios: ['Amil', 'Unimed'], - endereco: 'Av. Doutor José Machado de Souza, 200 - Jardins', - bairro: 'Jardins', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 180', - precoTeleconsulta: 'R$ 160', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: [] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '10:00', '11:00', '12:00', '13:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['11:00', '12:00', '13:00', '14:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 2, - nome: 'Marcos Vieira', - especialidade: 'Psicólogo comportamental', - crm: 'CRP SE 24/1198', - categoriaHero: 'Psicólogo', - avaliacao: 4.7, - avaliacaoQtd: 31, - convenios: ['SulAmérica', 'Bradesco Saúde'], - endereco: 'Rua Juarez Távora, 155 - São José', - bairro: 'São José', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 190', - precoTeleconsulta: 'R$ 150', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['14:00', '16:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['10:00', '11:00', '12:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:00', '10:30'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 3, - nome: 'Julia Azevedo', - especialidade: 'Psicóloga infantil', - crm: 'CRP SE 23/4476', - categoriaHero: 'Psicólogo', - avaliacao: 4.95, - avaliacaoQtd: 45, - convenios: ['NotreDame Intermédica', 'Particular'], - precoTeleconsulta: 'R$ 140', - atendeLocal: false, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:30', '11:30'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['08:30', '10:00', '11:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 4, - nome: 'Rafael Sousa', - especialidade: 'Neuropsicólogo', - crm: 'CRP BA 03/8874', - categoriaHero: 'Psicólogo', - avaliacao: 4.82, - avaliacaoQtd: 52, - convenios: ['Amil', 'Particular'], - endereco: 'Rua Riachão, 77 - Centro', - bairro: 'Centro', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 210', - atendeLocal: true, - atendeTele: false, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: [] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '13:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:00', '12:00'] }, - { label: 'Dom.', data: '12 Out', horarios: ['09:30'] } - ] - }, - { - id: 5, - nome: 'Lucas Amorim', - especialidade: 'Clínico geral', - crm: 'CRM SE 5122', - categoriaHero: 'Médico clínico geral', - avaliacao: 4.88, - avaliacaoQtd: 98, - convenios: ['Amil', 'Bradesco Saúde'], - endereco: 'Av. Beira Mar, 402 - Coroa do Meio', - bairro: 'Coroa do Meio', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 220', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00', '11:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:30', '14:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 6, - nome: 'Dr. João Silva', - especialidade: 'Ortopedista', - crm: 'CRM RJ 90876', - categoriaHero: 'Veja mais', - avaliacao: 4.7, - avaliacaoQtd: 96, - convenios: ['Unimed', 'Bradesco Saúde'], - endereco: 'Av. Beira Mar, 1450 - Farolândia', - bairro: 'Farolândia', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 310', - atendeLocal: true, - atendeTele: false, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: [] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 7, - nome: 'Dra. Beatriz Moura', - especialidade: 'Ginecologista', - crm: 'CRM BA 52110', - categoriaHero: 'Veja mais', - avaliacao: 4.95, - avaliacaoQtd: 186, - convenios: ['NotreDame Intermédica', 'Particular', 'Amil'], - endereco: 'Rua Tobias Barreto, 512 - Bairro São José', - bairro: 'São José', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 280', - precoTeleconsulta: 'R$ 240', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['14:00', '15:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:30'] }, - { label: 'Dom.', data: '12 Out', horarios: ['11:30'] } - ] - }, - { - id: 8, - nome: 'Dr. André Lemos', - especialidade: 'Gastroenterologista', - crm: 'CRM SE 9033', - categoriaHero: 'Veja mais', - avaliacao: 4.75, - avaliacaoQtd: 105, - convenios: ['SulAmérica', 'Unimed'], - endereco: 'Rua Arauá, 22 - Centro', - bairro: 'Centro', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 340', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['13:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00', '11:00', '15:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:30', '10:15'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 9, - nome: 'Dra. Fernanda Lima', - especialidade: 'Médico clínico geral', - crm: 'CRM SE 7890', - categoriaHero: 'Médico clínico geral', - avaliacao: 4.9, - avaliacaoQtd: 110, - convenios: ['Amil', 'Unimed', 'Bradesco Saúde'], - endereco: 'Av. Rio de Janeiro, 300 - São José', - bairro: 'São José', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 250', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00', '11:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:30', '14:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 10, - nome: 'Dra. Helena Castro', - especialidade: 'Pediatra geral', - crm: 'CRM SE 7812', - categoriaHero: 'Pediatra', - avaliacao: 4.92, - avaliacaoQtd: 134, - convenios: ['Amil', 'Unimed', 'SulAmérica'], - endereco: 'Rua José Hipólito, 98 - Suíssa', - bairro: 'Suíssa', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 260', - precoTeleconsulta: 'R$ 220', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00', '11:30'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:30', '10:00', '14:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:30', '11:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 11, - nome: 'Dr. Vinícius Prado', - especialidade: 'Pediatra neonatologista', - crm: 'CRM SE 6331', - categoriaHero: 'Pediatra', - avaliacao: 4.85, - avaliacaoQtd: 89, - convenios: ['Bradesco Saúde', 'NotreDame Intermédica'], - endereco: 'Av. Augusto Franco, 2220 - Siqueira Campos', - bairro: 'Siqueira Campos', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 280', - atendeLocal: true, - atendeTele: false, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: [] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00', '11:30'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:00'] }, - { label: 'Dom.', data: '12 Out', horarios: ['09:30'] } - ] - }, - { - id: 12, - nome: 'Dra. Marina Salles', - especialidade: 'Pediatra emergencista', - crm: 'CRM BA 85660', - categoriaHero: 'Pediatra', - avaliacao: 4.78, - avaliacaoQtd: 57, - convenios: ['Particular', 'Amil'], - precoTeleconsulta: 'R$ 210', - atendeLocal: false, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['13:00', '15:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:30', '12:30'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 13, - nome: 'Dr. Caio Moura', - especialidade: 'Pediatra pneumologista', - crm: 'CRM SE 7345', - categoriaHero: 'Pediatra', - avaliacao: 4.91, - avaliacaoQtd: 102, - convenios: ['SulAmérica', 'Unimed'], - endereco: 'Av. Hermes Fontes, 445 - Salgado Filho', - bairro: 'Salgado Filho', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 270', - precoTeleconsulta: 'R$ 230', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['10:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:30', '11:30'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 14, - nome: 'Dra. Patrícia Freire', - especialidade: 'Cirurgiã-dentista', - crm: 'CRO SE 2133', - categoriaHero: 'Dentista', - avaliacao: 4.9, - avaliacaoQtd: 176, - convenios: ['OdontoPrev', 'Amil Dental'], - endereco: 'Rua Itabaiana, 410 - Centro', - bairro: 'Centro', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 200', - precoTeleconsulta: 'R$ 160', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00', '13:30'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:30', '10:00', '14:30'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:30', '11:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 15, - nome: 'Dr. Henrique Assis', - especialidade: 'Implantodontista', - crm: 'CRO SE 1450', - categoriaHero: 'Dentista', - avaliacao: 4.83, - avaliacaoQtd: 94, - convenios: ['SulAmérica Odonto', 'Particular'], - endereco: 'Av. Jorge Amado, 321 - Atalaia', - bairro: 'Atalaia', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 350', - atendeLocal: true, - atendeTele: false, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: [] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:30'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 16, - nome: 'Dra. Lívia Teles', - especialidade: 'Ortodontista', - crm: 'CRO BA 11567', - categoriaHero: 'Dentista', - avaliacao: 4.88, - avaliacaoQtd: 140, - convenios: ['Uniodonto', 'Amil Dental', 'Particular'], - precoTeleconsulta: 'R$ 120', - atendeLocal: false, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['17:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '10:30', '15:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['08:30', '09:30'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 17, - nome: 'Dr. Pablo Menezes', - especialidade: 'Endodontista', - crm: 'CRO SE 2099', - categoriaHero: 'Dentista', - avaliacao: 4.76, - avaliacaoQtd: 83, - convenios: ['OdontoPrev', 'SulAmérica Odonto'], - endereco: 'Rua Cedro, 70 - Grageru', - bairro: 'Grageru', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 230', - precoTeleconsulta: 'R$ 190', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00', '13:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:30'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 18, - nome: 'Dra. Beatriz Moura', - especialidade: 'Ginecologista obstetra', - crm: 'CRM BA 52110', - categoriaHero: 'Ginecologista', - avaliacao: 4.95, - avaliacaoQtd: 186, - convenios: ['NotreDame Intermédica', 'Particular', 'Amil'], - endereco: 'Rua Tobias Barreto, 512 - São José', - bairro: 'São José', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 280', - precoTeleconsulta: 'R$ 240', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['14:00', '15:00'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:30'] }, - { label: 'Dom.', data: '12 Out', horarios: ['11:30'] } - ] - }, - { - id: 19, - nome: 'Dra. Camila Albuquerque', - especialidade: 'Ginecologista endocrinologista', - crm: 'CRM SE 6774', - categoriaHero: 'Ginecologista', - avaliacao: 4.89, - avaliacaoQtd: 122, - convenios: ['SulAmérica', 'Unimed'], - endereco: 'Av. Gonçalo Prado Rollemberg, 167 - São José', - bairro: 'São José', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 300', - atendeLocal: true, - atendeTele: false, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: [] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:30', '15:00'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 20, - nome: 'Dra. Renata Figueiredo', - especialidade: 'Ginecologista minimamente invasiva', - crm: 'CRM PE 112233', - categoriaHero: 'Ginecologista', - avaliacao: 4.94, - avaliacaoQtd: 208, - convenios: ['Amil', 'Bradesco Saúde', 'Particular'], - precoTeleconsulta: 'R$ 260', - atendeLocal: false, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['09:00', '10:30'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['08:30', '11:00', '14:30'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['09:45'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - }, - { - id: 21, - nome: 'Dr. Eduardo Fontes', - especialidade: 'Ginecologista mastologista', - crm: 'CRM SE 7012', - categoriaHero: 'Ginecologista', - avaliacao: 4.8, - avaliacaoQtd: 95, - convenios: ['NotreDame Intermédica', 'SulAmérica'], - endereco: 'Rua Teófilo Dantas, 55 - Centro', - bairro: 'Centro', - cidade: 'Aracaju • SE', - precoLocal: 'R$ 310', - atendeLocal: true, - atendeTele: true, - agenda: [ - { label: 'Hoje', data: '9 Out', horarios: ['08:30'] }, - { label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:30'] }, - { label: 'Sáb.', data: '11 Out', horarios: ['10:00', '12:00'] }, - { label: 'Dom.', data: '12 Out', horarios: [] } - ] - } -] - -const medicosMock: Medico[] = medicosBase.map((medico, index) => ({ - ...medico, - experiencia: - medico.experiencia ?? - [ - 'Especialista com atuação reconhecida pelo respectivo conselho profissional.', - 'Formação continuada em instituições nacionais e internacionais.', - 'Atendimento humanizado com foco em resultados sustentáveis.' - ], - planosSaude: - medico.planosSaude ?? medico.convenios ?? ['Amil', 'Unimed', 'SulAmérica'], - consultorios: - medico.consultorios ?? - (medico.endereco - ? [ - { - nome: 'Clínica principal', - endereco: `${medico.endereco}${medico.cidade ? ` — ${medico.cidade}` : ''}`, - telefone: '(79) 4002-8922' - } - ] - : []), - servicos: - medico.servicos ?? - [ - { - nome: 'Consulta inicial', - preco: medico.precoLocal ?? medico.precoTeleconsulta ?? 'Sob consulta' - }, - { nome: 'Retorno em até 30 dias', preco: 'R$ 150' } - ], - opinioes: - medico.opinioes ?? - [ - { - id: index * 2 + 1, - paciente: 'Ana P.', - data: '01/09/2025', - nota: 5, - comentario: 'Profissional muito atencioso e detalhista.' - }, - { - id: index * 2 + 2, - paciente: 'Marcos L.', - data: '18/08/2025', - nota: 4, - comentario: 'Explicações claras e ambiente acolhedor.' - } - ] -})) - -export default function ResultadosPage() { - const params = useSearchParams() - const router = useRouter() - const [tipoConsulta, setTipoConsulta] = useState( - params.get('tipo') === 'presencial' ? 'local' : 'teleconsulta' - ) - const [especialidadeHero, setEspecialidadeHero] = useState(params.get('especialidade') || 'Psicólogo') - const [convenio, setConvenio] = useState('Todos') - const [bairro, setBairro] = useState('Todos') - const [agendasExpandida, setAgendasExpandida] = useState>({}) - const [medicoSelecionado, setMedicoSelecionado] = useState(null) - const [abaDetalhe, setAbaDetalhe] = useState('experiencia') - - const profissionais = useMemo(() => { - return medicosMock.filter(medico => { - if (tipoConsulta === 'local' && !medico.atendeLocal) return false - if (tipoConsulta === 'teleconsulta' && !medico.atendeTele) return false - if (convenio !== 'Todos' && !medico.convenios.includes(convenio)) return false - if (bairro !== 'Todos' && medico.bairro !== bairro) return false - if (especialidadeHero !== 'Veja mais' && medico.categoriaHero !== especialidadeHero) return false - if (especialidadeHero === 'Veja mais' && medico.categoriaHero !== 'Veja mais') return false - return true - }) - }, [bairro, convenio, especialidadeHero, tipoConsulta]) - - const toggleBase = - 'rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]' +import React, { Suspense } from 'react' +import ResultadosClient from './ResultadosClient' +export default function Page() { return ( -
-
-
-
-
-

Resultados da procura

-

Qual especialização você deseja?

-
- -
-
- {especialidadesHero.map(item => ( - - ))} -
-
- -
- setTipoConsulta('teleconsulta')} - className={cn(toggleBase, tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')} - > - - Teleconsulta - - setTipoConsulta('local')} - className={cn(toggleBase, tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')} - > - - Consulta no local - - - - - - - - - -
- -
- {profissionais.map(medico => ( - -
- - - - - -
-
-

{medico.nome}

- {medico.especialidade} -
-
- - - {medico.avaliacao.toFixed(1)} • {medico.avaliacaoQtd} avaliações - - {medico.crm} - {medico.convenios.join(', ')} -
-
- -
- - {tipoConsulta === 'local' && medico.atendeLocal && ( -
- - - {medico.endereco} - -
- {medico.cidade} - {medico.precoLocal} -
-
- )} - - {tipoConsulta === 'teleconsulta' && medico.atendeTele && ( -
- - - Teleconsulta - - {medico.precoTeleconsulta} -
- )} - -
- - - Idiomas: Português, Inglês - - - - Acolhimento em cada consulta - - - - Pagamento seguro - - - - Especialista recomendado - -
- -
- - - -
- -
-
- {medico.agenda.map(coluna => { - const horarios = agendasExpandida[medico.id] ? coluna.horarios : coluna.horarios.slice(0, 3) - return ( -
-

{coluna.label}

-

{coluna.data}

-
- {horarios.length ? ( - horarios.map(horario => ( - - )) - ) : ( - - Sem horários - - )} - {!agendasExpandida[medico.id] && coluna.horarios.length > 3 && ( - +{coluna.horarios.length - 3} horários - )} -
-
- ) - })} -
-
-
- ))} - - {!profissionais.length && ( - - Nenhum profissional encontrado. Ajuste os filtros para ver outras opções. - - )} -
- - !open && setMedicoSelecionado(null)}> - - {medicoSelecionado && ( - <> - - - {medicoSelecionado.nome} - -

- {medicoSelecionado.especialidade} • {medicoSelecionado.crm} -

-
- -
-
- - - {medicoSelecionado.avaliacao.toFixed(1)} ({medicoSelecionado.avaliacaoQtd} avaliações) - - {medicoSelecionado.planosSaude.join(' • ')} -
- - - - - Experiência - - - Planos de saúde - - - Consultórios - - - Serviços - - - Opiniões ({medicoSelecionado.opinioes.length}) - - - Agenda - - - - - {medicoSelecionado.experiencia.map((linha, index) => ( -

{linha}

- ))} -
- - - {medicoSelecionado.planosSaude.map(plano => ( - - {plano} - - ))} - - - - {medicoSelecionado.consultorios.length ? ( - medicoSelecionado.consultorios.map((consultorio, index) => ( -
-

{consultorio.nome}

-

{consultorio.endereco}

-

Telefone: {consultorio.telefone}

-
- )) - ) : ( -

Atendimento exclusivamente por teleconsulta.

- )} -
- - - {medicoSelecionado.servicos.map(servico => ( -
- {servico.nome} - {servico.preco} -
- ))} -
- - - {medicoSelecionado.opinioes.map(opiniao => ( -
-
- {opiniao.paciente} - {opiniao.data} -
-
- {Array.from({ length: opiniao.nota }).map((_, index) => ( - - ))} -
-

{opiniao.comentario}

-
- ))} -
- - -

- Escolha o melhor horário disponível para sua consulta. -

-
-
- {medicoSelecionado.agenda.map(coluna => ( -
-

{coluna.label}

-

{coluna.data}

-
- {coluna.horarios.length ? ( - coluna.horarios.map(horario => ( - - )) - ) : ( - - Sem horários - - )} -
-
- ))} -
-
-
-
-
- - )} -
-
-
-
+ Carregando...
}> + + ) } 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 9cbb0e5..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('@')) { @@ -301,7 +341,24 @@ export function PatientRegistrationForm({ const apiMod = await import('@/lib/api'); const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); const userId = (userResponse.user as any)?.id || (userResponse.user as any)?.user_id || (userResponse.user as any)?.id; - if (pacienteId && userId && typeof apiMod.vincularUserIdPaciente === 'function') { + + // Guard: verify userId is present and looks plausible before attempting to PATCH + const isPlausibleUserId = (id: any) => { + if (!id) return false; + const s = String(id).trim(); + if (!s) return false; + // quick UUID v4-ish check (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) or numeric id fallback + const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const numeric = /^\d+$/; + return uuidV4.test(s) || numeric.test(s) || s.length >= 8; + }; + + if (!pacienteId) { + console.warn('[PatientForm] pacienteId ausente; pulando vinculação de user_id'); + } else if (!isPlausibleUserId(userId)) { + // Do not attempt to PATCH when userId is missing/invalid to avoid 400s + console.warn('[PatientForm] userId inválido ou ausente; não será feita a vinculação. userResponse:', userResponse); + } else if (typeof apiMod.vincularUserIdPaciente === 'function') { console.log('[PatientForm] Vinculando user_id ao paciente:', pacienteId, userId); try { await apiMod.vincularUserIdPaciente(pacienteId, String(userId)); @@ -318,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 { @@ -402,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 ff40e79..baf2de3 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -599,10 +599,35 @@ 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 ?? DEFAULT_LANDING; + if (explicit) { + + try { + const u = new URL(explicit); + return u.toString().replace(/\/$/, ''); + } catch (e) { + } + } + + const base = DEFAULT_REDIRECT_BASE.replace(/\/$/, ''); + let path = '/'; + if (target === 'paciente') path = '/paciente'; + else if (target === 'medico') path = '/profissional'; + else if (target === 'admin') path = '/dashboard'; + return `${base}${path}`; +} + // Token salvo no browser (aceita auth_token ou token) function getAuthToken(): string | null { if (typeof window === "undefined") return null; @@ -671,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; @@ -1081,17 +1120,17 @@ export async function excluirPaciente(id: string | number): Promise { * Este endpoint usa a service role key e valida se o requisitante é administrador. */ export async function assignRoleServerSide(userId: string, role: string): Promise { - const url = `/api/assign-role`; - const token = getAuthToken(); - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ user_id: userId, role }), - }); - return await parse(res); + // Atribuição de roles é uma operação privilegiada que requer a + // service_role key do Supabase (ou equivalente) e validação de permissões + // server-side. Não execute isso do cliente. + // + // Antes este helper chamava `/api/assign-role` (um proxy server-side). + // Agora que o projeto deve usar apenas o endpoint público seguro de + // criação de usuários (OpenAPI `/create-user`), a atribuição deve ocorrer + // dentro desse endpoint no backend. Portanto este helper foi descontinuado + // no cliente para evitar qualquer tentativa de realizar operação + // privilegiada no navegador. + throw new Error('assignRoleServerSide is not available in the client. Use the backend /create-user endpoint which performs role assignment server-side.'); } // ===== PACIENTES (Extra: verificação de CPF duplicado) ===== export async function verificarCpfDuplicado(cpf: string): Promise { @@ -1402,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; + } } @@ -1587,6 +1652,13 @@ export type CreateUserInput = { full_name: string; phone?: string | null; role: UserRoleEnum; + // Optional: when provided, backend can use this to send magic links that redirect + // to the given URL or interpret `target` to build a role-specific redirect. + emailRedirectTo?: string; + // Compatibility: some integrations expect `redirect_url` as the parameter name + // for the post-auth redirect. Include it so backend/functions receive it. + redirect_url?: string; + target?: 'paciente' | 'medico' | 'admin' | 'default'; }; export type CreatedUser = { @@ -1618,26 +1690,56 @@ export function gerarSenhaAleatoria(): string { } export async function criarUsuario(input: CreateUserInput): Promise { - // When running in the browser, call our Next.js proxy to avoid CORS/preflight - // issues that some Edge Functions may have. On server-side, call the function - // directly. - if (typeof window !== 'undefined') { - const proxyUrl = '/api/create-user' - const res = await fetch(proxyUrl, { + // Prefer calling the Functions path first in environments where /create-user + // is not mapped at the API root (this avoids expected 404 noise). Keep the + // root /create-user as a fallback for deployments that expose it. + const functionsUrl = `${API_BASE}/functions/v1/create-user`; + const url = `${API_BASE}/create-user`; + + let res: Response | null = null; + try { + res = await fetch(functionsUrl, { method: 'POST', headers: { ...baseHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify(input), - }) - return await parse(res as Response) + }); + } catch (err: any) { + console.error('[criarUsuario] fetch error for', functionsUrl, err); + // Attempt root /create-user fallback when functions path can't be reached + try { + console.warn('[criarUsuario] tentando fallback para', url); + const res2 = await fetch(url, { + method: 'POST', + headers: { ...baseHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return await parse(res2 as Response); + } catch (err2: any) { + console.error('[criarUsuario] fallback /create-user also failed', err2); + throw new Error( + 'Falha ao contatar o endpoint /functions/v1/create-user e o fallback /create-user também falhou. Verifique disponibilidade e CORS. Detalhes: ' + + (err?.message ?? String(err)) + ' | fallback: ' + (err2?.message ?? String(err2)) + ); + } } - const url = `${API_BASE}/functions/v1/create-user`; - const res = await fetch(url, { - method: "POST", - headers: { ...baseHeaders(), "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - return await parse(res); + // If we got a response but it's 404 (route not found), try the root path too + if (res && !res.ok && res.status === 404) { + try { + console.warn('[criarUsuario] /functions/v1/create-user returned 404; trying root path', url); + const res2 = await fetch(url, { + method: 'POST', + headers: { ...baseHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return await parse(res2 as Response); + } catch (err2: any) { + console.error('[criarUsuario] fallback /create-user failed after 404', err2); + // Fall through to parse original response to provide friendly error + } + } + + return await parse(res as Response); } // ===== ALTERNATIVA: Criar usuário diretamente via Supabase Auth ===== @@ -1689,14 +1791,22 @@ export async function criarUsuarioDirectAuth(input: { } const responseData = await response.json(); - const userId = responseData.user?.id || responseData.id; - - console.log('[DIRECT AUTH] Usuário criado:', userId); - + // Try several common locations for the returned user id depending on Supabase configuration + const userId = responseData?.user?.id || responseData?.id || responseData?.data?.user?.id || responseData?.data?.id; + + // If no user id was returned, treat this as a failure. Some Supabase setups (e.g. magic link / invite) + // may not return the user id immediately. In that case we cannot safely link the profile to a user. + if (!userId) { + console.warn('[DIRECT AUTH] signup response did not include a user id; response:', responseData); + throw new Error('Signup did not return a user id (provider may be configured for magic links or pending confirmation). Fallback cannot determine created user id.'); + } + + console.log('[DIRECT AUTH] Usuário criado:', userId); + // NOTE: Role assignments MUST be done by the backend (Edge Function or server) // when creating the user. The frontend should NOT attempt to assign roles. // The backend should use the service role key to insert into user_roles table. - + return { success: true, user: { @@ -1723,93 +1833,72 @@ 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 { - // Prefer server-side creation (new OpenAPI create-user) so roles are assigned - // correctly (and magic link is sent). Fallback to direct Supabase signup if - // the server function is unavailable. - try { - const res = await criarUsuario({ email: medico.email, password: '', full_name: medico.full_name, phone: medico.phone_mobile, role: 'medico' as any }); - return res; - } catch (err) { - console.warn('[CRIAR MÉDICO] Falha no endpoint server-side create-user, tentando fallback direto no Supabase Auth:', err); - // Fallback: create directly in Supabase Auth (old behavior) - } - - // --- Fallback to previous direct signup --- - const senha = gerarSenhaAleatoria(); - const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`; - const payload = { - email: medico.email, - password: senha, - data: { - userType: 'profissional', - full_name: medico.full_name, - phone: medico.phone_mobile, - } - }; - - const response = await fetch(signupUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "apikey": ENV_CONFIG.SUPABASE_ANON_KEY, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - let errorMsg = `Erro ao criar usuário (${response.status})`; - try { const errorData = JSON.parse(errorText); errorMsg = errorData.msg || errorData.message || errorData.error_description || errorMsg; } catch {} - throw new Error(errorMsg); - } - - const responseData = await response.json(); - return { success: true, user: responseData.user || responseData, email: medico.email, password: senha }; + 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(); + const resp = await criarUsuario({ email: medico.email, password, full_name: medico.full_name, phone: medico.phone_mobile, role: 'medico' as any, emailRedirectTo, redirect_url, target: 'medico' }); + // Return backend response plus the generated password so the UI can show/save it + return { ...(resp as any), password }; } // 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 { - // Prefer server-side creation (OpenAPI create-user) to assign role 'paciente'. + 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(); + const resp = await criarUsuario({ email: paciente.email, password, full_name: paciente.full_name, phone: paciente.phone_mobile, role: 'paciente' as any, emailRedirectTo, redirect_url, target: 'paciente' }); + // Return backend response plus the generated password so the UI can show/save it + return { ...(resp as any), password }; +} + + +export async function sendMagicLink( + email: string, + options?: { emailRedirectTo?: string; target?: 'paciente' | 'medico' | 'admin' | 'default'; redirectBase?: string } +): Promise<{ success: boolean; message?: string }> { + if (!email) throw new Error('Email obrigatório para enviar magic link'); + const url = `${API_BASE}/auth/v1/otp`; + const payload: any = { email }; + + const redirectUrl = buildRedirectUrl(options?.target, options?.emailRedirectTo, options?.redirectBase); + if (redirectUrl) { + // include both keys for broader compatibility across different Supabase setups + payload.options = { emailRedirectTo: redirectUrl, redirect_to: redirectUrl, redirect_url: redirectUrl }; + } + try { - const res = await criarUsuario({ email: paciente.email, password: '', full_name: paciente.full_name, phone: paciente.phone_mobile, role: 'paciente' as any }); - return res; - } catch (err) { - console.warn('[CRIAR PACIENTE] Falha no endpoint server-side create-user, tentando fallback direto no Supabase Auth:', err); - } + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + apikey: ENV_CONFIG.SUPABASE_ANON_KEY, + }, + body: JSON.stringify(payload), + }); - // Fallback to previous direct signup behavior - const senha = gerarSenhaAleatoria(); - const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`; - const payload = { - email: paciente.email, - password: senha, - data: { - userType: 'paciente', - full_name: paciente.full_name, - phone: paciente.phone_mobile, + const text = await res.text(); + let json: any = null; + try { json = text ? JSON.parse(text) : null; } catch { json = null; } + + if (!res.ok) { + const msg = (json && (json.error || json.msg || json.message)) ?? text ?? res.statusText; + throw new Error(String(msg)); } - }; - const response = await fetch(signupUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "apikey": ENV_CONFIG.SUPABASE_ANON_KEY, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - let errorMsg = `Erro ao criar usuário (${response.status})`; - try { const errorData = JSON.parse(errorText); errorMsg = errorData.msg || errorData.message || errorData.error_description || errorMsg; } catch {} - throw new Error(errorMsg); + return { success: true, message: (json && (json.message || json.msg)) ?? 'Magic link enviado. Verifique seu email.' }; + } catch (err: any) { + console.error('[sendMagicLink] erro ao enviar magic link', err); + throw new Error(err?.message ?? 'Falha ao enviar magic link'); } - - const responseData = await response.json(); - return { success: true, user: responseData.user || responseData, email: paciente.email, password: senha }; } // ===== CEP (usado nos formulários) ===== @@ -1841,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 { diff --git a/susconecta/lib/auth.ts b/susconecta/lib/auth.ts index e21a67d..1c732bc 100644 --- a/susconecta/lib/auth.ts +++ b/susconecta/lib/auth.ts @@ -1,7 +1,6 @@ import type { LoginRequest, LoginResponse, - RefreshTokenResponse, AuthError, UserData } from '@/types/auth'; @@ -67,11 +66,16 @@ async function processResponse(response: Response): Promise { if (!response.ok) { const errorMessage = data?.message || data?.error || response.statusText || 'Erro na autenticação'; const errorCode = data?.code || String(response.status); - + + // Log raw text as well to help debug cases where JSON is empty or {} + let rawText = ''; + try { rawText = await response.clone().text(); } catch (_) { rawText = ''; } + console.error('[AUTH ERROR]', { url: response.url, status: response.status, data, + rawText, }); throw new AuthenticationError(errorMessage, errorCode, data); @@ -89,66 +93,48 @@ export async function loginUser( password: string, userType: 'profissional' | 'paciente' | 'administrador' ): Promise { - let url = AUTH_ENDPOINTS.LOGIN; - - const payload = { - email, - password, - }; - - console.log('[AUTH-API] Iniciando login...', { - email, - userType, - url, - payload, - timestamp: new Date().toLocaleTimeString() - }); - - console.log('🔑 [AUTH-API] Credenciais sendo usadas no login:'); - console.log('📧 Email:', email); - console.log('🔐 Senha:', password); - console.log('👤 UserType:', userType); - - // Delay para visualizar na aba Network - await new Promise(resolve => setTimeout(resolve, 50)); - try { - console.log('[AUTH-API] Enviando requisição de login...'); - - // Debug: Log request sem credenciais sensíveis - debugRequest('POST', url, getLoginHeaders(), payload); - - const response = await fetch(url, { + // Use the canonical Supabase token endpoint for password grant as configured in ENV_CONFIG. + const url = AUTH_ENDPOINTS.LOGIN; + + const payload = { email, password }; + + console.log('[AUTH-API] Iniciando login (using AUTH_ENDPOINTS.LOGIN)...', { + email, + userType, + url, + timestamp: new Date().toLocaleTimeString() + }); + + // Do not log passwords. Log only non-sensitive info. + debugRequest('POST', url, getLoginHeaders(), { email }); + + // Perform single, explicit request to the configured token endpoint. + let response: Response; + try { + response = await fetch(url, { method: 'POST', headers: getLoginHeaders(), body: JSON.stringify(payload), }); + } catch (networkError) { + console.error('[AUTH-API] Network error when calling', url, networkError); + throw new AuthenticationError('Não foi possível contatar o serviço de autenticação', 'AUTH_NETWORK_ERROR', networkError); + } - console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, { - url: response.url, - status: response.status, - timestamp: new Date().toLocaleTimeString() - }); + console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, { + url: response.url, + status: response.status, + timestamp: new Date().toLocaleTimeString() + }); - // Se falhar, mostrar detalhes do erro - if (!response.ok) { - try { - const errorText = await response.text(); - console.error('[AUTH-API] Erro detalhado:', { - status: response.status, - statusText: response.statusText, - body: errorText, - headers: Object.fromEntries(response.headers.entries()) - }); - } catch (e) { - console.error('[AUTH-API] Não foi possível ler erro da resposta'); - } - } + // If endpoint is missing, make the error explicit + if (response.status === 404) { + console.error('[AUTH-API] Final response was 404 (Not Found) for', url); + throw new AuthenticationError('Signin endpoint not found (404) at configured AUTH_ENDPOINTS.LOGIN', 'SIGNIN_NOT_FOUND', { url }); + } - // Delay adicional para ver status code - await new Promise(resolve => setTimeout(resolve, 50)); - - const data = await processResponse(response); + const data = await processResponse(response); console.log('[AUTH] Dados recebidos da API:', data); @@ -256,10 +242,10 @@ export async function logoutUser(token: string): Promise { /** * Serviço para renovar token JWT */ -export async function refreshAuthToken(refreshToken: string): Promise { +export async function refreshAuthToken(refreshToken: string): Promise { const url = AUTH_ENDPOINTS.REFRESH; - console.log('[AUTH] Renovando token'); + console.log('[AUTH] Renovando token via REFRESH endpoint'); try { const response = await fetch(url, { @@ -272,22 +258,43 @@ export async function refreshAuthToken(refreshToken: string): Promise(response); - - console.log('[AUTH] Token renovado com sucesso'); - return data; + const data = await processResponse(response); + + console.log('[AUTH] Dados recebidos no refresh:', data); + + if (!data || !data.access_token) { + console.error('[AUTH] Refresh não retornou access_token:', data); + throw new AuthenticationError('Refresh não retornou access_token', 'NO_TOKEN_RECEIVED', data); + } + + // Adaptar para o mesmo formato usado no login + const adapted: LoginResponse = { + access_token: data.access_token || data.token, + refresh_token: data.refresh_token || null, + token_type: data.token_type || 'Bearer', + expires_in: data.expires_in || 3600, + user: { + id: data.user?.id || data.id || '', + email: data.user?.email || data.email || '', + name: data.user?.name || data.name || '', + userType: (data.user?.userType as any) || 'paciente', + profile: data.user?.profile || data.profile || {} + } + }; + + console.log('[AUTH] Token renovado com sucesso (adapted)', { + tokenSnippet: adapted.access_token?.substring(0, 20) + '...' + }); + + return adapted; } catch (error) { console.error('[AUTH] Erro ao renovar token:', error); - + if (error instanceof AuthenticationError) { throw error; } - - throw new AuthenticationError( - 'Não foi possível renovar a sessão', - 'REFRESH_ERROR', - error - ); + + throw new AuthenticationError('Não foi possível renovar a sessão', 'REFRESH_ERROR', error); } } diff --git a/susconecta/lib/debug-utils.ts b/susconecta/lib/debug-utils.ts index b08ee4c..fdfd64d 100644 --- a/susconecta/lib/debug-utils.ts +++ b/susconecta/lib/debug-utils.ts @@ -9,7 +9,6 @@ export function debugRequest( body?: any ) { if (process.env.NODE_ENV !== 'development') return; - const headersWithoutSensitive = Object.keys(headers).reduce((acc, key) => { // Não logar valores sensíveis, apenas nomes if (key.toLowerCase().includes('apikey') || key.toLowerCase().includes('authorization')) { @@ -20,15 +19,35 @@ export function debugRequest( return acc; }, {} as Record); - const bodyShape = body ? Object.keys(typeof body === 'string' ? JSON.parse(body) : body) : []; + const bodyShape = body ? Object.keys(typeof body === 'string' ? (() => { + try { return JSON.parse(body); } catch { return {}; } + })() : body) : []; - console.log('[DEBUG] Request Preview:', { - method, - path: new URL(url).pathname, - query: new URL(url).search, - headerNames: Object.keys(headers), - headers: headersWithoutSensitive, - bodyShape, - timestamp: new Date().toISOString(), - }); + // Support relative URLs (e.g. '/api/signin-user') by providing a base when needed. + try { + let urlObj: URL; + try { + urlObj = new URL(url); + } catch (e) { + // Fallbacks: browser origin or localhost for server-side dev + const base = (typeof window !== 'undefined' && window.location && window.location.origin) + ? window.location.origin + : 'http://localhost'; + urlObj = new URL(url, base); + } + + console.log('[DEBUG] Request Preview:', { + method, + path: urlObj.pathname, + query: urlObj.search, + headerNames: Object.keys(headers), + headers: headersWithoutSensitive, + bodyShape, + timestamp: new Date().toISOString(), + }); + } catch (err) { + // Never throw from debug tooling; keep best-effort logging + // eslint-disable-next-line no-console + console.warn('[DEBUG] debugRequest failed to parse URL or body', { url, error: err }); + } } \ No newline at end of file diff --git a/susconecta/lib/http.ts b/susconecta/lib/http.ts index 6172af2..cd55f8f 100644 --- a/susconecta/lib/http.ts +++ b/susconecta/lib/http.ts @@ -73,17 +73,26 @@ class HttpClient { } const data = await response.json() - + + // Data pode ser um LoginResponse completo ou apenas { access_token } + const newAccessToken = data.access_token || data.token || null + const newRefreshToken = data.refresh_token || null + + if (!newAccessToken) { + console.error('[HTTP] Refresh não retornou access_token', data) + throw new Error('Refresh did not return access_token') + } + // Atualizar tokens de forma atômica - localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, data.access_token) - if (data.refresh_token) { - localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token) + localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, newAccessToken) + if (newRefreshToken) { + localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, newRefreshToken) } console.log('[HTTP] Token renovado com sucesso!', { timestamp: new Date().toLocaleTimeString() }) - return data.access_token + return newAccessToken } /** diff --git a/susconecta/package-lock.json b/susconecta/package-lock.json index f1244cc..af7dba3 100644 --- a/susconecta/package-lock.json +++ b/susconecta/package-lock.json @@ -41,6 +41,7 @@ "@radix-ui/react-toggle": "latest", "@radix-ui/react-toggle-group": "latest", "@radix-ui/react-tooltip": "latest", + "@supabase/supabase-js": "^2.75.0", "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", @@ -2627,6 +2628,80 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.75.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.0.tgz", + "integrity": "sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.75.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.0.tgz", + "integrity": "sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.75.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.0.tgz", + "integrity": "sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.75.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.0.tgz", + "integrity": "sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.75.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.0.tgz", + "integrity": "sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.75.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.0.tgz", + "integrity": "sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.75.0", + "@supabase/functions-js": "2.75.0", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "2.75.0", + "@supabase/realtime-js": "2.75.0", + "@supabase/storage-js": "2.75.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3250,7 +3325,6 @@ "version": "22.18.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3262,6 +3336,12 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3325,6 +3405,15 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", @@ -9144,6 +9233,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/trim-canvas": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz", @@ -9344,7 +9439,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -9519,6 +9613,22 @@ "d3-timer": "^3.0.1" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9634,6 +9744,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/susconecta/pages/api/create-user.ts b/susconecta/pages/api/create-user.ts deleted file mode 100644 index 515a20d..0000000 --- a/susconecta/pages/api/create-user.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next' -import { ENV_CONFIG } from '@/lib/env-config' - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }) - - try { - const target = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/create-user` - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY, - } - - // forward authorization header if present (keeps the caller's identity) - if (req.headers.authorization) headers.Authorization = String(req.headers.authorization) - - const r = await fetch(target, { - method: 'POST', - headers, - body: JSON.stringify(req.body), - }) - // If the function is not available (404) or returns server error (5xx), - // perform a fallback: create the user via Supabase Auth signup so the - // client doesn't experience a hard 404. - if (r.status === 404 || r.status >= 500) { - console.warn('[proxy/create-user] function returned', r.status, 'falling back to signup') - // attempt signup - try { - const body = req.body || {} - const email = body.email - let password = body.password - const full_name = body.full_name - const phone = body.phone - const role = body.role || (Array.isArray(body.roles) ? body.roles[0] : undefined) - - // generate a password if none provided - if (!password) { - const rand = Math.floor(Math.random() * 900) + 100 - password = `senha${rand}!` - } - - const userType = (role && String(role).toLowerCase() === 'paciente') ? 'paciente' : 'profissional' - - const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup` - const signupRes = await fetch(signupUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY, - }, - body: JSON.stringify({ - email, - password, - data: { userType, full_name, phone } - }), - }) - - const signupText = await signupRes.text() - try { - const signupJson = JSON.parse(signupText) - return res.status(signupRes.status).json({ fallback: true, from: 'signup', result: signupJson }) - } catch { - return res.status(signupRes.status).send(signupText) - } - } catch (err2: any) { - console.error('[proxy/create-user] fallback signup failed', err2) - return res.status(502).json({ error: 'Bad gateway (fallback failed)', details: String(err2) }) - } - } - - const text = await r.text() - try { - const json = JSON.parse(text) - return res.status(r.status).json(json) - } catch { - // not JSON, return raw text - res.status(r.status).send(text) - } - } catch (err: any) { - console.error('[proxy/create-user] error', err) - return res.status(502).json({ error: 'Bad gateway', details: String(err) }) - } -} diff --git a/susconecta/pnpm-lock.yaml b/susconecta/pnpm-lock.yaml index 8797bfc..d66aef2 100644 --- a/susconecta/pnpm-lock.yaml +++ b/susconecta/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: eslint-plugin-unicorn: specifier: ^61.0.2 version: 61.0.2(eslint@9.36.0(jiti@2.5.1)) + lightningcss: + specifier: ^1.30.2 + version: 1.30.2 next: specifier: ^15.5.4 version: 15.5.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2500,70 +2503,140 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.30.1: resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.30.1: resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.30.1: resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.30.1: resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.30.1: resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.30.1: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5743,36 +5816,69 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.30.2: + optional: true + lightningcss-darwin-arm64@1.30.1: optional: true + lightningcss-darwin-arm64@1.30.2: + optional: true + lightningcss-darwin-x64@1.30.1: optional: true + lightningcss-darwin-x64@1.30.2: + optional: true + lightningcss-freebsd-x64@1.30.1: optional: true + lightningcss-freebsd-x64@1.30.2: + optional: true + lightningcss-linux-arm-gnueabihf@1.30.1: optional: true + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + lightningcss-linux-arm64-gnu@1.30.1: optional: true + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + lightningcss-linux-arm64-musl@1.30.1: optional: true + lightningcss-linux-arm64-musl@1.30.2: + optional: true + lightningcss-linux-x64-gnu@1.30.1: optional: true + lightningcss-linux-x64-gnu@1.30.2: + optional: true + lightningcss-linux-x64-musl@1.30.1: optional: true + lightningcss-linux-x64-musl@1.30.2: + optional: true + lightningcss-win32-arm64-msvc@1.30.1: optional: true + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + lightningcss-win32-x64-msvc@1.30.1: optional: true + lightningcss-win32-x64-msvc@1.30.2: + optional: true + lightningcss@1.30.1: dependencies: detect-libc: 2.1.0 @@ -5788,6 +5894,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.0 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 diff --git a/susconecta/src/app/api/assign-role/route.ts b/susconecta/src/app/api/assign-role/route.ts deleted file mode 100644 index 157f3b2..0000000 --- a/susconecta/src/app/api/assign-role/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextResponse } from 'next/server' -import { ENV_CONFIG } from '@/lib/env-config' - -type Body = { - user_id: string - role: string -} - -async function getRequesterIdFromToken(token: string | null): Promise { - if (!token) return null - try { - const url = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/user` - const res = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY, Authorization: `Bearer ${token}` } }) - if (!res.ok) return null - const data = await res.json().catch(() => null) - return data?.id ?? null - } catch (err) { - console.error('[assign-role] erro ao obter requester id', err) - return null - } -} - -export async function POST(req: Request) { - try { - const body = (await req.json()) as Body - if (!body || !body.user_id || !body.role) return NextResponse.json({ error: 'user_id and role required' }, { status: 400 }) - - // Business rule: there is no separate 'paciente' role — patients are any user. - // Prevent creation/assignment of a 'paciente' role to avoid confusion. - if (body.role === 'paciente') { - return NextResponse.json({ error: "role 'paciente' must not be created or assigned; patients are regular users" }, { status: 400 }) - } - - const authHeader = req.headers.get('authorization') - const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : null - - const requesterId = await getRequesterIdFromToken(token) - if (!requesterId) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) - - // Check if requester is administrador - const checkUrl = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/user_roles?user_id=eq.${requesterId}&role=eq.administrador` - const checkRes = await fetch(checkUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Authorization: `Bearer ${token}` } }) - if (!checkRes.ok) return NextResponse.json({ error: 'forbidden' }, { status: 403 }) - const arr = await checkRes.json().catch(() => []) - if (!Array.isArray(arr) || arr.length === 0) return NextResponse.json({ error: 'forbidden' }, { status: 403 }) - - // Insert role using service role key from environment (must be set on the server) - const svcKey = process.env.SUPABASE_SERVICE_ROLE_KEY - if (!svcKey) return NextResponse.json({ error: 'server misconfigured' }, { status: 500 }) - - const insertUrl = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/user_roles` - const insertRes = await fetch(insertUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json', apikey: svcKey, Authorization: `Bearer ${svcKey}` }, - body: JSON.stringify({ user_id: body.user_id, role: body.role }), - }) - - if (!insertRes.ok) { - const errBody = await insertRes.text().catch(() => null) - console.error('[assign-role] insert failed', insertRes.status, errBody) - return NextResponse.json({ error: 'failed to assign role', details: errBody }, { status: insertRes.status }) - } - - const result = await insertRes.json().catch(() => null) - return NextResponse.json({ ok: true, data: result }) - } catch (err) { - console.error('[assign-role] unexpected error', err) - return NextResponse.json({ error: 'internal error' }, { status: 500 }) - } -}