diff --git a/admin/src/app/professionals/[id]/page.tsx b/admin/src/app/professionals/[id]/page.tsx index 22495f6..135e42b 100644 --- a/admin/src/app/professionals/[id]/page.tsx +++ b/admin/src/app/professionals/[id]/page.tsx @@ -47,6 +47,8 @@ export default function ProfessionalDetailPage() { const [savingPro, setSavingPro] = useState(false); const [savingUser, setSavingUser] = useState(false); const [validatingRethus, setValidatingRethus] = useState(false); + const [rethusResult, setRethusResult] = useState(null); + const [loadingRethus, setLoadingRethus] = useState(false); const [proForm, setProForm] = useState({ profession: '', identification: '', address: '', rate: '', rethus_code: '' }); const [userForm, setUserForm] = useState({ name: '', email: '', phone: '', city: '' }); @@ -101,6 +103,19 @@ export default function ProfessionalDetailPage() { } }; + const consultRethus = async () => { + setLoadingRethus(true); + setRethusResult(null); + try { + const data = await api.get(`/verifik/rethus/professional/${id}`); + setRethusResult(data); + } catch (e: any) { + toast.error(e?.message || 'Error al consultar RETHUS'); + } finally { + setLoadingRethus(false); + } + }; + const validateRethus = async () => { setValidatingRethus(true); try { @@ -349,13 +364,22 @@ export default function ProfessionalDetailPage() { )}
+ {!professional.rethus_validated && (
+ + {rethusResult && ( +
+

{rethusResult.fullName || `${rethusResult.firstName} ${rethusResult.lastName}`}

+

Estado: + + {rethusResult.rethus?.status || '—'} + +

+ {rethusResult.rethus?.academic?.length > 0 && ( +
+

Títulos registrados:

+ {rethusResult.rethus.academic.map((a: any, i: number) => ( +
+

{a.profession}

+

{a.type} · {a.originDegree} · {a.startDate}

+ {a.entity &&

{a.entity}

} +
+ ))} +
+ )} +
+ )} + {!professional.rethus_validated && (

- Copia el código o cédula, búscalo en el portal de Minsalud y una vez verificado haz clic en "Marcar como validado". + Consulta automáticamente con Verifik o búscalo en el portal de Minsalud. Una vez verificado, haz clic en "Marcar como validado".

)} diff --git a/admin/src/app/settings/page.tsx b/admin/src/app/settings/page.tsx index a130695..6d8eab4 100644 --- a/admin/src/app/settings/page.tsx +++ b/admin/src/app/settings/page.tsx @@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { Save, ExternalLink, Copy, FileText, Shield, Map, - Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server, + Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server, Link, } from 'lucide-react'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1'; @@ -57,6 +57,13 @@ export default function SettingsPage() { const [policies, setPolicies] = useState>({ privacy: '', terms: '' }); const [savingPolicy, setSavingPolicy] = useState(null); + // Verifik + const [verifikConnected, setVerifikConnected] = useState(false); + const [verifikEmail, setVerifikEmail] = useState(''); + const [verifikOtp, setVerifikOtp] = useState(''); + const [verifikOtpSent, setVerifikOtpSent] = useState(false); + const [verifikLoading, setVerifikLoading] = useState(false); + // SMTP const [smtp, setSmtp] = useState({ host: '', port: '587', user: '', pass: '', from: '' }); const [showSmtpPass, setShowSmtpPass] = useState(false); @@ -69,12 +76,14 @@ export default function SettingsPage() { api.get<{ configured: boolean }>('/settings/maps-key'), api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'), api.get('/settings/smtp').catch(() => null), + api.get<{ connected: boolean }>('/verifik/status').catch(() => ({ connected: false })), ]) - .then(([globalData, mapsData, policiesData, smtpData]) => { + .then(([globalData, mapsData, policiesData, smtpData, verifikStatus]) => { setGlobal(globalData || {}); setMapsConfigured(mapsData.configured); setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' }); if (smtpData) setSmtp({ host: smtpData.host || '', port: smtpData.port || '587', user: smtpData.user || '', pass: smtpData.pass || '', from: smtpData.from || '' }); + setVerifikConnected(verifikStatus.connected); }) .catch(() => toast.error('Error al cargar configuración')) .finally(() => setLoading(false)); @@ -134,6 +143,48 @@ export default function SettingsPage() { } }; + const sendVerifikOtp = async () => { + if (!verifikEmail.trim()) return toast.error('Ingresa tu email de Verifik'); + setVerifikLoading(true); + try { + await api.post('/verifik/send-otp', { email: verifikEmail.trim() }); + setVerifikOtpSent(true); + toast.success('OTP enviado a tu correo'); + } catch (e: any) { + toast.error(e?.message || 'Error al enviar OTP'); + } finally { + setVerifikLoading(false); + } + }; + + const confirmVerifikOtp = async () => { + if (!verifikOtp.trim()) return toast.error('Ingresa el código OTP'); + setVerifikLoading(true); + try { + await api.post('/verifik/confirm', { email: verifikEmail.trim(), otp: verifikOtp.trim() }); + setVerifikConnected(true); + setVerifikOtpSent(false); + setVerifikOtp(''); + toast.success('Verifik conectado correctamente'); + } catch (e: any) { + toast.error(e?.message || 'Error al confirmar OTP'); + } finally { + setVerifikLoading(false); + } + }; + + const refreshVerifikToken = async () => { + setVerifikLoading(true); + try { + await api.post('/verifik/refresh', {}); + toast.success('Token renovado'); + } catch (e: any) { + toast.error(e?.message || 'Error al renovar token'); + } finally { + setVerifikLoading(false); + } + }; + const copyLink = (key: string) => { navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`); toast.success('Enlace copiado'); @@ -418,6 +469,66 @@ export default function SettingsPage() { + {/* ── Verifik ── */} +
+

Verifik — Validación de profesionales

+ + + + + Cuenta Verifik + + + Permite consultar RETHUS (registro de profesionales de salud) directamente desde el perfil de cada profesional. + + + +
+ {verifikConnected + ? <>Conectado + : <>No conectado} +
+ + {!verifikOtpSent ? ( +
+ setVerifikEmail(e.target.value)} + className="flex-1" + /> + +
+ ) : ( +
+

Ingresa el código que llegó a {verifikEmail}

+
+ setVerifikOtp(e.target.value)} + className="w-40 font-mono" + /> + + +
+
+ )} + + {verifikConnected && ( + + )} +
+
+
+ {/* ── Documentos legales ── */}

Documentos legales

diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1a34e33..28054f2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { StorageModule } from './storage/storage.module'; import { NotificationsModule } from './notifications/notifications.module'; import { SmsModule } from './sms/sms.module'; import { SuggestionsModule } from './suggestions/suggestions.module'; +import { VerifikModule } from './verifik/verifik.module'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module'; NotificationsModule, SmsModule, SuggestionsModule, + VerifikModule, ], }) export class AppModule {} diff --git a/backend/src/verifik/verifik.controller.ts b/backend/src/verifik/verifik.controller.ts new file mode 100644 index 0000000..0578b71 --- /dev/null +++ b/backend/src/verifik/verifik.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Post, Get, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { VerifikService } from './verifik.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; + +@ApiTags('Verifik') +@Controller('verifik') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class VerifikController { + constructor(private verifik: VerifikService) {} + + @Get('status') + status() { return this.verifik.getStatus(); } + + @Post('send-otp') + sendOtp(@Body() body: { email: string }) { + return this.verifik.sendOtp(body.email); + } + + @Post('confirm') + confirm(@Body() body: { email: string; otp: string }) { + return this.verifik.confirmOtp(body.email, body.otp); + } + + @Post('refresh') + refresh() { return this.verifik.refreshToken(); } + + // Query by professional ID — looks up cedula from DB + @Get('rethus/professional/:id') + rethusByProfessional(@Param('id') id: string) { + return this.verifik.queryRethusByProfessional(id); + } + + // Direct query by document + @Get('rethus') + rethus( + @Query('documentType') documentType: string, + @Query('documentNumber') documentNumber: string, + ) { + return this.verifik.queryRethus(documentType, documentNumber); + } +} diff --git a/backend/src/verifik/verifik.module.ts b/backend/src/verifik/verifik.module.ts new file mode 100644 index 0000000..579e01d --- /dev/null +++ b/backend/src/verifik/verifik.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { VerifikService } from './verifik.service'; +import { VerifikController } from './verifik.controller'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + providers: [VerifikService], + controllers: [VerifikController], + exports: [VerifikService], +}) +export class VerifikModule {} diff --git a/backend/src/verifik/verifik.service.ts b/backend/src/verifik/verifik.service.ts new file mode 100644 index 0000000..a995dcf --- /dev/null +++ b/backend/src/verifik/verifik.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +const BASE = 'https://api.verifik.co'; + +@Injectable() +export class VerifikService { + private readonly logger = new Logger(VerifikService.name); + + constructor(private prisma: PrismaService) {} + + // ── token storage ────────────────────────────────────────────────────────── + + private async getStoredToken(): Promise { + const s = await this.prisma.settings.findUnique({ where: { key: 'verifik_config' } }); + return (s?.value as any)?.token ?? null; + } + + private async saveToken(token: string) { + await this.prisma.settings.upsert({ + where: { key: 'verifik_config' }, + create: { key: 'verifik_config', value: { token } }, + update: { value: { token } }, + }); + } + + // ── auth flow ────────────────────────────────────────────────────────────── + + async sendOtp(email: string) { + const res = await fetch(`${BASE}/v2/projects/email-login?email=${encodeURIComponent(email)}`, { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as any).message || `Error ${res.status}`); + } + return { message: 'OTP enviado al correo' }; + } + + async confirmOtp(email: string, otp: string) { + const res = await fetch(`${BASE}/v2/projects/email-login/confirm`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, otp }), + }); + const body = await res.json().catch(() => ({})) as any; + if (!res.ok) throw new Error(body.message || `Error ${res.status}`); + const token: string = body.data?.accessToken ?? body.accessToken; + if (!token) throw new Error('No se recibió token'); + await this.saveToken(token); + return { message: 'Conectado correctamente' }; + } + + async refreshToken() { + const token = await this.getStoredToken(); + if (!token) throw new Error('No hay token guardado'); + const res = await fetch(`${BASE}/v2/auth/session?origin=refresh&expiresIn=1`, { + headers: { Accept: 'application/json', Authorization: token }, + }); + const body = await res.json().catch(() => ({})) as any; + if (!res.ok) throw new Error(body.message || `Error ${res.status}`); + const newToken: string = body.accessToken ?? body.data?.accessToken; + if (!newToken) throw new Error('No se recibió token renovado'); + await this.saveToken(newToken); + return { message: 'Token renovado' }; + } + + async getStatus() { + const token = await this.getStoredToken(); + if (!token) return { connected: false }; + // Quick validation — session endpoint with no origin param just validates + const res = await fetch(`${BASE}/v2/auth/session`, { + headers: { Accept: 'application/json', Authorization: token }, + }); + return { connected: res.ok }; + } + + // ── RETHUS lookup ────────────────────────────────────────────────────────── + + async queryRethusByProfessional(professionalId: string) { + const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } }); + if (!prof) throw new Error('Profesional no encontrado'); + if (!prof.identification) throw new Error('El profesional no tiene número de cédula registrado'); + return this.queryRethus('CC', prof.identification); + } + + async queryRethus(documentType: string, documentNumber: string) { + let token = await this.getStoredToken(); + if (!token) throw new Error('Verifik no está configurado. Conecta tu cuenta en Configuración.'); + + const call = async (t: string) => + fetch(`${BASE}/v2/co/cedula/rethus?documentType=${documentType}&documentNumber=${documentNumber}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${t}` }, + }); + + let res = await call(token); + + // Auto-refresh on 401 + if (res.status === 401) { + this.logger.log('Token Verifik expirado, renovando…'); + await this.refreshToken(); + token = await this.getStoredToken(); + res = await call(token!); + } + + const body = await res.json().catch(() => ({})) as any; + if (!res.ok) throw new Error(body.message || `Error Verifik ${res.status}`); + return body.data ?? body; + } +}